Dieser Artikel ist möglicherweise nicht für alle geeignet. Um ihn zu verstehen, solltest du zumindest angehender Frontend- oder Full-Stack-Entwickler sein, über grundlegende Kenntnisse von Konzepten wie SSR, SSG und ISR verfügen und Next.js mindestens einmal verwendet haben.
Vorwort
Vielleicht liegt es daran, dass ich von Natur aus sehr neugierig auf die grundlegenden Prinzipien hinter den Dingen bin und manchmal hartnäckig nachfrage, warum etwas nicht auf eine bestimmte Weise gemacht werden kann. Vermutlich komme ich deshalb gelegentlich auf ungewöhnliche Ideen – und dies ist eine davon. Sie kam mir bereits im November 2025, und damals sprach ich mit vielen Freunden darüber. Wegen zeitlicher Überschneidungen mit meinen laufenden Projekten schob ich sie jedoch immer wieder auf und setzte erst um den Jahreswechsel 2026 tatsächlich eine erste Version um. Danach ließ ich sie erneut liegen – weil ich mich daranmachte, eine Website aufzubauen. Der Blog ist zwar noch immer nicht ganz fertig, aber immerhin habe ich nun einen halbwegs brauchbaren Ort, um Dinge festzuhalten. Deshalb möchte ich das Thema wieder hervorholen und noch einmal darüber sprechen.
Zunächst möchte ich eines klarstellen: Ich behaupte nicht, dass SSR schlecht ist. Nach dem Trend zu urteilen, den ich beobachte, wird es meiner Ansicht nach jedoch von zu vielen Menschen missbraucht, während man sich allgemein vom Strom treiben lässt und damit einem Problem ausweicht. Ich habe mit etlichen Leuten darüber gesprochen, doch ihre Antworten waren meist recht unbestimmt. Auch meinen Ansatz habe ich erläutert, aber mein Gegenüber befürchtet stets alle möglichen Probleme und Grenzfälle. Und ehrlich gesagt möchte ich nicht jedes Mal den gesamten Gedankengang von Anfang bis Ende vollständig erklären. Deshalb schreibe ich ihn heute einfach ausführlich auf und versuche, einige Fragen und technische Grundlagen zu klären.
Missbrauch von SSR
Als ich gerade erst in die Web-Frontend-Entwicklung einstieg, war das erste Full-Stack-Framework, mit dem ich in Berührung kam, vermutlich Next.js. Ich gebe zu, dass es ein ausgesprochen gutes Framework ist. Zwar hat es einige Leistungsprobleme, doch diese sind der ganz konkrete Preis für ein gutes Entwicklungserlebnis, und das ist durchaus vertretbar. Als ich mich jedoch nach und nach eingehender mit den Konzepten und dem Wissen rund um Frontends beschäftigte, stellte ich fest, dass es wohl immer weniger zu mir passte – denn ich schreibe sehr gern Rust, befasse mich gern mit systemnaher Technik und habe mit eingebetteten Systemen experimentiert, weshalb ich einen besonderen Leistungsanspruch habe. Das bedeutet nicht, dass bei allem das absolute Maximum erreicht werden muss; vielmehr frage ich mich: Wenn es bereits eine Lösung mit deutlich besserer Leistung gibt, die womöglich nur wenig zusätzlichen Aufwand erfordert, warum macht es dann niemand so? Deshalb denke ich jedes Mal, wenn ich Next verwende: Warum muss selbst bei einer sehr einfachen Seite, auf der fast alles unverändert bleibt und sich nur eine Stelle ändern muss, die gesamte Seite erneut gerendert werden? Warum? Nach meinem Verständnis sollte selbst dann, wenn das Backend nicht in Rust geschrieben ist, keine Höchstleistung angestrebt wird und alles auf einem JavaScript-Interpreter läuft, die geringe Datenmenge, die sich tatsächlich ändert, nur vergleichsweise wenig Aufwand verursachen; das „Rendern“ sollte mit sehr geringen Kosten möglich sein, anstatt die gesamte Seite erneut zu rendern.
So wurde es später zu einer inneren Hürde für mich, zu einer Frage, über die ich zwangsläufig nachdenken musste. Ironischerweise hatte ich jedoch, nachdem ich mir die Entwicklung des Frontends in den vergangenen fast zehn Jahren sowie Vue, Svelte, Solid und ihre Frameworks Next.js, TanStack Start, Remix, Nuxt, Sveltekit und Soild Start angesehen hatte, den Eindruck, dass offenbar alle vom richtigen Weg abgekommen waren. Insbesondere Reacts RSC ist ein Fehler, doch für seine Existenz gibt es durchaus Gründe; vielleicht gehe ich bei Gelegenheit später noch ausführlich darauf ein...
Meiner Ansicht nach missbrauchen RSC und all die SSR-Frameworks im Grunde render. SSG entspricht zwar dem Stil, den ich mir wünsche – die Seite wird beim Erstellen generiert und muss bei einer Anfrage nicht erneut gerendert werden –, bietet aber keine wirklich dynamischen Funktionen (x). ISR wiederum scheint einen Mittelweg zwischen SSG und SSR zu finden und eine gewisse Flexibilität zu ermöglichen, erfüllt meine Erwartungen im Kern jedoch nicht. Meiner Meinung nach wurde ISR eigentlich nur eingeführt, um die schlechte Leistung von SSR auszugleichen; auch spätere Maßnahmen wie verschiedene CDN-Caching-Strategien sind letztlich nur Behelfslösungen und keine wirklich eleganten Lösungen.
Natürlich ist SSR an sich kein Fehler; es ist eine sehr gute Technik, die viele reale Anwendungsfälle abdeckt. Das Problem besteht darin, dass diese Frameworks SSR derart stark zur Standardoption gemacht haben, dass es in vielleicht 95 % der Fälle, in denen Entwickler SSR tatsächlich einsetzen, überhaupt nicht benötigt wird. Der Großteil des Seiteninhalts ist unveränderlich und nur sehr wenige Teile sind wirklich dynamisch, trotzdem wird bei jeder Anfrage der gesamte Komponentenbaum durchlaufen.
In den letzten Jahren hat sich React allerdings etwas verbessert: React 19.2 bietet PPR, doch dabei fällt zur Laufzeit weiterhin ein kostspieliges renderToString() an, sodass es eher eine Behelfslösung als eine echte Lösung ist. Daher fragte ich mich: Wenn das meiste bereits zur Build-Zeit feststeht, warum verlagert man das Rendering nicht direkt in die Kompilierungsphase?
Rendering zur Kompilierzeit?
Das Rendering in die Kompilierzeit zu verlegen, klingt ziemlich absurd, denn der wesentliche Grund für serverseitiges Rendering besteht gerade darin, dass sich manche Werte und Bedingungen erst zu einem bestimmten Zeitpunkt während der Laufzeit auflösen lassen: Man weiß vorher nicht, worum es sich handelt, und kann daher keine Entscheidung treffen. Dass die meisten meine Idee sofort verwerfen, ist also nicht unbegründet und durchaus nachvollziehbar.
Tatsächlich ist mir aber ein ziemlich raffinierter Pipeline eingefallen, um das umzusetzen, und genau deshalb muss ich es als eine Art Protocol bezeichnen. Außerdem fand ich es schon immer ausgesprochen töricht, dass Full-Stack-Frameworks die Grenze zwischen Frontend und Backend verwischen. (Allerdings muss ich zugeben, dass Next.js in den letzten Jahren in dieser Hinsicht eine sehr gute Entwicklererfahrung geboten hat, wodurch viele Einsteiger glauben, eine Full-Stack-Anwendung zu entwickeln sei ganz einfach; tatsächlich entstehen daraus jedoch auch viele Sicherheitsrisiken – aber ich schweife ab.)
Dasselbe gilt für den Datenabruf in Komponenten: Ich bevorzuge klar definierte Grenzen – Komponenten sollten reine Komponenten sein, und der Datenabruf sollte vollständig ausgelagert werden. Sobald man diese Prämisse akzeptiert und reine Komponenten von den Daten trennt, wird es interessant: Man stellt fest, dass sich die Daten selbst tatsächlich kategorisieren lassen.
Für diese Inspiration habe ich auch TypeScript zu danken. Was ein sogenannter „Wert“ zur Kompilierzeit konkret ist, spielt eigentlich keine Rolle; entscheidend ist sein Typ. Was auch immer in ein slot eingetragen werden soll: Solange es sich nicht um etwas wie Open String handelt (eine Zeichenkette mit unendlich vielen möglichen Werten), lässt es sich in einen Typ mit endlich vielen Möglichkeiten fassen. Wenn man etwa eine Übersicht entwickelt und einen Bereich bedingt darstellen muss, läuft es letztlich auf Fälle wie Benutzer oder Administrator hinaus – also auf Typen, die sich vollständig definieren lassen. Fast jede Stelle in der realen Welt, an der bedingte Darstellung oder logische Entscheidungen nötig sind, kann auf einige klar bestimmte Möglichkeiten zurückgeführt werden.
Und genau diese Möglichkeiten lassen sich mit einem hervorragenden Ansatz beschreiben: JTD!
JTD-Spezifikation
JTD (JSON-Typdefinition) ist in RFC 8927 definiert und kennt acht Schema Forms: Empty, Ref, Type (Boolesche Werte, Zeichenketten, Zeitstempel und Zahlentypen verschiedener Genauigkeit), Enum, Elements, Properties, Values und Discriminator; außerdem kann jedes Schema als nullable gekennzeichnet werden. Der Vorteil von JTD liegt in seiner Sprachunabhängigkeit: Für JavaScript, Rust, Go und nahezu jede andere Sprache gibt es entsprechende Typabbildungen. Dadurch wird es auf natürliche Weise zu einer Brücke zwischen Frontend und Backend, während sein Trägerformat JSON ohnehin bereits der kleinste gemeinsame Nenner von Frontend und Backend ist. In der Praxis beschränken sich 95% der Webanwendungen darauf, Zeichenketten oder Zahlenfelder anzuzeigen oder Entscheidungen anhand Boolescher Werte zu treffen – all das fällt in diesen Bereich. Sobald das klar ist, eröffnet sich uns tatsächlich ein großer Handlungsspielraum. Natürlich ist auch JTD nicht perfekt; es gibt Ausnahmen wie Markdown, auf die wir später noch ausführlich eingehen werden. Genau hier liegt für mich der eigentliche Sinn von SSR.
Sentinel-Wert
Beginnen wir mit dem einfachen Teil: Da wir den Typ jedes dynamischen Werts bereits kennen, können wir die React-Komponente zur Kompilierzeit einmal mit renderToString() ausführen – allerdings nicht mit echten Daten, sondern mit Daten, die mithilfe von Sentinel simuliert wurden. Was bedeutet das genau?
{ user: { name: "Alice", age: 30 } }Angenommen, deine Daten sehen so aus, dann können wir sie durch Folgendes ersetzen
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }Diese %%SEAM:...%% sind Wächterwerte, die die Position jedes dynamischen Werts besetzen. Nachdem React mit diesen Wächterdaten renderToString() ausgeführt hat, sind diese Positionen im erzeugten HTML markiert. Anschließend wandelt die Erstellungspipeline die Wächterwerte in Platzmarkierungen in Form von HTML-Kommentaren um.
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>An diesem Punkt ist dir vielleicht schon aufgefallen, dass diese slot „Slots mit Typmarkierungen“ sind. Die Aufgabe der serverseitigen Laufzeitumgebung wird nun denkbar einfach: Sie nimmt die echten Daten entgegen und füllt damit diese Lücken – nichts weiter als reine Textersetzung. renderToString(), eine JavaScript-Laufzeitumgebung oder vDOM werden überhaupt nicht benötigt. Jede Sprache, die HTML-Kommentare auswerten und Text ersetzen kann, eignet sich als Backend – Rust, Go und TypeScript funktionieren alle. Deshalb handelt es sich um ein Protokoll und nicht um ein Framework.
An dieser Stelle fragst du dich vielleicht, wie bedingtes Rendern funktionieren soll. Wenn beispielsweise ein Feld null ist, sollte ein ganzer Inhaltsblock nicht erscheinen. Tatsächlich muss das aber nicht durch JavaScript zur Laufzeit entschieden werden; stattdessen kann das Protokoll diesen Fall definieren.
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->Dasselbe gilt für bedingtes Rendern und das Rendern von Listen.
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->Sogar Musterabgleich
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->Das ist die Kunst der Kommentare: Hier wurden Kommentare gewählt, weil sie zufällig gültiges HTML sind – mehr steckt nicht dahinter. Doch wie werden diese Bedingungs- und Schleifenblöcke zur Build-Zeit erkannt und erzeugt? Dafür gibt es tatsächlich einen recht raffinierten Ansatz: Obwohl wir kein virtuelles DOM haben, können wir das HTML zweimal rendern und die Unterschiede vergleichen. Nehmen wir bedingtes Rendern als Beispiel: Zuerst wird mit den vollständigen Wächterdaten gerendert, dann setzt man ein bestimmtes nullable-Feld auf null und rendert erneut. Vergleicht man beide Ausgaben, ist der verschwundene HTML-Abschnitt genau der von diesem Feld gesteuerte Bedingungsblock; man muss ihn nur noch direkt mit <!--seam:if:...--> umschließen. Bei Arrays funktioniert es genauso.
Vielleicht CTR?
Bestimmt macht sich jetzt wieder jemand Sorgen: Explodiert die Zahl der Kombinationen beim bedingten Rendern nicht exponentiell? Wenn etwa drei bis fünf Variablen das bedingte Rendern steuern und jede davon zehn mögliche Werte hat, ergibt das tatsächlich eine scheinbar sehr große Zahl. In Wirklichkeit wirkt sie jedoch bedrohlicher, als sie ist. Erstens müssen wir bei vielen Zeichenkettenwerten nicht den tatsächlichen Inhalt aufzählen; uns interessiert nur, ob ein Wert vorhanden ist oder nicht nullable, also gibt es effektiv lediglich zwei Möglichkeiten. Zweitens muss der Typ jedes Feldes, das tatsächlich in einer Bedingung geprüft wird, vollständig aufzählbar sein – schließlich würde man keinen open string für eine if-Prüfung verwenden, oder? Und selbst wenn wir wirklich alle Kombinationen durchgingen, wäre ein moderner CPU damit womöglich schon nach wenigen ms fertig. Zudem fällt dieser gesamte Aufwand zur Kompilierzeit an, ähnlich wie beim Kompilieren von Rust: Die Kompilierung ist vergleichsweise teuer, dafür besteht die Laufzeitarbeit nur noch aus einfacher Zeichenkettenersetzung. Wie man es auch rechnet, dieses Geschäft lohnt sich – zumal hier keinerlei besondere Magie im Spiel ist. Eine kleine Einzelheit möchte ich allerdings schon andeuten: Später führe ich für einige komplexe Ableitungen eine winzige eingebettete JavaScript-Ausführungsumgebung ein. Das ist ein Kompromiss; weshalb er nötig wurde und warum man theoretisch darauf verzichten könnte, erkläre ich später.
Das ist, was ich mit CTR (Rendering zur Kompilierzeit) meine. Die Einschränkungen sind genau dieselben wie bei SSR: So nimmt <!--seam:path--> beim Einfügen von Text automatisch eine HTML-Escapierung vor (&, <, > und so weiter); zum Einfügen von unverarbeitetem HTML muss dagegen ausdrücklich <!--seam:path:html--> verwendet werden. Ein fehlender Datenpfad wird in Text slot zu einer leeren Zeichenfolge, während die Einfügung in Attribut slot übersprungen wird. Ein each-Block wird ebenfalls einfach übersprungen, wenn er kein Array erhält. Diese Verhaltensweisen unterscheiden sich grundsätzlich nicht von den Grenzfällen eines SSR-Frameworks – ich habe lediglich den Render-Schritt von SSR in die Kompilierzeit verlegt. Mehr ist es nicht. Zur Build-Zeit durchläuft CTR das kartesische Produkt aller möglichen typisierten Werte sämtlicher Bedingungsvariablen und verwendet dabei entweder beliebige automatisch erzeugte typkonforme Testwerte oder besondere, vom Benutzer überschriebene Testwerte (normalerweise müssen die zur Erzeugung aller HTML-Varianten benötigten Werte nicht manuell angegeben werden, es ist aber möglich; andernfalls wird automatisch ein zum jeweiligen Typ passender Wert gewählt). Jede Kombination wird einmal gerendert; anschließend werden die Ergebnisse verglichen, um die Grenzen aller Bedingungs- und Schleifenblöcke zu bestimmen und schließlich ein vollständig entfaltetes HTML-Gerüst zu erhalten. Mathematisch betrachtet lassen sich die zur Laufzeit übergebenen Daten immer korrekt in dieses Gerüst einfügen, solange sie den Typdefinitionen entsprechen, denn sämtliche möglichen Verzweigungspfade wurden bereits zur Kompilierzeit vollständig durchlaufen.
Konsistenz?
Wie lässt sich diese Konsistenz also gewährleisten? Die Antwort ist der JTD-Vertrag: Frontend und Backend sind zwar getrennt, doch solange beide demselben JTD-Schema folgen, stimmen die Datentypen überein und Inkonsistenzen sind ausgeschlossen.
Doch gegenüber anderen Frameworks stand ich vor einer zusätzlichen Herausforderung: Da das Backend nun nicht mehr an die JavaScript-Laufzeit gebunden war, musste es keine Einheit mehr mit dem Frontend bilden; man kann es ohne Weiteres in Rust oder Go schreiben. In einem TypeScript-Full-Stack-Framework lässt sich die Konsistenz zwischen Frontend und Backend unmittelbar durch Typen gewährleisten – aber wie funktioniert das mit anderen Sprachen? Tatsächlich setze ich hier auf Codegenerierung: Unabhängig von der Sprache des Backends dient es als Referenz, und die für das Frontend verfügbaren Variablen und Typen werden direkt als TypeScript generiert, damit das Frontend sie importieren kann. Dadurch ist die Grenze zwischen Frontend und Backend zwar klar gezogen, beide können aber wie in einem TypeScript-Full-Stack-Framework im selben Ordner leben, den Gepflogenheiten eines einzigen Repositorys folgen und sich direkt gegenseitig aufrufen, ohne Programmierschnittstellen oder Dinge wie gen OpenAPI von Hand schreiben zu müssen. Tatsächlich verwende ich den privaten Pfad /_seam/ als Standard-Endpunkt des Frameworks, der wie bei Nuxt konfigurierbar ist; durch Ausführen von JTD Typed-RPC lassen sich dann die Probleme der Datenübertragung und der CORS lösen.
CTR × SSR
Dann gibt es noch raw HTML slot, womit wir wieder bei jenen 5 % der Randfälle wären: Bei Dingen wie Markdown und Rich Text kann ihr Wert zur Kompilierzeit schlicht nicht bekannt sein, und sie durch Typen einzuschränken wäre äußerst aufwendig. Natürlich könnte man das Protokoll erweitern, sämtliche Markdown-Syntax aufzählen und sie zur Laufzeit analysieren, doch der Aufwand käme praktisch einer Neuentwicklung eines Markdown-Renderers gleich, oder? Nur die wenigen unbedingt nötigen Typen einzuschränken ist etwas völlig anderes, als die gesamte Rendering-Engine neu zu schreiben. Damit sind wir wieder bei der eingangs erwähnten Philosophie: Probleme mit geringerem Aufwand und auf elegantere Weise lösen.
Die gute Nachricht ist also, dass raw HTML slot tatsächlich das Zusammenspiel von CTR und SSR ermöglicht. Das bedeutet, dass sich der Großteil der Benutzeroberfläche einer Seite mit CTR und praktisch ohne Laufzeitkosten umsetzen lässt, während der zentrale Markdown-Artikel mit SSR gerendert wird. Da das Backend nun nicht mehr eingeschränkt ist, kann man entweder mit TypeScript die bisherige Rendermethode von SSR importieren oder völlig neu denken! Läuft das Backend mit Rust, kann man zum Rendern natürlich einen Markdown-Compiler für Rust verwenden. Solange am Ende eine HTML-Zeichenfolge entsteht, die in raw slot eingefügt wird, kann sie angezeigt werden. Die Verwendung von CTR bedeutet nicht, dass SSR nicht verwendet werden kann; beide können problemlos nebeneinander bestehen.
Die PPR-Lücke
So betrachtet ist CTR im Wesentlichen die Idealform von PPR (partiellem Vorrendern): Alles, was sich zwischenspeichern lässt, wird bereits zur Kompilierzeit vollständig gerendert, sodass zur Laufzeit kein zusätzlicher Aufwand entsteht und nur die wenigen Daten Kosten verursachen, die sich tatsächlich ändern. Worin unterscheidet sich das also vom PPR von React 19.2? Die Antwort lautet: Mein Ansatz ist konsequenter und radikaler. PPR Selbst im Idealfall, wenn alle statischen Teile zwischengespeichert sind und nur ein kleiner dynamischer Abschnitt aktualisiert werden muss, muss React renderToReadableStream() erneut ausführen, selbst wenn sich lediglich eine einfache Zeichenfolge geändert hat – und das verursacht erhebliche Kosten.
CTR zieht diese Grenze hingegen sehr klar: Einfache Typen wie Zeichenketten, Zahlen und boolesche Werte werden direkt durch Zeichenkettenersetzung verarbeitet; nur komplexe Typen wie Markdown, bei denen eine vollständige Aufzählung extrem aufwendig wäre, durchlaufen einen echten Renderingprozess. Dabei bezeichnet Rendering hier nicht mehr SSR im herkömmlichen Sinne – es kann sich um eine in jeder beliebigen Programmiersprache implementierte Renderingmethode handeln.
Und RSC
Sprechen wir nun über RSC (React-Serverkomponenten). Zuvor hatte ich gesagt, RSC könnte ein Fehler sein, weil es zu viele Grenzen zwischen Frontend und Backend verwischt, etliche Sicherheitsrisiken mit sich bringt und zu zahlreichen CVEs führt. Tatsächlich liegt das jedoch eher an Next.js und anderen SSR-Ansätzen: Die Nutzer haben schlicht keine Möglichkeit, darauf zu verzichten. Wer dynamische Inhalte will, muss SSR verwenden, obwohl auch eine String-Ersetzung wie bei uns vollkommen funktioniert. Zugleich muss man anerkennen, dass RSC dem Server eine sehr wichtige Fähigkeit verleiht: beliebige React-Komponenten zu rendern, also beliebigen Code auszuführen.
Sobald man jedoch beliebigen Code ausführen muss, ist es praktisch unmöglich, ihn mit Typen vollständig abzubilden. Selbst wenn es möglich wäre, wäre der Aufwand vermutlich nicht geringer als der für einen React-Compiler. Eine Fähigkeit wie die von RSC hat also durchaus ihre Berechtigung. Können CTR und RSC koexistieren? Ja, das können sie, und zwar völlig konfliktfrei. Dies muss allerdings auf Framework-Ebene gelöst werden und fällt nicht in den Zuständigkeitsbereich des Protokolls selbst. Ich habe es vorerst noch nicht implementiert, doch anscheinend lässt sich der Ansatz von TanStack Start übernehmen: Man muss lediglich je ein zusätzliches html- und js-Paket senden. Theoretisch dürfte die Umsetzung recht einfach sein; zumindest ist der Arbeitsaufwand klar absehbar.
Zurück zu raw HTML slot: Gelöst werden damit vor allem Fälle wie Markdown und Rich Text — das gerenderte HTML wird über dangerouslySetInnerHTML eingefügt, und nach der Hydration nimmt dieser Bereich an keiner Interaktion mehr teil, er ist "tot". Dieser Anteil sollte so klein wie möglich bleiben: Wenn du dem Artikel Rahmen oder Styling geben willst, gehört das in React-Komponenten und nicht in dieses Stück HTML hinein. Der Nachteil ist, dass sich daran nach der Hydration nichts mehr ändern lässt, aber es drückt die Kosten für "SSR" unter diesen Voraussetzungen tatsächlich auf ein sehr niedriges Niveau, nahe an Zero Cost. Von den Fällen, die wirklich SSR brauchen, sind etwa 60% genau dieses Einfügen von statischem HTML; erst die restlichen 40% brauchen das, was RSC bietet: das Ausführen beliebiger Komponenten auf dem Server.
UI-Unabhängigkeit
Schließlich gibt es noch die äußerst reizvolle Protokollunabhängigkeit. Im Grunde müssen wir nur den einen entscheidenden Punkt renderToString erfassen; welches UI-Framework davor zum Einsatz kommt, hat mit dem Protokoll nichts zu tun. Worin liegt dann aber der Unterschied zu Astro? Keine Sorge: Ich baue ganz sicher nicht einfach ein weiteres Astro. Oberflächlich betrachtet ähnelt mein Konzept zwar ein wenig den Islands von Astro, tatsächlich bestehen jedoch große Unterschiede.
Ich hydriere nicht mehrere Laufzeitumgebungen auf einer einzelnen Seite. Hm, ehrlich gesagt gibt es meiner Meinung nach nur sehr wenige Fälle, in denen man das wirklich braucht. Die Kommunikation des Komponentenstatus zwischen unterschiedlichen Technologie-Stacks wird äußerst aufwendig. Das ist eher eine Übergangslösung für die Migration von einem Stack zu einem anderen, wenn sich nicht alles auf einmal umstellen lässt. Außerdem ist Astro im Kern eine MPA, während wir vor der Hydrierung eine MPA sein und danach zu einer SPA werden können – mit clientseitigem Routing wie bei Next.js, das seitenübergreifende Animationen ermöglicht. Davon kann Astro nur träumen.
Astro und SSG
Außerdem ist Astros Design eigentlich erst in Szenarien wirklich nützlich, die mehrere Technologie-Stacks umfassen. Wenn du es nur wegen der Geschwindigkeit einsetzt, aber nur eines seiner Frameworks verwendest, also etwa nur React einbindest und nicht Vue, dann ist die angepriesene „Geschwindigkeit“ meines Erachtens ein Scheinargument. Der erste Seitenaufbau besteht zwar tatsächlich nur aus HTML, doch für jede Interaktion muss die Seite hydratisiert werden; dafür muss die gesamte React-Laufzeitumgebung heruntergeladen werden, was sich im Kern nicht von unserer Hydratisierung unterscheidet. Natürlich können wir später auch das Konzept der Inselarchitektur umsetzen und einen Router für die Anwendungshülle hinzufügen, um eine Navigation als Einzelseitenanwendung über verschiedene Frameworks für Benutzeroberflächen hinweg zu ermöglichen. Das steht jedoch erst später auf dem Entwicklungsplan, und zumindest im Moment habe ich es nicht eilig.
Abschließend noch der Vergleich mit traditionellem SSG: Ich habe dasselbe erreicht wie SSG – im Wesentlichen wird alles, was zur Kompilierzeit feststeht, vollständig gerendert. Wir sind jedoch dynamischer, denn slot-Werte einfacher Typen lassen sich zur Laufzeit vollständig ersetzen. Man kann es sich so vorstellen: Wir haben SSG als eine Art Einstiegspunkt für eine MPA gerendert. Vor der Hydration ist es eine MPA, danach wird es zu einer SPA, wobei die „dynamischen“ Fähigkeiten erhalten bleiben.
Hydration-Fehler?
Zum Schluss bleibt die von mir verhasste Hydrationsabweichung, die du vermutlich ebenso wenig magst. Letztlich handelt es sich dabei jedoch lediglich um einen inkonsistenten DOM-Zustand. Herkömmliche Frameworks wie der App Router von Next.js versuchen, die gesamte Anwendung mit React zu umschließen. Fügt der Browser auf der Benutzerseite irgendwelches Markup ein, kann dadurch ein Hydrationsfehler entstehen. Im Grunde gelten für uns dieselben Einschränkungen wie für herkömmliches serverseitiges Rendering. Wenn du TS verwendest, umschließe ich die Anwendung jedoch zusätzlich mit einem Hydrations-div namens __root. Dadurch erstreckt sich der Hydrationsbereich nicht mehr auf den Metadatenbereich, was die Hydration robuster macht. React 19 unterstützt außerdem Dokumentmetadaten-Tags wie <title>, <meta> und <link> von Haus aus. Selbst wenn du nur ein bestimmtes <div> auf der Seite statt des gesamten <html> hydratisierst, kannst du <title>My Page</title> direkt in einer Komponente rendern; React wird es automatisch in <head> hochziehen.
Zurück zu Abweichungen bei der Hydration: Da wir den Typ jedes slot bereits zur Kompilierzeit kennen, können wir eine zusätzliche CTR-Äquivalenzprüfung einbauen. Dazu füllen wir das vollständig expandierte HTML mit aus den Typdefinitionen abgeleiteten Testdaten, führen anschließend das herkömmliche renderToReadableStream() einmal aus und vergleichen dann, ob die beiden DOM-Bäume semantisch äquivalent sind; Unterschiede bei der Formatierung spielen dabei keine Rolle. Die Formatierung kann geringfügig abweichen, doch solange die DOM-Strukturen streng und vollständig äquivalent sind, kann man sich tatsächlich endgültig von Abweichungen bei der Hydration verabschieden. Warum ist das mit herkömmlichem SSR nicht möglich? Weil diese Systeme den Vorgang erst zur Laufzeit ausführen, während die CTR-Struktur verlangt, dass sämtliche Einschränkungen bereits zur Kompilierzeit umgesetzt werden. Natürlich stellen wir mit any auch einen Ausweg bereit, doch wie bei TypeScript gilt: Wer any verwendet, muss die Folgen bewusst in Kauf nehmen. Die CLI warnt beim Kompilieren, dass any als Ausweg über eine offene Zeichenkette zu einer Abweichung führen kann.
Serverless
Natürlich darf auch die Möglichkeit eines serverlosen Betriebs nicht fehlen. In den vergangenen Jahren ist die Erfahrung damit ausgesprochen gut geworden – mein Urteil lautet: „Abgesehen von den Kosten gibt es praktisch keine Nachteile.“ CTR eignet sich jedoch von Natur aus hervorragend für dieses Szenario: Zur Laufzeit erledigen wir nur sehr wenige und leichte Aufgaben, sodass es in einer serverlosen Umgebung äußerst schnell läuft und sowohl die Antwortzeiten als auch der Aufwand deutlich sinken. Lediglich Fälle wie das Rendern von Markdown lassen sich dadurch nicht verbessern, doch das sollte unbedingt in der Geschäftslogik optimiert werden, etwa indem Markdown vorab gerendert und gespeichert wird, damit es nicht bei jeder Anfrage erneut gerendert werden muss – genau wie auf meiner jetzigen Website. Das ist ein Problem der Geschäftsebene, das kein Framework lösen kann; den Aufwand der übrigen einfachen Logik kann ein Framework jedoch auf nahezu null reduzieren. Verglichen mit herkömmlichem SSR liegt dieser Aufwand nicht einmal mehr in derselben Größenordnung. Wie gering ist er? Er liegt ungefähr zwischen einigen Hundert Mikrosekunden und 1 Millisekunde – für traditionelles SSR kaum vorstellbar.
Was aber, wenn das Backend in einer anderen Sprache geschrieben ist? Nehmen wir Cloudflare Workers als Beispiel: Viele Serverless-Plattformen unterstützen WASM, sodass sich auch andere Sprachen zu WASM-Binärdateien kompilieren und als Backend einsetzen lassen. Im Grunde kompiliere ich das Frontend ähnlich wie beim clientseitigen Rendering zu rein statischen Ressourcen. In Verbindung mit einer privaten Brücke, /_seam/, bieten diese Ressourcen jedoch dieselben dynamischen Möglichkeiten wie echte Full-Stack-Frameworks und sind damit selbstverständlich vollständig mit Serverless-Plattformen kompatibel.
Seam und SeamJS
Vielleicht bist du inzwischen also verwirrt, wie Seam und SeamJS zusammenhängen. Eigentlich ist es ganz einfach: Seam ist das Protokoll. Es legt fest, wie man dynamische Stellen mit Sentinel markiert, wie daraus slot-Marker werden, wie die diff-Erkennung für Bedingungs- und Schleifenblöcke funktioniert und wie die Laufzeit auf Basis des AST Daten einspeist. Das Protokoll selbst ist sprachunabhängig; jedes Backend, das HTML-Kommentare parsen und Zeichenketten ersetzen kann, kann es umsetzen. SeamJS ist das Framework, eine konkrete Umsetzung dieses Protokolls. Es fertige Räder wie Vite, TanStack Router und TanStack Query zusammen und ergänzt dann das, was diese nicht abdecken: meine skeleton-Extraktion, die Injection-Engine, das CLI und so weiter.
Aber ehrlich gesagt befindet sich SeamJS noch in einem sehr rudimentären Zustand. Es lässt sich zwar problemlos ausführen, doch bevor man damit ernsthaft Projekte entwickeln kann, ist noch viel Feinarbeit nötig. Ich denke außerdem verstärkt über architektonische Änderungen nach, etwa über eine Abstraktion des Datenübertragungskanals, bin mir aber nicht sicher, ob ich diesen Ansatz in späteren Versionen weiterverfolgen werde. Auf Framework-Ebene werde ich auf jeden Fall ein TypeScript-Full-Stack-Framework sowie ein Flaggschiff-Framework mit Rust als Backend entwickeln. Die Go-Implementierung möchte ich dagegen in einer späteren Version entfernen, da mir schlicht die Kapazität fehlt, sie weiter zu pflegen.
Rendering is a protocol, not a render-time computation.
Mehr als ein Web-Framework
Wozu ist das Ganze also gut? Tatsächlich ist es nicht auf das Web beschränkt. Wenn ich auch den Transport-Kanal abstrahiere, lässt es sich später auf Desktop-Umgebungen wie Electron oder Tauri portieren. Man muss lediglich die HTTP-Übertragungspipeline durch IPC-Kommunikation ersetzen; ausgeführt wird weiterhin dasselbe Seam-Protokoll. Dadurch benötigen Electron-Anwendungen beim Start keinen Ladebildschirm mehr, und viele Elemente können ähnlich wie beim serverseitigen Rendern sofort lokal dargestellt werden. Das ist die Magie von CTR!
Der Preis von „kein JS-Runtime"
Warum hat SeamJS am Ende dennoch eine JS-Laufzeit integriert? Weil ich bei der Umsetzung dieses Ansatzes auf ein Problem gestoßen bin: Die Idealvorstellung von CTR stellt äußerst strenge Anforderungen an die Daten für die erste Darstellung. Die Daten müssen eine vollständig ableitbare, deterministische Struktur bilden; sie dürfen nicht einmal Berechnungslogik enthalten, sondern ausschließlich Bedingungen. Das ist ausgesprochen restriktiv. Mit Blick auf die Entwicklungserfahrung eines Frameworks erwarten Entwickler, die an die herkömmliche Arbeit mit React gewöhnt sind, selbstverständlich, Berechnungen innerhalb einer Komponente durchführen und die daraus gewonnenen Werte direkt verwenden zu können. Die strikte Einhaltung der CTR-Vorgaben, um einen ready-to-display-Zustand zu erreichen, würde den Ablauf äußerst umständlich machen und könnte sogar dazu führen, dass dieselbe Komponente zweimal geschrieben werden muss.
Tatsächlich gibt es aber eine Lösung: Aufgrund der Funktionsweise des Webs kann das Frontend nur JavaScript ausführen, weshalb natürlich auch Komponenten nur als JS laufen können. Um dieses Problem zu lösen, muss daher auch das Backend JS ausführen können; ein TypeScript-Full-Stack-Projekt kann dafür einfach seine vorhandene Laufzeitumgebung verwenden. In Rust muss lediglich eine winzige JS-Laufzeitumgebung wie QuickJS eingebettet werden. Dabei ist zu beachten, dass diese JavaScript-Laufzeitumgebung nur eine standardisierte Teilmenge von JS implementiert und keineswegs mit Laufzeitumgebungen wie Bun oder Node vergleichbar ist, die vollständige Betriebssystem-APIs mitbringen. Sie dient wirklich nur dazu, Daten herzuleiten.
So lässt sich weiterhin etwas Berechnungslogik in die Komponente schreiben: Sobald das Backend die Daten erhalten hat, führt es ein kurzes JavaScript-Programm aus, leitet daraus den Zustand ready-to-display ab und sendet ihn zur Hydratisierung der ersten Ansicht an das Frontend zurück. CTR ist zufrieden, weil es streng strukturierte abgeleitete Daten erhält, und auch die Entwicklungserfahrung verbessert sich, weil die Komponente nur einmal geschrieben werden muss. Wer sich strikt an reine Typbeschränkungen hält, kann das Backend natürlich tatsächlich ohne JavaScript-Laufzeitumgebung betreiben – dieses Versprechen gilt streng genommen also weiterhin. Unabhängig davon sind Leistungsaufwand, Speicherbedarf und Größe der zusätzlich eingebundenen kleinen JavaScript-Laufzeitumgebung wesentlich geringer als etwa bei Node: Sie benötigt lediglich rund 200–300 KB und ermöglicht dennoch enorme Flexibilität.
Ausblick
Nach all dem Gerede: Wann kann man das Ding endlich ? Wohl erst in sehr, sehr langer Zeit. Es ist wirklich nicht so, dass ich will; es ist eher so, dass mich dieses Ding im Moment viel, viel zu sehr in Geiselhaft nimmt. Wenn ich es nur zum Entwickeln benutzen kann, dann kriege ich im Grunde keine Anwendung mehr geschrieben – bei allem, was ich schreibe, stellt mir zuerst das Framework ein Bein. Und zuletzt habe ich ohnehin vor allem an dieser Website und Ähnlichem gearbeitet. Deshalb ist mir klar geworden: Besser, ich bringe die Website erst auf eine gewisse Größe, dann weiß ich, was meine Anforderungen wirklich sind. Wenn ich später wieder an SeamJS gehe, habe ich eine TODO-Liste; Funktion für Funktion umgesetzt, und es ist benutzbar. Danach kann ich noch eine Migration und ein Benchmark hinterherschieben? Gut, . Die Idee steht hier. Egal, ob es heute schon nutzbar ist – gedanklich läuft der Prototyp zumindest. Gute Nacht 💤