Cet article ne s’adresse peut-être pas à tout le monde. Pour le comprendre, vous devez au moins être en passe de devenir développeur frontend ou full-stack, avoir une connaissance élémentaire de concepts tels que SSR, SSG et ISR, et avoir déjà utilisé Next.js au moins une fois.
Préface
C’est peut-être parce que j’éprouve naturellement une grande curiosité pour les principes fondamentaux des choses et que je m’obstine parfois à vouloir comprendre précisément pourquoi on ne pourrait pas procéder de telle ou telle manière. J’imagine que c’est ce qui me permet, de temps à autre, d’avoir des idées originales, et celle-ci en fait partie. Elle m’est venue dès novembre 2025 et j’en ai alors discuté avec beaucoup d’amis autour de moi, mais les conflits de calendrier avec les projets que j’avais en cours m’ont sans cesse fait repousser sa réalisation. Ce n’est qu’aux alentours du Nouvel An 2026 que j’en ai réellement conçu une première version. Je l’ai ensuite de nouveau mise de côté — parce que je suis parti créer un site. Le blog n’est toujours pas entièrement terminé, mais j’ai enfin un endroit à peu près convenable où consigner des choses ; j’aimerais donc ressortir cette idée et en reparler.
Tout d’abord, je tiens à préciser une chose : je ne dis pas que SSR est mauvais. Je pense simplement que, d’après la tendance que j’observe, trop de gens en font un usage abusif et que le mouvement général consiste à suivre le courant pour éluder un problème. J’en ai discuté avec pas mal de personnes, mais leurs réponses étaient pour la plupart assez incertaines. J’ai aussi exposé mon idée, mais mes interlocuteurs craignent toujours qu’elle ne soulève toutes sortes de problèmes et de cas limites. Et, pour être honnête, je n’ai pas vraiment envie de réexpliquer chaque fois l’intégralité de mon raisonnement du début à la fin. Aujourd’hui, je vais donc tout simplement le consigner en détail et tenter de clarifier certaines interrogations ainsi que les principes techniques en jeu.
L’abus du SSR
Quand j’ai débuté dans le développement front-end web, le premier framework full-stack que j’ai découvert était probablement Next.js. Je reconnais que c’est un excellent framework. Il présente certes quelques problèmes de performances, mais ceux-ci sont le prix bien réel à payer pour son expérience de développement, ce qui est tout à fait défendable. Cependant, à mesure que j’approfondissais ma compréhension des concepts et des connaissances liés au front-end, j’ai constaté qu’il me convenait peut-être de moins en moins. J’aime beaucoup écrire en Rust, étudier les couches basses et j’ai déjà expérimenté les systèmes embarqués ; j’ai donc une exigence particulière en matière de performances. Cela ne signifie pas que tout doit être poussé à l’extrême, mais plutôt que, lorsqu’il existe déjà une méthode nettement plus performante qui ne demanderait peut-être que très peu d’efforts, je me demande pourquoi personne ne l’adopte. Ainsi, chaque fois que j’utilise Next, je me pose la question suivante : même pour une page très simple dont presque tout est fixe et où un seul élément doit changer, pourquoi faut-il effectuer de nouveau le rendu de la page entière ? Pourquoi ? Selon moi, même si le back-end n’est pas écrit en Rust, même si l’on ne recherche pas les performances extrêmes et même si le programme s’exécute dans un interpréteur JavaScript, la petite quantité de données qui change réellement devrait représenter un coût relativement faible ; le « rendu » devrait pouvoir être effectué à très faible coût, au lieu de refaire celui de la page entière.
Par la suite, c’est devenu un obstacle intérieur, une question à laquelle je ne pouvais m’empêcher de réfléchir. Pourtant, après avoir parcouru près de dix ans d’évolution du développement frontal, ainsi que Vue, Svelte, Solid et leurs frameworks Next.js, TanStack Start, Remix, Nuxt, Sveltekit et Soild Start, j’ai ironiquement eu l’impression que tout le monde s’était égaré. Le RSC de React, en particulier, est une erreur, même si son existence s’explique en réalité ; j’aurai peut-être l’occasion d’y revenir en détail plus tard...
À mon avis, RSC et toute une série de frameworks SSR abusent en réalité de render. SSG correspond certes au style que je recherche — les pages sont générées lors de la compilation et n’ont pas besoin d’être rendues à nouveau à chaque requête —, mais il n’offre pas de véritables capacités dynamiques (x). Quant à l’ISR, il semble trouver un équilibre entre SSG et SSR et apporter une certaine souplesse, mais, au fond, il ne répond pas à mes attentes. Je pense que l’ISR est apparu essentiellement pour compenser les mauvaises performances du SSR ; les diverses stratégies ultérieures de mise en cache sur CDN et autres procédés du même genre ne sont eux aussi que des solutions de contournement, et non des solutions véritablement élégantes.
Bien sûr, le SSR n’est pas une erreur en soi ; c’est une excellente technique qui répond à de nombreux besoins réels. Le problème, c’est que ces frameworks en ont fait l’option par défaut de façon excessive, si bien que, dans peut-être 95 % des cas où les développeurs utilisent effectivement le SSR, ils n’en ont absolument pas besoin. La majeure partie du contenu d’une page est fixe et les éléments véritablement dynamiques sont très rares, mais l’intégralité de l’arbre des composants est tout de même exécutée à chaque requête.
Ces dernières années, React s’est tout de même quelque peu amélioré : React 19.2 propose le PPR, mais celui-ci entraîne encore un renderToString() coûteux à l’exécution et relève donc davantage d’une solution de contournement que d’une véritable solution. Je me suis alors demandé : puisque la plupart des éléments sont déjà déterminés lors de la construction, pourquoi ne pas effectuer le rendu directement à la compilation ?
Rendu à la compilation ?
Effectuer le rendu à la compilation paraît assez absurde, car la raison d’être fondamentale du rendu côté serveur est précisément que certaines valeurs et conditions ne peuvent être résolues qu’au moment où elles se présentent à l’exécution : tant qu’on ignore ce qu’elles sont, il est impossible de trancher. Il est donc compréhensible que la plupart des gens rejettent d’emblée mon idée, et leur réaction n’est pas déraisonnable.
Mais j’ai en fait imaginé un Pipeline assez ingénieux pour y parvenir, et c’est précisément pourquoi je dois appeler cela une sorte de Protocol. Par ailleurs, j’ai toujours trouvé particulièrement stupide que les frameworks full-stack brouillent la frontière entre le frontal et le dorsal. (Même s’il faut reconnaître que, ces dernières années, Next.js a offert une excellente expérience de développement sur ce plan, donnant ainsi à beaucoup de débutants l’impression que créer une application couvrant toute la pile est très simple, alors qu’en réalité cela engendre aussi de nombreux risques de sécurité — mais je m’égare.)
Il en va de même pour la récupération des données dans les composants : je préfère une approche aux limites clairement définies — les composants doivent rester purs, et la récupération des données doit en être entièrement extraite. Dès lors que l’on accepte ce principe et que l’on sépare les composants purs des données, les choses deviennent intéressantes : on découvre que les données elles-mêmes peuvent en réalité être classées.
Je dois également remercier TypeScript de m’avoir inspiré cette idée. Ce qu’est concrètement une « valeur » au moment de la compilation importe peu ; ce qui compte, c’est son type. Quel que soit le contenu à placer dans un slot, tant qu’il ne s’agit pas de quelque chose comme Open String (une chaîne ayant une infinité de valeurs possibles), on peut l’encapsuler dans un type représentant un nombre fini de possibilités. Par exemple, lorsqu’on développe un tableau de bord et qu’on doit afficher une section sous condition, on se ramène simplement à des cas comme utilisateur ou administrateur, autrement dit à des types qu’il est toujours possible de définir exhaustivement. Dans le monde réel, presque toutes les situations exigeant un affichage conditionnel ou une décision logique peuvent être ramenées à quelques possibilités bien déterminées.
Et justement, il existe une excellente façon de décrire ces possibilités : JTD !
Spécification JTD
La JTD (définition de types JSON), définie par la RFC 8927, comporte huit Schema Forms : Empty, Ref, Type (booléen, chaîne de caractères, horodatage et types numériques de différentes précisions), Enum, Elements, Properties, Values et Discriminator ; en outre, tout schéma peut être marqué nullable. L’avantage de la JTD est qu’elle fonctionne dans plusieurs langages : il existe des correspondances de types pour JavaScript, Rust, Go et presque tous les autres langages. Elle constitue donc naturellement un pont entre le frontend et le backend, tandis que son format, JSON, est déjà le plus petit dénominateur commun entre le frontend et le backend. Dans la réalité, 95% des applications web ne font guère plus qu’afficher des chaînes de caractères ou des champs numériques, ou prendre des décisions à partir de valeurs booléennes, autant de cas qui entrent dans ce cadre. Une fois ce point établi, notre marge de manœuvre devient en réalité très vaste. Bien entendu, la JTD n’est pas parfaite et connaît des exceptions, comme Markdown, mais nous y reviendrons en détail plus loin. C’est précisément là que je reconnais véritablement l’intérêt de SSR.
Valeur sentinelle
Commençons par le plus simple : puisque nous connaissons déjà le type de chaque valeur dynamique, nous pouvons exécuter une fois le composant React avec renderToString() lors de la compilation, non pas avec de vraies données, mais avec des données simulées à l’aide de Sentinel. Qu’est-ce que cela signifie exactement ?
{ user: { name: "Alice", age: 30 } }Supposons que vos données se présentent ainsi ; nous pouvons alors les remplacer par
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }Ces %%SEAM:...%% sont des sentinelles qui occupent l’emplacement de chaque valeur dynamique. Une fois que React a exécuté renderToString() avec ces données de sentinelle, ces emplacements sont marqués dans le HTML généré. Le pipeline de compilation convertit ensuite les sentinelles en marqueurs d’emplacement sous forme de commentaires HTML.
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>À ce stade, vous avez peut-être déjà compris que ces slot sont des « emplacements munis d’un marqueur de type ». Le rôle de l’environnement d’exécution côté serveur devient alors extrêmement simple : récupérer les données réelles et insérer les valeurs dans ces trous, par un simple remplacement de chaînes de caractères. Nul besoin de renderToString(), d’un environnement d’exécution JavaScript ou de vDOM. Tout langage capable d’analyser des commentaires HTML et d’effectuer un remplacement de chaînes de caractères peut servir de moteur côté serveur : Rust, Go et TypeScript conviennent tous. Voilà pourquoi il s’agit d’un protocole et non d’un framework.
À ce stade, vous vous demandez peut-être comment gérer le rendu conditionnel. Par exemple, lorsqu’un champ vaut null, tout un bloc de contenu ne devrait pas apparaître. En réalité, cela ne nécessite pas non plus de décision prise par JavaScript à l’exécution : le protocole peut simplement définir ce cas.
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->Il en va de même pour le rendu conditionnel et le rendu de listes.
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->Même la correspondance de motifs
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->C’est tout l’art des commentaires : ils ont été choisis ici simplement parce qu’ils constituent par hasard du HTML valide, rien de plus. Mais comment ces blocs conditionnels et ces boucles sont-ils déterminés et générés au moment de la compilation ? L’astuce est en réalité assez ingénieuse : même sans DOM virtuel, nous pouvons effectuer deux rendus du HTML et en comparer les différences. Prenons le rendu conditionnel : on effectue d’abord un rendu avec l’intégralité des données sentinelles, puis on attribue null à un champ nullable donné avant d’effectuer un nouveau rendu. En comparant les deux sorties, la portion de HTML disparue correspond au bloc conditionnel contrôlé par ce champ ; il suffit alors de l’envelopper directement dans <!--seam:if:...-->. Le principe est identique pour les tableaux.
Peut-être le CTR ?
Quelqu’un va forcément s’inquiéter à nouveau : le nombre de combinaisons du rendu conditionnel ne risque-t-il pas d’exploser de façon exponentielle ? Si, par exemple, trois à cinq variables contrôlent le rendu conditionnel et que chacune admet dix valeurs possibles, leur produit donne effectivement un nombre qui paraît très élevé. En réalité, ce nombre est surtout impressionnant en apparence. Premièrement, il n’est pas nécessaire d’énumérer le contenu réel de nombreuses valeurs de type chaîne : seule compte la présence ou l’absence d’une valeur nullable, ce qui ne représente en pratique que deux possibilités. Deuxièmement, le type d’un champ véritablement utilisé dans une condition est nécessairement énumérable ; vous n’allez tout de même pas utiliser un open string pour effectuer un test if, n’est-ce pas ? Et même s’il fallait réellement parcourir toutes les combinaisons, un CPU moderne en viendrait probablement à bout en quelques ms. De plus, toute cette charge intervient à la compilation, comme lors de la compilation de Rust : on paie un coût relativement élevé à ce stade, mais à l’exécution il ne reste plus qu’un simple remplacement de chaînes. Quel que soit le calcul, l’échange est avantageux, d’autant qu’il n’y a ici aucune magie particulière. Je peux toutefois glisser un petit indice : plus loin, j’introduis un minuscule environnement d’exécution JavaScript embarqué afin d’effectuer certaines déductions complexes. C’est un compromis ; j’en expliquerai ensuite la raison, ainsi que le fait qu’il soit théoriquement possible de s’en passer.
C’est ce que j’appelle le CTR (rendu à la compilation). Ses limites sont exactement les mêmes que celles du SSR : par exemple, <!--seam:path--> applique automatiquement un échappement HTML lors de l’insertion de texte (&, <, >, etc.) ; pour insérer du HTML brut, il faut employer explicitement <!--seam:path:html-->. Un chemin de données manquant devient une chaîne vide dans le texte slot, tandis que l’injection est omise dans l’attribut slot. Enfin, un bloc each est tout simplement ignoré s’il reçoit autre chose qu’un tableau. Ces comportements ne diffèrent pas fondamentalement des cas limites rencontrés avec un framework SSR : j’ai simplement déplacé l’étape de rendu du SSR vers la compilation, rien de plus. Lors de la construction, le CTR parcourt le produit cartésien de toutes les valeurs typées possibles des variables conditionnelles, en utilisant soit des valeurs fictives arbitraires générées automatiquement et conformes aux types, soit des valeurs fictives particulières définies par l’utilisateur (il n’est généralement pas nécessaire de fournir manuellement les valeurs requises pour générer toutes les variantes HTML, mais cela reste possible ; sinon, une valeur conforme à chaque type est choisie automatiquement). Chaque combinaison est rendue une fois, puis les résultats sont comparés afin de repérer les limites de tous les blocs conditionnels et de toutes les boucles, ce qui produit finalement un squelette HTML entièrement déployé. Mathématiquement, tant que les données fournies à l’exécution respectent ces définitions de types, elles peuvent toujours être injectées correctement dans ce squelette, puisque tous les chemins de branchement possibles ont déjà été énumérés exhaustivement lors de la compilation.
Cohérence ?
Comment garantir cette cohérence ? La réponse tient au contrat JTD : même si le frontend et le backend sont séparés, dès lors qu’ils respectent le même schéma JTD, leurs types de données restent alignés et aucune incohérence ne peut survenir.
Mais je devais relever un défi supplémentaire par rapport aux autres frameworks : puisque la partie serveur était libérée de l’environnement d’exécution JavaScript, elle n’avait plus à faire corps avec la partie cliente, et rien n’empêche de l’écrire en Rust ou en Go. Dans un framework TypeScript couvrant toute la pile, la cohérence entre les parties cliente et serveur peut être garantie directement par les types ; mais qu’en est-il des autres langages ? En pratique, j’ai utilisé ici la génération de code : quel que soit le langage du serveur, celui-ci sert de référence, et les variables et types accessibles à la partie cliente sont directement générés en TypeScript pour qu’elle puisse les importer. Ainsi, même si la frontière entre les deux parties est clairement tracée, elles peuvent vivre dans le même dossier comme dans un framework TypeScript couvrant toute la pile, conformément aux usages d’un dépôt unique, et s’appeler directement sans écrire manuellement d’interfaces de programmation ni quoi que ce soit comme gen OpenAPI. J’ai en fait choisi le chemin privé /_seam/ comme point de terminaison par défaut du framework, configurable comme dans Nuxt ; il suffit alors d’exécuter JTD Typed-RPC pour régler les problèmes de transfert de données et de CORS.
CTR × SSR
Vient ensuite raw HTML slot, qui nous ramène justement aux 5 % de cas limites : pour des éléments tels que Markdown et Rich Text, il est tout simplement impossible de connaître leur valeur à la compilation, et les contraindre à l’aide de types serait extrêmement coûteux. On pourrait bien sûr étendre le protocole, énumérer toute la syntaxe Markdown, puis l’analyser à l’exécution, mais cela reviendrait pratiquement à réécrire un moteur de rendu Markdown, non ? Or, ne contraindre que les quelques types strictement nécessaires ne représente absolument pas la même charge de travail que de réécrire l’intégralité du moteur de rendu. Cela nous ramène aussi à la philosophie évoquée au début : résoudre les problèmes plus élégamment et à moindre coût.
La bonne nouvelle, c’est donc que raw HTML slot permet bel et bien à CTR et SSR de coexister. Cela signifie que l’on peut réaliser la majeure partie de l’interface utilisateur d’une page avec CTR, pour un coût d’exécution presque nul, puis afficher l’article Markdown central avec SSR. Comme le serveur n’est désormais plus soumis à ces contraintes, on peut soit importer en TypeScript la méthode d’affichage existante de SSR, soit élargir complètement son approche ! Si le serveur utilise Rust, on peut bien sûr effectuer l’affichage avec un compilateur Markdown pour Rust. Du moment que le résultat final est une chaîne HTML insérée dans raw slot, elle pourra s’afficher. Utiliser CTR ne signifie pas que l’on ne peut pas utiliser SSR : les deux peuvent parfaitement coexister.
L'écart de PPR
Vu sous cet angle, CTR représente essentiellement la forme idéale du PPR (prérendu partiel) : tout ce qui peut être mis en cache est entièrement rendu lors de la compilation, sans aucun coût à l’exécution, et seules les quelques données qui changent réellement ont un coût. En quoi cela diffère-t-il donc du PPR de React 19.2 ? La réponse est que mon approche est plus radicale et plus poussée. PPR Même dans le cas idéal où toutes les parties statiques sont mises en cache et où seul un petit fragment de contenu dynamique doit être actualisé, React doit tout de même réexécuter renderToReadableStream(), quand bien même ce contenu ne serait qu’une simple chaîne de caractères ayant changé, et ce coût est loin d’être négligeable.
En revanche, CTR délimite très clairement cette frontière : les types simples, comme les chaînes de caractères, les nombres et les booléens, font l’objet d’un remplacement direct de chaîne ; seuls les types complexes comme Markdown, dont l’énumération exhaustive serait extrêmement coûteuse, passent par un véritable processus de rendu. De plus, le rendu ne désigne plus ici le SSR au sens traditionnel : il peut s’agir de n’importe quel mode de rendu implémenté dans n’importe quel langage de programmation.
Et RSC
Parlons maintenant des RSC (composants serveur React). J’ai dit précédemment que les RSC étaient peut-être une erreur, parce qu’ils brouillaient excessivement les frontières entre le frontal et le dorsal, introduisaient de nombreux risques de sécurité et quantité de CVE. Mais, en réalité, la faute revient davantage à Next.js et aux autres approches SSR : les utilisateurs n’ont tout simplement pas la possibilité de s’en passer. Dès qu’ils veulent du contenu dynamique, ils doivent recourir au SSR, alors qu’un remplacement de chaînes comme le nôtre fonctionne tout aussi bien. Il faut néanmoins reconnaître que les RSC confèrent au serveur une capacité essentielle : afficher n’importe quel composant React, autrement dit exécuter n’importe quel code.
Mais dès lors qu’il faut exécuter du code arbitraire, il devient pratiquement impossible de le décrire exhaustivement avec des types. Et même si c’était possible, le travail requis ne serait pas forcément moindre que celui nécessaire pour écrire un compilateur React. Une capacité comme celle de RSC a donc bien une raison d’être. CTR et RSC peuvent-ils coexister ? Oui, tout à fait, et sans aucun conflit. Cela relève toutefois de la couche du framework, et non du protocole lui-même. Je ne l’ai pas encore implémenté, mais il semble possible de reprendre l’approche de TanStack Start : il suffirait d’envoyer un paquet html et un paquet js supplémentaires. En théorie, l’implémentation devrait être assez simple ; à tout le moins, la charge de travail est clairement délimitée.
Pour revenir à raw HTML slot : ce qu'il résout, ce sont surtout des cas comme Markdown ou Rich Text — le HTML rendu est injecté via dangerouslySetInnerHTML, et après l'hydratation cette zone ne participe plus à aucune interaction, elle est "morte". Cette partie doit rester la plus réduite possible : si vous voulez ajouter des bordures ou du style à l'article, cela s'écrit en composants React, et non mêlé à ce bloc de HTML. L'inconvénient, c'est qu'elle ne peut plus changer une fois l'hydratation faite, mais elle ramène vraiment le coût du "SSR" dans ces conditions à un niveau très bas, proche du Zero Cost. Parmi les cas qui réclament réellement du SSR, environ 60% relèvent de cette injection de HTML statique ; seuls les 40% restants ont besoin de ce que propose RSC : exécuter des composants arbitraires côté serveur.
Indépendance de l'UI
Enfin vient la très séduisante indépendance vis-à-vis du protocole. Au fond, il nous suffit de saisir l’unique point essentiel, renderToString ; le framework d’interface utilisé en amont n’a en réalité aucun rapport avec le protocole. Quelle est alors la différence avec Astro ? Mais rassurez-vous : je ne suis certainement pas en train de créer un autre Astro. En apparence, mon concept ressemble un peu aux îlots d’Astro, mais les différences sont en réalité considérables.
Je n’hydrate pas plusieurs environnements d’exécution sur une même page. Hum, en réalité, je pense que les cas où l’on en a vraiment besoin sont très limités. La communication de l’état des composants entre différentes piles technologiques devient extrêmement coûteuse. Il s’agit plutôt d’une solution transitoire pour migrer d’une pile vers une autre lorsqu’il est impossible de tout remplacer d’un seul coup. De plus, Astro est fondamentalement une MPA, tandis que nous pouvons fonctionner comme une MPA avant l’hydratation, puis devenir une SPA après celle-ci, avec un routage côté client comme Next.js permettant des animations entre les pages — une fonctionnalité dont Astro ne peut que rêver.
Astro et SSG
Par ailleurs, la conception d’Astro n’est véritablement utile que dans les cas qui couvrent plusieurs piles technologiques. Si vous l’utilisez pour sa vitesse tout en vous limitant à un seul de ses frameworks—par exemple en important React mais pas Vue—, alors la « rapidité » qu’il revendique me semble être un faux argument. Certes, le premier écran chargé n’est constitué que de HTML, mais à la moindre interaction il faut l’hydrater, ce qui impose de télécharger l’intégralité de l’environnement d’exécution React ; ce n’est fondamentalement pas différent de notre hydratation. Bien sûr, nous pourrons ensuite adopter le concept d’îlots et ajouter un routeur de l’enveloppe applicative afin de permettre une navigation monopage entre différents frameworks d’interface utilisateur, mais cela appartient à une étape ultérieure de la feuille de route et, pour le moment, rien ne presse.
Enfin, par rapport au SSG traditionnel, j’ai obtenu le même résultat que le SSG : en substance, tout ce qui peut être déterminé à la compilation est entièrement rendu. Mais notre solution est plus dynamique, car les valeurs slot de types simples peuvent être entièrement remplacées à l’exécution. On peut voir les choses ainsi : nous avons rendu le SSG sous la forme d’une sorte de point d’entrée pour une MPA. Avant l’hydratation, c’est une MPA ; après, elle devient une SPA, tout en conservant ses capacités « dynamiques ».
Erreur d'hydratation ?
Enfin, il reste cette incohérence d’hydratation que je déteste tant, et je suis certain que vous ne l’appréciez pas davantage. Mais au fond, il ne s’agit que d’une incohérence de l’état DOM. Les frameworks traditionnels, comme l’App Router de Next.js, tentent d’envelopper l’application entière dans React ; dès que le navigateur injecte une balise quelconque du côté de l’utilisateur, une erreur d’hydratation risque donc de se produire. Nous sommes en réalité soumis aux mêmes contraintes que le rendu traditionnel côté serveur, mais lorsque vous utilisez TS, j’ajoute également une enveloppe d’hydratation div appelée __root. La zone hydratée ne couvre ainsi plus la zone des métadonnées, ce qui rend l’hydratation plus robuste. React 19 prend par ailleurs nativement en charge les balises de métadonnées du document telles que <title>, <meta> et <link>. Même si vous n’hydratez qu’un <div> particulier de la page plutôt que l’intégralité du <html>, vous pouvez rendre <title>My Page</title> directement dans un composant : React le remontera automatiquement dans <head>.
Revenons aux divergences d’hydratation : puisque nous connaissons déjà le type de chaque slot à la compilation, nous pouvons ajouter une vérification d’équivalence CTR. Elle consiste à remplir le HTML entièrement développé avec des données simulées déduites des définitions de types, à exécuter une fois le renderToReadableStream() traditionnel, puis à vérifier l’équivalence sémantique des deux arbres DOM sans tenir compte des différences de mise en forme. Leur présentation peut varier légèrement, mais tant que leurs structures DOM sont strictement et entièrement équivalentes, il devient réellement possible de dire définitivement adieu aux divergences d’hydratation elles-mêmes. Pourquoi le SSR traditionnel en est-il incapable ? Parce qu’il n’effectue cette opération qu’à l’exécution, tandis que la structure CTR exige que toutes ces contraintes soient appliquées dès la compilation. Nous proposons bien sûr any comme échappatoire, mais, comme avec TypeScript, utiliser any signifie en assumer volontairement les conséquences : lors de la compilation, la CLI vous avertira que any, l’échappatoire sous forme de chaîne ouverte, peut provoquer une divergence.
Sans serveur
Bien sûr, il ne faut pas non plus écarter l’option sans serveur. Ces dernières années, l’expérience offerte par le sans serveur est devenue excellente ; selon moi, « hormis le coût, cela n’a pratiquement aucun défaut ». Or, CTR se prête naturellement très bien à ce scénario : les opérations effectuées à l’exécution sont légères et peu nombreuses, si bien que le fonctionnement est extrêmement rapide dans un environnement sans serveur, avec une nette amélioration du temps de réponse comme de la surcharge. Les seuls cas qui ne peuvent guère en bénéficier sont ceux comme le rendu de Markdown, mais ils devraient absolument être optimisés dans la logique métier, par exemple en prérendant et en stockant Markdown afin de ne pas devoir le rendre de nouveau à chaque requête, comme le fait mon site actuel. C’est un problème relevant de la couche métier, qu’un framework ne peut pas résoudre ; en revanche, celui-ci peut ramener presque à zéro la surcharge de toutes les autres opérations simples. Par rapport au SSR traditionnel, cette surcharge n’est même plus du même ordre de grandeur. À quel point est-elle faible ? Elle se situe environ entre quelques centaines de microsecondes et 1 milliseconde, ce qui serait inimaginable avec un SSR traditionnel.
Mais que faire si le serveur utilise un autre langage ? Prenons Cloudflare Workers : de nombreuses plateformes sans serveur prennent en charge WASM, si bien que d’autres langages peuvent eux aussi être compilés en binaires WASM et servir côté serveur. En substance, je compile l’interface en ressources entièrement statiques, comme pour un rendu côté client ; mais associées à une passerelle privée, /_seam/, ces ressources offrent les mêmes capacités dynamiques que de véritables frameworks full-stack et sont donc naturellement compatibles avec les plateformes sans serveur.
Seam et SeamJS
Vous êtes peut-être déjà perdu dans la relation entre Seam et SeamJS. C'est en fait très simple : Seam est le protocole. Il définit comment marquer les positions dynamiques avec Sentinel, comment cela se transforme en marqueurs slot, comment se fait la détection diff pour les blocs conditionnels et les boucles, et comment le runtime injecte les données à partir de l'AST. Le protocole lui-même est indépendant du langage : n'importe quel backend capable d'analyser des commentaires HTML et de faire du remplacement de chaînes peut l'implémenter. SeamJS est le framework, une implémentation concrète bâtie sur ce protocole. Il ensemble des roues déjà existantes comme Vite, TanStack Router et TanStack Query, puis comble ce qu'elles ne couvrent pas : mon extraction de skeleton, le moteur d'injection, le CLI, et ainsi de suite.
Mais pour être honnête, SeamJS reste encore très rudimentaire. Il fonctionne sans véritable problème, mais il faudra encore beaucoup de travail avant de pouvoir réellement l’utiliser pour développer des projets. Je réfléchis aussi davantage à certaines évolutions architecturales, comme l’abstraction du canal de transmission des données, sans toutefois savoir si je conserverai cette orientation dans les versions ultérieures. Du côté des frameworks, je créerai sans aucun doute un framework full-stack en TypeScript, ainsi qu’un framework phare utilisant Rust pour le backend. Quant à l’implémentation en Go, je compte la supprimer dans une prochaine version, car je ne pense tout simplement pas avoir assez de temps et d’énergie pour continuer à la maintenir.
Rendering is a protocol, not a render-time computation.
Plus qu’un framework web
À quoi cela sert-il donc ? En réalité, son utilisation ne se limite pas au Web. Par exemple, une fois le canal Transport lui aussi abstrait, il sera possible de porter ultérieurement le système vers des environnements de bureau comme Electron ou Tauri. Il suffira de remplacer le pipeline de transport HTTP par une communication IPC, tout en continuant d’exécuter le même protocole Seam. Ainsi, les applications Electron n’auront plus besoin d’afficher un écran de chargement au démarrage, et de nombreux éléments pourront être rendus immédiatement en local, comme avec le rendu côté serveur. Voilà la magie de CTR !
Le prix du sans-runtime JS
Alors pourquoi SeamJS a-t-il tout de même fini par intégrer un environnement d’exécution JS ? Parce qu’en mettant cette approche en œuvre, j’ai rencontré un problème : l’idéal de CTR impose des contraintes extrêmement strictes aux données du premier affichage. Ces données doivent former une structure déterministe entièrement déductible ; elles ne peuvent même contenir aucune logique de calcul, seulement des conditions. Cela devient excessivement contraignant. Du point de vue de l’expérience de développement offerte par un framework, les personnes habituées à écrire du React de manière classique s’attendent naturellement à pouvoir effectuer quelques calculs dans un composant, puis utiliser directement les valeurs obtenues. Respecter strictement les contraintes de CTR pour atteindre un état ready-to-display rendrait le processus extrêmement fastidieux et pourrait même obliger à écrire deux fois le même composant.
Il existe cependant une solution : en raison du fonctionnement du Web, le front-end ne peut exécuter que du JavaScript, et les composants ne peuvent donc naturellement fonctionner qu’en JS. Pour résoudre ce problème, le back-end doit par conséquent pouvoir exécuter du JS ; un projet TypeScript complet peut simplement utiliser son environnement d’exécution existant. Avec Rust, il suffit d’intégrer un minuscule environnement d’exécution JS tel que QuickJS. Précisons qu’ici, l’environnement d’exécution JavaScript n’implémente qu’un sous-ensemble standard de JS et n’a rien à voir avec ceux de Bun ou de Node, qui fournissent des API complètes de système d’exploitation. Il ne sert réellement qu’à déduire et à dériver des données.
Cela permet de continuer à écrire une partie de la logique de calcul dans le composant : une fois les données reçues, le serveur exécute un petit morceau de JavaScript pour en déduire l’état ready-to-display, puis le renvoie à l’interface afin d’hydrater le premier affichage. CTR est satisfait, puisqu’il obtient des données dérivées à la structure stricte, tandis que l’expérience de développement s’améliore, car le composant ne doit être écrit qu’une seule fois. Bien entendu, si l’on respecte rigoureusement de pures contraintes de types, le serveur peut effectivement fonctionner sans environnement d’exécution JavaScript — cette promesse reste donc techniquement valable. Quoi qu’il en soit, le petit environnement d’exécution JavaScript ajouté présente un coût en performances, une empreinte mémoire et une taille très inférieurs à ceux de solutions telles que Node : il ne représente qu’environ 200 à 300 Ko, tout en apportant une très grande souplesse.
Perspectives d’avenir
Après tout ce discours, quand est-ce qu'on pourra enfin ? Sans doute pas avant très, très longtemps. Ce n'est vraiment pas que je veuille ; c'est que, à ce stade, ce truc me prend beaucoup, beaucoup trop en otage. Si je ne peux m'en servir que pour le développer, alors je n'écris pratiquement plus d'applications : quoi que j'écrive, le framework me fait d'abord un croche-pied. D'autant que ces derniers temps je travaillais surtout sur ce site et ce genre de choses. Alors j'ai fini par comprendre : autant donner d'abord une certaine ampleur au site, comme ça je saurai quels sont vraiment mes besoins. Ensuite, quand je reviendrai à SeamJS, j'aurai une TODO List ; il suffira d'implémenter les fonctions une à une pour que ce soit utilisable. Après quoi je pourrai encore meubler avec une migration et un Benchmark ? Voilà, . L'idée est là. Peu importe si c'est utilisable aujourd'hui ; sur le plan des principes, au moins, le prototype tourne. Bonne nuit 💤