Cet article n'est peut-être pas adapté à tout le monde. Pour comprendre de quoi il s'agit, vous devez au moins être un développeur front-end débutant ou un développeur full-stack, avoir une connaissance de base des concepts tels que SSR, SSG et ISR, et avoir au moins utilisé Next.js.
Préface
Il se peut que je sois né avec une curiosité très forte pour les principes fondamentaux, et parfois je persiste à demander pourquoi on ne peut pas faire les choses de telle manière ; je suppose que c’est ainsi que je parviens parfois à imaginer des idées originales : celle-ci en est un exemple. Elle est apparue dès novembre 2025, j’en ai parlé à de nombreux amis à l’époque, mais à cause de conflits de planning avec d’autres projets, elle a été repoussée jusqu’aux alentours du Nouvel An 2026 où j’ai finalement réalisé une première version. Ensuite elle a de nouveau été mise de côté – parce que je me suis lancé dans la création d’un site. Le blog n’est pas encore totalement opérationnel, mais j’ai au moins un endroit où je peux consigner des choses, donc je veux ressortir ce sujet et le réexpliquer.
Tout d'abord, une précision s'impose : je ne dis pas que SSR est mauvais. J'ai simplement le sentiment, au vu des tendances actuelles, que trop de monde l'utilise à tort et à travers et suit le mouvement pour éviter de confronter un vrai problème. J'en ai discuté avec pas mal de personnes, mais les retours étaient pour la plupart assez indécis ; j'ai également partagé mes idées, mais mes interlocuteurs s'inquiétaient toujours de l'existence de divers problèmes et cas limites. Et pour être honnête, je n'ai pas vraiment envie de réexpliquer l'ensemble de ma réflexion de A à Z à chaque fois. J'ai donc décidé aujourd'hui de tout poser par écrit en détail, afin d'essayer de clarifier certaines interrogations et les principes techniques sous-jacents.
L'abus de SSR
Lorsque j'ai débuté dans le développement frontend Web, le tout premier framework full-stack que j'ai manipulé était probablement Next.js. J'admets que c'est un très bon framework. Même s'il présente quelques problèmes de performance, ceux-ci sont le prix à payer pour une vraie expérience DX, ce qui est tout à fait justifiable. Cependant, au fur et à mesure que j'ai approfondi ma compréhension des concepts et connaissances du frontend, j'ai réalisé qu'il me convenait de moins en moins — j'aime énormément écrire en Rust, j'adore explorer le bas niveau, j'ai touché à l'embarqué, j'ai donc une obsession très particulière pour la performance ; une obsession qui ne cherche pas l'extrême en tout, mais plutôt : lorsqu'il existe un moyen d'obtenir des performances nettement meilleures avec peut-être très peu d'efforts, pourquoi personne ne le fait ? Ainsi, chaque fois que j'utilise Next, je me demande : pourquoi, même pour une page très simple où la majeure partie est fixe et une seule zone doit changer, l'ensemble de la page doit-il être entièrement rendu à nouveau ? Pourquoi ? Selon ma vision des choses, même si le backend n'est pas écrit en Rust et sans chercher l'extrême, même en s'exécutant sur un interpréteur JavaScript, cette petite donnée modifiée devrait représenter un surcoût relativement faible, permettant d'effectuer le « rendu » à très bas coût plutôt que de tout re-rendre.
Au point qu'ultérieurement, c'est devenu un blocage pour moi, un problème sur lequel je ne pouvais m'empêcher de réfléchir. Mais ce qui est ironique, c'est qu'après avoir passé en revue les près de 10 dernières années d'évolution du front-end — Vue, Svelte, Solid et leurs frameworks Next.js, TanStack Start, Remix, Nuxt, Sveltekit et Soild Start —, j'ai eu l'impression que tout le monde avait fait fausse route. En particulier RSC de React, qui est une erreur, même si son existence a en fait ses raisons d'être, un point sur lequel je reviendrai peut-être plus en détail plus tard si l'occasion se présente...
De mon point de vue, RSC et toute une série de frameworks SSR abusent en réalité de render. SSG a certes adopté le style que je recherchais — les pages sont générées au moment du build et n'ont pas besoin d'être restituées à chaque requête —, mais il ne possède pas de vraies capacités dynamiques(x). Quant à ISR, il semble trouver un équilibre entre SSG et SSR en apportant une certaine flexibilité, mais il ne répond pas fondamentalement à mes attentes ; je pense qu'ISR n'a vu le jour que pour compenser les mauvaises performances de SSR, et toutes les opérations ultérieures comme les stratégies de mise en cache CDN ne sont en fait qu'une sorte de workaround, et non une solution vraiment élégante.
Bien sûr, SSR en soi n'est pas une erreur ; c'est une excellente chose qui répond à de nombreux cas d'utilisation réels. Le problème, c'est que ces frameworks ont fait de SSR une option par défaut quasi systématique, si bien que dans 95 % des cas où les développeurs y ont recours, ils n'en ont probablement pas du tout besoin. La majeure partie du contenu d'une page est fixe, les éléments réellement dynamiques étant extrêmement rares, mais chaque requête continue malgré tout de réexécuter l'ensemble de l'arbre de composants.
Cependant, React s'est amélioré ces dernières années : React 19.2 propose PPR, mais cette solution implique encore des renderToString() coûteux au moment de l'exécution, ce qui en fait plutôt un workaround qu'une vraie solution. Je me suis donc demandé : puisque la plupart des éléments sont déjà déterminés au moment du build, pourquoi ne pas effectuer le rendu directement à la compilation ?
Rendu à la compilation ?
Mettre le rendu dans la phase de compilation semble absurde, car la raison d'être essentielle du SSR est que certains valeurs et conditions ne peuvent absolument pas être résolues avant le runtime : on ne sait pas ce qu'elles sont et on ne peut rien trancher. Il est donc tout à fait compréhensible et non sans raison que la plupart des gens rejettent mon idée d'emblée.
Mais en réalité, j'ai pensé à un Pipeline assez ingénieux pour réaliser cela, et c'est aussi pourquoi je dois appeler cela une sorte de Protocol. De plus, j'ai toujours trouvé que flouter la frontière entre le front-end et le back-end avec des frameworks full-stack était une chose très stupide (même s'il faut reconnaître que ces dernières années, Next.js a fait du très bon travail en matière de DX sur ce point, ce qui donne l'impression à beaucoup de débutants qu'écrire une application full-stack est une chose très simple, alors qu'en réalité de nombreuses failles de sécurité en découlent — mais je m'égare).
C'est la même chose pour la récupération de données au sein des composants : je préfère une approche aux limites claires — les composants sont de purs composants, et la récupération des données devrait en être totalement extraite. Et une fois ce préalable accepté, après avoir séparé les composants purs des données, les choses deviennent intéressantes : on s'aperçoit que les données elles-mêmes peuvent en fait être catégorisées.
Je tiens également à remercier TypeScript pour l'inspiration qu'il m'a apportée. En réalité, ce que la soi-disant « valeur » est elle-même au moment de la compilation importe peu, ce qui compte c'est son type ; quel que soit le contenu à remplir dans un slot, tant qu'il ne s'agit pas de quelque chose comme Open String (une chaîne de caractères aux possibilités infinies), il peut être enveloppé dans un type aux possibilités finies. Par exemple, lorsque vous rédigez un dashboard et devez effectuer un rendu conditionnel d'un bloc de contenu, il ne s'agit que de User ou Admin, etc., mais cela reste un type qu'il est possible de définir intégralement ; dans le monde réel, presque tous les endroits nécessitant un rendu conditionnel ou une prise de décision logique peuvent être ramenés à quelques possibilités déterminées.
Et justement, ces possibilités peuvent être décrites par une excellente approche, à savoir JTD !
Spécification JTD
Dans JTD (JSON Type Definition) RFC 8927, huit Schema Form sont définies : Empty, Ref, Type (boolean, string, timestamp ainsi que divers types numériques de différentes précisions), Enum, Elements, Properties, Values, Discriminator, et tout schema peut également être marqué comme nullable. L'avantage de JTD est qu'il est agnostique du langage : JavaScript, Rust, Go—presque tous les langages possèdent un mappage de types correspondant, ce qui en fait naturellement un pont entre le frontend et le backend. De plus, son support est le JSON, qui est à la base le plus petit dénominateur commun entre frontend et backend. Dans le monde réel, 95 % des applications web ne font rien d'autre qu'afficher des chaînes de caractères, des champs numériques ou faire des évaluations avec des booléens, ce qui entre entièrement dans ce cadre. Une fois cela clarifié, la marge de manœuvre dont nous disposons devient très vaste. Bien sûr, JTD n'est pas parfait et il existe des exceptions comme Markdown, mais nous y reviendrons en détail plus tard ; c'est d'ailleurs ici que je reconnais vraiment toute la valeur de SSR.
Sentinel
Commençons par le plus simple : puisque nous savons déjà que chaque valeur dynamique a un type, nous pouvons faire une chose au moment de la compilation : exécuter le composant React avec renderToString(), mais pas avec de vraies données, plutôt avec des données factices créées avec Sentinel. Qu'est-ce que cela signifie ?
{ user: { name: "Alice", age: 30 } }En supposant que vos données ressemblent à ceci, nous pouvons les remplacer par :
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }Ces %%SEAM:...%% sont les Sentinel ; ils occupent l'emplacement de chaque valeur dynamique. Une fois que React a exécuté renderToString() avec ces données Sentinel, ces emplacements sont marqués dans l'HTML de sortie. Ensuite, le pipeline de build transforme ces sentinelles en balises slot 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 typés ». À partir de là, ce que le runtime côté serveur doit faire devient extrêmement simple : récupérer les données réelles et les insérer dans ces trous, une pure substitution de chaînes. Aucun besoin de renderToString(), aucun besoin de runtime JavaScript, aucun besoin de vDOM. N'importe quel langage capable d'analyser des commentaires HTML et de faire de la substitution de chaînes peut servir de backend -- Rust, Go, TypeScript, tous conviennent. C'est pour cela que c'est un protocole et non un framework.
À ce stade, vous vous demandez peut-être : comment gérer le rendu conditionnel ? Par exemple, lorsqu'un champ est null, tout un bloc de contenu ne devrait pas apparaître. Mais en réalité, cela ne nécessite pas non plus de JavaScript au moment de l'exécution pour être évalué ; au contraire, le protocole peut définir cette situation.
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->Le rendu conditionnel fonctionne ainsi, et il en va de même pour le rendu de listes.
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->Même le filtrage par motif
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->C'est là tout l'art des commentaires : les commentaires ont été choisis ici simplement parce qu'ils s'avèrent être des caractères HTML valides, rien de plus. Alors, comment ces blocs conditionnels et ces boucles sont-ils déterminés et générés au build-time ? C'est en fait une méthode assez astucieuse : bien que nous n'ayons pas de vDOM, nous pouvons rendre l'HTML deux fois pour effectuer un Diff. Prenons l'exemple du rendu conditionnel : la première fois, on effectue le rendu avec la donnée Sentinel complète, et la deuxième fois, on définit un certain champ nullable à null avant de refaire le rendu. En comparant les deux sorties, le bloc HTML qui a disparu correspond au bloc conditionnel contrôlé par ce champ, qu'il suffit alors d'entourer avec <!--seam:if:...-->. La même logique s'applique aux tableaux.
Peut-être CTR ?
Alors quelqu'un va sûrement s'inquiéter à nouveau : les combinaisons de rendu conditionnel ne vont-elles pas exploser de manière exponentielle ? Par exemple, si 3 à 5 variables contrôlent le rendu conditionnel et que chaque variable a 10 possibilités, leur produit donne en effet un nombre qui semble énorme. Mais en réalité, ce chiffre n'est impressionnant qu'en apparence. Premièrement, pour de nombreuses valeurs de type chaîne de caractères, nous n'avons pas vraiment besoin d'en énumérer le contenu ; nous nous soucions seulement de savoir si la valeur existe ou non nullable — ce qui fait en réalité seulement 2 cas. Deuxièmement, pour les champs qui nécessitent vraiment une évaluation conditionnelle, leur type est forcément énumérable — par exemple (vous n'allez tout de même pas utiliser un open string pour faire une évaluation if, n'est-ce pas ?). De plus, même s'il fallait vraiment énumérer toutes les combinaisons, les parcourir sur un CPU moderne ne prendrait peut-être que quelques ms. Et il s'agit entièrement d'un coût à la compilation — tout comme la compilation Rust, on paie un prix assez lourd à la compilation, mais à l'exécution, cela devient une pure substitution de chaînes. Peu importe le calcul, l'opération est rentable, d'autant qu'il n'y a aucune magie particulière ici. On peut toutefois glisser un petit détail ici : plus loin, j'ai en fait introduit un tout petit environnement d'exécution JavaScript embarqué pour effectuer des déductions complexes, mais c'est un compromis ; j'expliquerai les raisons plus tard, ainsi que le fait qu'en théorie, on pourrait s'en passer.
C'est ce que j'appelle le CTR (Compile-Time Rendering). Quant aux limitations, elles sont en fait exactement identiques à celles du SSR. Par exemple, lorsque <!--seam:path--> effectue une insertion de texte, il réalise automatiquement un échappement HTML (&, <, >, etc.) ; si vous devez insérer du HTML brut, vous devez utiliser explicitement <!--seam:path:html--> ; les chemins de données manquants deviennent des chaînes vides dans le texte slot et sautent l'injection dans l'attribut slot ; et si le bloc each ne reçoit pas un tableau, il est directement ignoré. Ces comportements ne diffèrent pas fondamentalement des cas limites que vous rencontrez dans les frameworks SSR—j'ai simplement déplacé l'étape de rendu du SSR pour l'exécuter au moment de la compilation, c'est tout. De son côté, le CTR effectue au moment du build-time une traversée en produit cartésien sur toutes les valeurs de types des variables conditionnelles, en utilisant soit une valeur Mock quelconque correspondant au type, soit une valeur Mock spéciale surchargée (Override) par l'utilisateur (généralement, il n'est pas nécessaire d'écrire manuellement les valeurs Mock requises pour générer les variantes HTML, mais vous pouvez les fournir manuellement ; sinon, une valeur correspondant à votre type est automatiquement injectée). De cette façon, chaque combinaison est rendue une fois pour différencier (diff) les limites de tous les blocs conditionnels et blocs de boucle, ce qui permet d'obtenir au final un squelette HTML entièrement déployé. D'un point de vue mathématique, tant que les données transmises au runtime se conforment à ces définitions de types, elles seront obligatoirement injectées correctement dans ce squelette, car tous les chemins de branches possibles ont déjà été exhaustativement parcourus lors de la compilation.
Cohérence ?
Alors, comment garantir la cohérence ? En fait, la réponse réside dans ce Contract JTD. Même si le frontend et le backend sont séparés, tant que les deux respectent le même JTD schema, les types de données sont alignés et il n'y a aucun problème d'incohérence.
Mais j'avais un défi de plus que les autres frameworks : puisque le backend est libéré du runtime JavaScript, il n'a plus besoin de ne faire qu'un avec le frontend — vous pouvez tout à fait utiliser Rust ou Go pour écrire le backend. Dans un framework full-stack TypeScript, la cohérence entre le frontend et le backend peut être directement garantie par les types ; mais qu'en est-il pour les autres langages ? En réalité, j'ai utilisé codegen ici : quel que soit le langage utilisé pour le backend, le backend sert de référence, et les variables ainsi que les types utilisables par le frontend sont directement générés par codegen en TypeScript pour être importés par le frontend. Ainsi, même si la frontière entre frontend et backend est clairement définie, ils peuvent en réalité coexister dans un seul dossier comme dans un framework full-stack TypeScript, conformément aux habitudes Monorepo, et s'appeler directement sans avoir à écrire manuellement des API ou des éléments comme gen OpenAPI. En pratique, j'ai utilisé un chemin privé /_seam/ comme point d'accès par défaut du framework (configurable comme dans Nuxt), de sorte que l'exécution de JTD Typed-RPC résout les problèmes de transmission de données et de CORS.
CTR x SSR
Ensuite, il y a raw HTML slot, ce qui nous ramène en fait à ces 5% d'Edge Case. Pour des éléments comme Markdown ou Rich Text, il est tout simplement impossible de connaître leur valeur au moment de la compilation, et les contraindre par des types représenterait un coût très élevé. Bien sûr, on pourrait dire qu'il suffit d'étendre le protocole, d'énumérer toute la syntaxe de Markdown et de l'analyser à l'exécution, mais cette charge de travail équivaudrait pratiquement à réécrire un moteur de rendu Markdown ? Or, ne contraindre que ces quelques types minimaux n'a absolument rien à voir avec le travail requis pour réécrire l'ensemble du moteur de rendu, ce qui nous ramène à la philosophie que j'évoquais au tout début : résoudre les problèmes à moindre coût et de manière plus élégante.
La bonne nouvelle est donc que raw HTML slot permet en fait à CTR et SSR de coexister. Cela signifie que la majeure partie de l'interface utilisateur de votre page est conçue avec CTR, avec un coût d'exécution pratiquement nul ; et pour l'article Markdown le plus central, vous utilisez SSR pour le rendre. Le backend étant désormais libéré, vous pouvez choisir d'utiliser TypeScript pour importer votre méthode de rendu SSR d'origine, ou faire preuve d'ouverture d'esprit ! Si le backend utilise Rust, vous pouvez naturellement utiliser le compilateur Markdown de Rust pour effectuer le rendu. Quoi qu'il en soit, il suffit au final de fournir une chaîne HTML et de l'insérer dans ce raw slot pour qu'elle s'affiche. Utiliser CTR ne signifie pas que vous ne pouvez pas utiliser SSR ; les deux peuvent parfaitement coexister.
L'idéal et la réalité du PPR
En comprenant les choses ainsi, CTR est essentiellement le cas idéal de PPR (Partial Prerendering) : tout ce qui peut être mis en cache est entièrement rendu au moment de la compilation, avec un surcoût d'exécution nul — seule la petite portion de données qui change réellement a un coût. Alors, qu'est-ce qui diffère du PPR de React 19.2 ? La réponse est que je suis plus radical, plus agressif PPR Même dans le cas le plus idéal, où toutes les parties statiques sont déjà mises en cache et où seul un petit fragment de contenu dynamique doit être mis à jour, même si ce contenu dynamique n'est qu'une simple chaîne de caractères qui change, React doit quand même réexécuter renderToReadableStream(), et ce coût n'est pas négligeable.
De son côté, CTR trace cette frontière très clairement : s'il s'agit de types simples comme les chaînes de caractères, les nombres et les booléens, tout se fait par remplacement direct de chaînes ; seuls les types complexes dont le coût d'énumération est extrêmement élevé, comme Markdown, passent par un véritable processus de render. De plus, le render n'est plus ici le concept de SSR au sens traditionnel ; il peut s'agir du mode de rendu de n'importe quel langage de programmation.
RSC est également indispensable
Parlons maintenant de RSC (React Server Component). J'ai mentionné plus tôt que RSC était peut-être une erreur — parce qu'il floutait trop les frontières entre front-end et back-end, apportait pas mal de risques de sécurité et de nombreux CVE — mais en réalité, ce problème vient plutôt de Next.js et d'autres flux SSR : les utilisateurs n'ont même pas la possibilité de ne pas l'utiliser. C'est devenu : si vous voulez du dynamique, il faut du SSR. Mais en pratique, un remplacement de chaîne comme le nôtre fonctionne parfaitement aussi. De plus, il faut bien admettre que RSC confère au côté serveur une capacité très importante, à savoir rendre n'importe quel composant React, c'est-à-dire exécuter du code arbitraire.
Cependant, dès lors qu'il s'agit d'exécuter du code arbitraire, il est pratiquement impossible d'en faire l'énumération exhaustive au moyen des types ; et même si c'était possible, la charge de travail ne serait pas nécessairement inférieure à la création d'un compilateur React. Cette capacité de RSC a donc sa raison d'être. CTR et RSC peuvent-ils coexister ? La réponse est oui, et ils ne sont absolument pas incompatibles. Mais cela relève du framework et n'entre pas dans le périmètre du protocole lui-même. Je ne l'ai pas encore implémenté pour l'instant, mais il semble qu'on puisse s'inspirer de TanStack Start : tant qu'on envoie un paquet html et un paquet js supplémentaires, l'implémentation théorique reste très simple, au moins il s'agit d'une charge de travail clairement identifiable.
Pour en revenir à raw HTML slot, cette solution vise principalement des scénarios comme Markdown et Rich Text : l'HTML rendu est injecté via dangerouslySetInnerHTML, et après l'hydratation, cette zone ne participe à aucune interaction, elle est « morte » ; cette partie du contenu doit être minimisée. Si vous souhaitez ajouter des bordures ou des styles à l'article, ceux-ci doivent être écrits avec des composants React au lieu d'être mélangés dans cet HTML. L'inconvénient de cette approche est qu'elle ne peut pas changer après l'hydratation, mais sous cette condition, elle réduit véritablement le coût de « SSR » à un niveau extrêmement bas, proche du Zero Cost. Dans les scénarios nécessitant réellement du SSR, environ 60 % correspondent à cette injection d'HTML statique ; seuls les 40 % restants requièrent la capacité de RSC à exécuter n'importe quel composant côté serveur.
Agnosticisme de l'UI frontend
Enfin, il y a la très attrayante agnosticité vis-à-vis du protocole : en substance, il nous suffit de saisir ce point clé qu'est renderToString, le framework UI utilisé en amont n'ayant en réalité aucun rapport avec le protocole. Dès lors, quelle est la différence avec Astro ? Mais rassurez-vous, je ne crée certainement pas un autre Astro. En apparence, mon approche ressemble un peu au concept d'îles d'Astro, mais en réalité, les différences sont majeures.
Je n'hydrate pas plusieurs runtimes au sein d'une même page — humm, en fait, je pense que les scénarios où l'on en a vraiment besoin sont très limités. La communication de l'état des composants entre différentes stacks techniques devient extrêmement coûteuse ; cela ressemble plus à une solution de transition lors de la migration d'une stack technique à une autre quand on ne peut pas tout remplacer d'un coup, on passe donc par une étape intermédiaire. Deuxièmement, Astro est essentiellement une MPA, alors que nous pouvons être une MPA avant l'hydratation et devenir une SPA après l'hydratation. Tout comme Next.js, nous disposons d'un routage côté client et pouvons réaliser des animations entre les pages, ce dont Astro rêverait.
Astro et SSG
De plus, la conception d'Astro n'est véritablement utile que dans des scénarios impliquant plusieurs stacks techniques. Si vous l'utilisez uniquement pour sa rapidité tout en ne faisant appel qu'à un seul de ses frameworks – par exemple en intégrant uniquement React et pas Vue –, alors cette soi-disant « rapidité » me semble être une fausse promesse. Il est vrai que le premier affichage est entièrement généré en HTML, mais dès que vous souhaitez la moindre interactivité, il faut procéder à l'hydratation. Or, le coût de cette hydratation équivaut à télécharger l'intégralité du React Runtime, ce qui ne diffère en rien de notre propre hydratation. Bien sûr, nous pourrions par la suite adopter le concept d'Island et ajouter un shell router pour réaliser une navigation SPA multi-frameworks UI, mais cela fait partie d'une Roadmap ultérieure ; du moins, je ne suis pas pressé pour le moment.
Enfin, il y a la comparaison avec le SSG traditionnel. J'ai accompli la même chose que le SSG : essentiellement pré-rendre tout ce qui peut l'être au moment de la compilation. Mais nous sommes plus dynamiques, car les valeurs de slot de types simples peuvent être entièrement remplacées au moment de l'exécution. Vous pouvez voir cela comme le fait de rendre le SSG sous la forme d'un point d'entrée pour une MPA : avant l'hydratation c'est une MPA, après l'hydratation cela devient une SPA, tout en conservant la capacité « dynamique ».
Peut-être dire adieu aux erreurs d'hydratation ?
Enfin, il y a le hydration mismatch que je déteste tant, et je parie que vous ne l'aimez pas non plus ; mais au fond, cela se résume à une incohérence d'état DOM. Les frameworks traditionnels comme l'App Router de Next.js essaient d'envelopper toute l'application dans React, de sorte que si le navigateur côté utilisateur injecte des balises, il y a un risque d'erreur d'hydratation. Nos limites sont en fait les mêmes que celles du SSR traditionnel, mais lorsque vous utilisez TS, j'ai également enveloppé un div d'hydratation appelé __root afin que la zone d'hydratation ne couvre plus la zone metadata, ce qui permet d'obtenir une hydratation plus durable. De plus, React 19 intègre la prise en charge native des balises de métadonnées de document telles que <title>, <meta> et <link>. Même si vous n'hydratez qu'un <div> spécifique dans la page (au lieu de l'ensemble du <html>), le fait de rendre directement <title>My Page</title> dans un composant permettra à React de le hoist automatiquement dans <head>.
Pour en revenir au hydration mismatch, sachant que nous connaissons déjà le type de chaque slot au moment de la compilation, nous pouvons ajouter une vérification d'équivalence CTR supplémentaire. Celle-ci consiste à injecter des données factices (mock) dérivées des définitions de types dans le HTML entièrement déployé, à l'exécuter une fois avec le renderToReadableStream() traditionnel, puis à comparer si la sémantique de l'arbre DOM des deux côtés est équivalente, sans se soucier des différences de formatage (fmt). Il peut y avoir de subtiles variations de format, mais tant que la structure du DOM est strictement et totalement équivalente, il est en fait possible de dire adieu au hydration mismatch ; pourquoi les SSR traditionnels en sont-ils incapables ? Parce qu'ils font cela au moment de l'exécution (runtime), alors que la structure CTR exige que vous appliquiez toutes ces contraintes dès la compilation. Nous disposons bien sûr d'une porte de sortie (escape hatch) avec any, mais tout comme avec TypeScript, si vous utilisez any, vous devez en assumer les conséquences : le CLI vous avertira (warn) lors de la compilation qu'un open string d'échappement any pourrait provoquer un mismatch.
Explorer Serverless
Bien sûr, la possibilité du Serverless ne doit pas non plus être oubliée. Ces dernières années, l'expérience du Serverless s'est avérée excellente, et je l'évaluerais comme « n'ayant aucun inconvénient à part être coûteux ». Cependant, CTR est naturellement très adapté à ce scénario : ce que notre runtime fait est très léger et réduit, donc s'exécuter sur Serverless sera extrêmement rapide, et le temps de réponse tout comme le surcoût seront nettement améliorés. La seule partie restante qui ne peut pas être améliorée concerne les scénarios comme le rendu de Markdown, mais cela devrait être entièrement optimisé dans la logique métier, par exemple en pré-rendant le Markdown pour le stocker afin de ne pas avoir à le rendre à nouveau à chaque requête, tout comme le fait mon site web actuel. C'est un problème de niveau métier et non quelque chose qu'un framework peut résoudre, mais le framework peut vous aider à réduire le surcoût de ces logiques simples en dehors de cela à presque zéro. Comparé au SSR traditionnel, il ne s'agit plus du même ordre de grandeur de surcoût ; à quel point est-ce petit ? Il s'agit d'environ quelques centaines de us ~ 1 ms, ce à quoi le SSR traditionnel n'oserait même pas rêver.
Alors, que faire si le backend est dans un autre langage ? Prenons l'exemple de Cloudflare Workers : en fait, de nombreux Serverless prennent en charge WASM, et d'autres langages compilés en WASM BIN peuvent tout aussi bien servir de backend. En substance, je compile simplement le frontend en ressources statiques pures, de manière similaire au CSR, mais cet ensemble de ressources statiques, combiné à un pont privé /_seam/, permet d'obtenir les mêmes capacités dynamiques que ces véritables frameworks full-stack, le rendant naturellement et entièrement compatible avec Serverless.
SeamJS
Alors, vous êtes déjà confus par la relation entre Seam et SeamJS ? En réalité, ces deux éléments sont très simples : Seam est un protocole. Il définit la manière de marquer des emplacements dynamiques avec Sentinel, de les convertir en marqueurs slot, d'effectuer la détection diff des blocs conditionnels et de boucle, et de réaliser l'injection de données au moment de l'exécution basée sur l'AST. Le protocole lui-même est indépendant du langage ; n'importe quel backend capable d'analyser les commentaires HTML et d'effectuer des substitutions de chaînes peut l'implémenter. SeamJS est un framework, une implémentation concrète basée sur ce protocole. Il assemble des composants existants comme Vite, TanStack Router et TanStack Query, puis comble ce qu'ils ne couvrent pas, comme mon moteur d'extraction et d'injection de skeleton, la CLI, etc.
Mais pour être honnête, SeamJS n'en est encore qu'à un stade très basique. Il fonctionne sans problème, mais pour vraiment l'utiliser dans des projets, il nécessite encore beaucoup de peaufinage. Je réfléchis également davantage à des réformes architecturales, comme l'abstraction du canal de transmission de données, bien que je ne sois pas sûr de maintenir cette direction dans les versions futures. Sur le plan du framework, je vais définitivement créer un framework full-stack TypeScript ainsi qu'un framework phare avec Rust en backend ; quant à l'implémentation liée à Go, elle sera retirée dans les versions ultérieures, car je sens que je n'ai plus l'énergie de la maintenir.
Plus qu'un simple framework web
Alors, à quoi sert de créer ce truc ? En réalité, ce n'est pas limité au Web. Par exemple, une fois que j'aurai aussi abstrait le canal Transport, il sera possible de le porter ultérieurement sur des environnements Desktop tels qu'Electron ou Tauri. Tant que l'on remplace le pipeline de transport HTTP par la communication IPC, le protocole Seam exécuté reste exactement le même. Résultat : ces applications Electron n'ont plus besoin d'afficher un écran de loading au lancement, et beaucoup de choses peuvent être directement rendues localement, à la manière du SSR. C'est ça, la magie de CTR !
Le coût de No JS Runtime
Alors, pourquoi SeamJS a-t-il finalement intégré un JS Runtime ? Parce qu'en mettant en œuvre cette solution, j'ai rencontré un problème : l'idéal de CTR impose des contraintes extrêmement strictes sur les données du premier écran. Les données doivent constituer une structure déterministe pouvant être entièrement dérivée, sans aucune logique de calcul, uniquement des conditions. Cela est devenu très exigeant. Comme on peut l'imaginer en termes d'expérience développeur pour un framework, les développeurs React traditionnels s'attendent naturellement à effectuer quelques calculs au sein des composants et à utiliser directement les valeurs obtenues. Si l'on devait respecter strictement les contraintes de CTR pour atteindre un état ready-to-display, le processus deviendrait extrêmement fastidieux, allant même jusqu'à devoir écrire le même composant deux fois.
Mais il existe en fait une solution : pour des raisons liées au Web, le frontend ne peut s'exécuter que sur JavaScript, il est donc naturel que les composants ne tournent que sur JS. Le backend doit donc impérativement disposer d'une capacité d'exécution JS pour résoudre ce problème. Dans un projet full-stack TypeScript, le runtime lui-même peut être utilisé pour le résoudre ; tandis que dans Rust, il suffit d'intégrer un tout petit JS Runtime comme QuickJS. Notez qu'ici, le JavaScript Runtime est un sous-ensemble standard de JS, pas du tout un runtime avec des API système complètes comme Bun ou Node – il sert vraiment juste à faire de la déduction, à faire du derive.
De cette façon, vous pouvez toujours écrire de la logique de calcul au sein des composants. Après avoir récupéré les données sur le backend, cette logique exécute un petit morceau de JS pour la dériver vers l'état ready-to-display, puis la renvoie au frontend pour l'hydratation du premier écran. De cette manière, CTR est satisfait en obtenant des données dérivées strictement structurées, la DX des développeurs est améliorée, et les composants ne sont écrits qu'une seule fois. Bien sûr, si vous choisissez de respecter strictement des contraintes de type pures, le backend peut en effet se passer de runtime (No JS Runtime) — cette promesse reste d'ailleurs valable. Quoi qu'il en soit, ce tout petit JavaScript Runtime ajouté a des coûts de performance, une empreinte mémoire et une taille bien plus faibles que des outils comme Node — de l'ordre de 200 à 300 Ko seulement —, tout en apportant une très grande flexibilité.
Perspectives d'avenir
Après tout ce discours, quand est-ce qu'on pourra enfin l'utiliser ? Probablement dans très, très longtemps. Ce n'est vraiment pas que je veuille poser un lapin, mais j'ai l'impression qu'à ce stade, ce truc me prend beaucoup trop d'énergie. Si je ne peux l'utiliser que pour développer le framework lui-même, je ne peux pratiquement écrire aucune application — quoi que j'essaie de construire, le framework me met des bâtons dans les roues, sans compter que ces derniers temps, je me suis surtout consacré à la création de ce site web. J'ai donc compris une chose : autant développer d'abord ce site jusqu'à une certaine échelle, ainsi je saurai quels sont mes besoins réels. Quand je reviendrai plus tard pour écrire SeamJS, j'aurai une TODO List prête, et il suffira de réaliser chaque fonctionnalité une par une pour qu'il soit utilisable. Et plus tard, je pourrai toujours broder un article de plus sur la migration et le Benchmark ? Voilà, les promesses sont faites, le concept est là. Ne cherchons pas à savoir s'il est utilisable aujourd'hui, au moins le prototype théorique est validé. Bonne nuit 💤