Dieser Artikel ist möglicherweise nicht für jeden geeignet. Um zu verstehen, worum es geht, sollten Sie mindestens ein angehender Frontend-Entwickler oder Full-Stack-Entwickler sein, ein grundlegendes Verständnis von Konzepten wie SSR, SSG und ISR haben und mindestens einmal Next.js verwendet haben.

Vorwort

Vielleicht liegt es daran, dass ich von Natur aus eine starke Neugier für niedrigere Prinzipien habe und manchmal beharrlich frage, warum etwas nicht so gemacht werden kann; ich vermute, es ist genau das, weshalb ich gelegentlich einzigartige Ideen entwickeln kann: Das hier ist eine davon. Bereits im November 2025 tauchte sie auf, ich sprach damals mit vielen Freunden darüber, aber wegen Terminüberschneidungen mit laufenden Projekten verschob sich die Umsetzung bis etwa zum Neujahrswechsel 2026, als ich endlich eine Version fertigstellte. Danach legte ich das Ganze wieder beiseite – weil ich eine Website aufbaute. Der Blog ist zwar noch nicht vollständig fertig, aber zumindest habe ich einen Ort, an dem ich Dinge festhalten kann, sodass ich das Thema wieder aufgreifen und neu erläutern möchte.

Zunächst möchte ich eines klarstellen: Ich behaupte nicht, dass SSR schlecht ist. Ich habe nur das Gefühl, dass es nach den Trends, die ich beobachte, von viel zu vielen Menschen missbraucht wird und man im Großen und Ganzen mit dem Strom schwimmt, um einem Problem auszuweichen. Ich habe dies bereits mit etlichen Leuten diskutiert, aber die Reaktionen waren meist eher verunsichert. Ich habe auch meine Vorstellungen geäußert, aber mein Gegenüber befürchtete stets verschiedene Probleme und Grenzfälle. Um ehrlich zu sein, möchte ich auch nicht jedes Mal den gesamten Denkansatz von Anfang bis Ende durchgehen. Deshalb schreibe ich es heute einfach ausführlich auf und versuche, einige Fragen sowie die technischen Prinzipien klar verständlich zu erklären.

Der Missbrauch von SSR

Als ich gerade erst mit der Web-Frontend-Entwicklung anfing, war das erste Full-Stack-Framework, mit dem ich in Berührung kam, wohl Next.js. Ich gebe zu, dass es ein hervorragendes Framework ist. Auch wenn es einige Performance-Probleme hat, wurden diese durch eine handfeste DX-Erfahrung erkauft, was völlig akzeptabel ist. Aber als ich nach und nach ein tieferes Verständnis für Frontend-Konzepte und -Wissen entwickelte, merkte ich, dass es immer weniger zu mir passt – da ich sehr gerne Rust schreibe, mich leidenschaftlich mit Low-Level-Themen beschäftige und Erfahrung mit Embedded Systems habe, besitze ich eine ganz besondere Besessenheit von Performance; bei dieser Besessenheit geht es nicht darum, überall an die Grenzen zu gehen, sondern eher darum: Wenn es hier einen Weg gibt, der bei vielleicht minimalem Aufwand eine deutlich bessere Performance bietet, warum tut das dann niemand? Jedes Mal, wenn ich Next verwende, frage ich mich daher: Warum muss eine ganze Seite neu gerendert werden, selbst wenn sie sehr einfach ist, das meiste unverändert bleibt und sich nur eine einzige Stelle ändert? Warum? Meinem Verständnis nach sollte selbst dann, wenn das Backend nicht in Rust geschrieben ist und man keine Extreme anstrebt, selbst auf einem JavaScript-Interpreter die eigentliche Datenänderung einen vergleichsweise geringen Overhead verursachen – so dass ein „Rendering“ auf eine sehr kostengünstige Weise realisiert wird, anstatt die gesamte Seite neu zu rendern.

SSR re-renders the entire page for one small changeA page where only the username changes,but traditional SSR rebuilds the entire component tree and re-renders everything.NavHero sectionUsernameSidebarContent blockFooterChangedStatic (unchanged)Traditional SSRrenderToString()Entire component treeNavHeroUserSideContentFooterAll re-rendered (~100-300ms)Only 1 field changedbut the entire page was rebuilt

So sehr, dass es später zu einer echten Hürde in meinem Kopf und einer Frage wurde, über die ich einfach nachdenken musste. Ironischerweise habe ich nach dem Rückblick auf die Entwicklung des Frontends in den letzten knapp 10 Jahren – Vue, Svelte, Solid und ihre Frameworks Next.js, TanStack Start, Remix, Nuxt, Sveltekit und Soild Start – das Gefühl, dass sich irgendwie alle verrannt haben. Besonders Reacts RSC halte ich für einen Fehler, obwohl dessen Existenz durchaus ihre Daseinsberechtigung hat – ein Punkt, auf den ich vielleicht später noch einmal näher eingehen werde...

Meiner Ansicht nach missbrauchen RSC und eine ganze Reihe von SSR-Frameworks in Wirklichkeit render. SSG hat zwar den Stil erreicht, den ich mir wünschte – Seiten werden bereits beim Build generiert und müssen bei Anfragen nicht neu gerendert werden –, aber es verfügt über keine echten dynamischen Fähigkeiten(x). Was ISR betrifft, scheint es zwar einen Kompromiss zwischen SSG und SSR gefunden zu haben und eine gewisse Flexibilität zu bieten, aber im Grunde erfüllt es meine Erwartungen nicht. Ich denke, dass ISR überhaupt nur entstanden ist, um die schlechte Performance von SSR auszugleichen; einschließlich all der nachfolgenden CDN-Caching-Strategien und ähnlicher Maßnahmen handelt es sich dabei im Grunde nur um eine Art workaround und keineswegs um eine wirklich elegante Lösung.

Natürlich ist SSR an sich kein Fehler; es ist eine tolle Sache, die viele reale Anwendungsfälle löst. Das Problem ist jedoch, dass diese Frameworks SSR in die Rolle der absoluten Standardoption gedrängt haben, sodass Entwickler SSR in wahrscheinlich 95 % der Fälle nutzen, in denen sie es eigentlich gar nicht brauchen. Der Großteil des Inhalts auf einer Seite ist statisch und die wirklich dynamischen Teile sind verschwindend gering, dennoch muss bei jeder Anfrage der gesamte Komponentenbaum durchlaufen werden.

In den letzten Jahren hat sich bei React allerdings einiges verbessert. React 19.2 bietet zwar PPR, aber das verursacht zur Laufzeit immer noch teure renderToString() und ist eher ein workaround als eine echte Lösung. Deshalb dachte ich mir: Wenn das meiste ohnehin schon zur Build-Zeit feststeht, warum verlagern wir das Rendering dann nicht direkt in die Kompilierphase?

Rendering approaches spectrum from static to dynamicCompares SSG,ISR,SSR,PPR and RSC on a spectrum with two cost arrows between rows.SSGNo dynamicISRWorkaroundPPRWorkaroundSSR~100-300msRSCArbitraryStaticRuntime costDynamicWhat if?SSG's speed + SSR's dynamic=???

Kompilierzeit-Rendering?

Das Rendering in die Kompilierzeit zu verlegen, klingt erst einmal absurd, denn der Hauptgrund für die Existenz von SSR ist schließlich, dass sich manche Werte und Bedingungen erst zur Laufzeit auflösen lassen – man weiß nicht, was sie sind, und kann keine Entscheidungen treffen. Daher ist es verständlich und keineswegs unbegründet, dass die meisten Menschen meine Idee sofort verworfen haben.

Aber eigentlich ist mir ein durchaus eleganter Pipeline eingefallen, um das zu realisieren, und das ist auch der Grund, warum ich es als eine Art Protocol bezeichnen muss. Außerdem fand ich es schon immer ziemlich dumm, wie Full-Stack-Frameworks die Grenze zwischen Frontend und Backend verwischen (obwohl man zugeben muss, dass Next.js in den letzten Jahren in dieser Hinsicht bei der DX einen großartigen Job gemacht hat, sodass viele Anfänger denken, das Schreiben einer Full-Stack-Anwendung sei eine ganz einfache Sache – in Wirklichkeit entstehen dadurch aber auch viele Sicherheitsrisiken, aber ich schweife ab).

Component and data separation — values become typed slotsShows how pure components have typed slots,and runtime values collapse into finite JTD types.TraditionalComponent + data coupledDashboard componentfetch("/api/user")if (role==="admin") ...Value unknown until runtimeInfinite possibilities?SeamComponent + data separatedPure componentUI only,no data fetchingData (typed)JTD schema contractstringbooleanenumint32Finite,enumerable → compile-time rendering possible

Das Schreiben von Datenabfragen innerhalb von Komponenten ist genau dasselbe – ich bevorzuge einen Ansatz mit klaren Grenzen: Komponenten sind reine Komponenten, und das Laden von Daten sollte vollständig herausgelöst werden. Sobald man diese Voraussetzung akzeptiert und reine Komponenten von Daten trennt, wird die Sache interessant: Man stellt fest, dass Daten an sich tatsächlich kategorisiert werden können.

Hier möchte ich auch TypeScript für die Inspiration danken. Eigentlich ist es gar nicht wichtig, was der sogenannte „Wert“ selbst zur Kompilierzeit ist – wichtig ist sein Typ. Welcher Inhalt in ein slot eingetragen werden muss: Solange es nicht so etwas wie Open String (ein String mit unendlich vielen Möglichkeiten) ist, kann er in einen Typ mit endlichen Möglichkeiten verpackt werden. Wenn Sie beispielsweise ein dashboard schreiben und einen bestimmten Inhalt bedingt rendern müssen, handelt es sich lediglich um User oder Admin usw., aber letztendlich ist es ein Typ, der vollständig definiert werden kann; in der realen Welt lassen sich fast alle Stellen, die bedingtes Rendern oder logische Entscheidungen erfordern, auf ein paar bestimmte Möglichkeiten zurückführen.

Und wie es der Zufall will, lassen sich diese Möglichkeiten mit einem hervorragenden Ansatz beschreiben, nämlich JTD!

JTD-Spezifikation

In JTD (JSON Type Definition) RFC 8927 werden acht Schema Form-Typen definiert: Empty, Ref, Type (boolean, string, timestamp sowie numerische Typen verschiedener Präzision), Enum, Elements, Properties, Values und Discriminator; zudem kann jedes Schema als nullable markiert werden. Der Vorteil von JTD liegt in seiner Sprachunabhängigkeit: In fast allen Sprachen wie JavaScript, Rust und Go lassen sich entsprechende Typ-Mappings finden, was es von Natur aus zu einer Brücke zwischen Frontend und Backend macht. Zudem ist JSON das Trägermedium, das ohnehin den kleinsten gemeinsamen Nenner zwischen Frontend und Backend darstellt. In der realen Welt bestehen 95% aller Website-Anwendungen aus nichts anderem als dem Anzeigen von Zeichenketten, numerischen Feldern oder der Auswertung von Booleschen Werten – all das fällt genau in diesen Bereich. Wenn man sich das klar macht, eröffnet sich ein enormer Handlungsspielraum. Natürlich ist JTD nicht perfekt und es gibt Ausnahmen wie etwa Markdown, aber darauf gehen wir später noch im Detail ein; genau hier sehe ich nämlich den wahren Wert von SSR.

Sentinel

Fangen wir mit dem einfachen Teil an: Da wir bereits wissen, dass jeder dynamische Wert einen Typ hat, können wir zur Kompilierzeit eines tun – die React-Komponente einmal durch renderToString() ausführen, aber nicht mit echten Daten, sondern mit Sentinel gemockten Daten. Was bedeutet das also?

js
{ user: { name: "Alice", age: 30 } }

Angenommen, Ihre Daten sehen so aus, dann können wir sie ersetzen durch:

js
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }

Diese %%SEAM:...%% sind die Sentinels; sie besetzen die Position jedes dynamischen Werts. Nachdem React renderToString() mit diesen Sentinel-Daten ausgeführt hat, werden diese Positionen im ausgegebenen HTML markiert. Als Nächstes wandelt die Build-Pipeline diese Sentinels in Slot-Markierungen in Form von HTML-Kommentaren um.

html
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>

<!-- Slot -->
<span><!--seam:user.name--></span>

An diesem Punkt haben Sie vielleicht schon erkannt, dass diese slot "typisierte Slots" sind. Was die serverseitige Laufzeitumgebung jetzt tun muss, wird extrem einfach: die echten Daten holen und in diese Lücken einsetzen, reine Zeichenkettenersetzung. Es braucht überhaupt kein renderToString(), keine JavaScript-Laufzeitumgebung, kein vDOM. Jede Sprache, die HTML-Kommentare parsen und Zeichenkettenersetzung durchführen kann, kann als Backend dienen -- Rust, Go, TypeScript, alle funktionieren. Deshalb ist es ein Protokoll und kein Framework.

Sentinel to Slot build pipeline — horizontalBuild-time pipeline:real data becomes sentinel data,rendered by React,converted to typed slot markers.Build-time pipelineReal dataSentinel%%SEAM:name%%renderToString()React renderHTML skeleton<!--seam:name-->Before<span>%%SEAM:user.name%%</span>After<span><!--seam:user.name--></span>Typed slots — any language can inject data via string replacement

An dieser Stelle fragen Sie sich vielleicht: Was ist mit bedingtem Rendering? Wenn beispielsweise ein Feld null ist, sollte ein ganzer Inhaltsblock gar nicht erst erscheinen. Doch auch das muss eigentlich nicht von JavaScript zur Laufzeit ausgewertet werden – stattdessen kann das Protokoll selbst diesen Fall definieren.

html
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->

Beim bedingten Rendering ist es so, und beim Listen-Rendering ist es genauso.

html
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->

Sogar Pattern Matching

html
<!--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: Kommentare wurden hier gewählt, schlicht weil sie zufällig gültige HTML-Zeichen sind, mehr nicht. Wie werden diese bedingten Blöcke und Schleifenblöcke also zur build-time bestimmt und generiert? Es ist tatsächlich ein ziemlich cleverer Ansatz: Obwohl wir keinen vDOM haben, können wir das HTML zweimal rendern, um ein Diff durchzuführen. Nehmen wir das bedingte Rendern als Beispiel: Beim ersten Durchlauf wird mit den vollständigen Sentinel-Daten gerendert, beim zweiten Durchlauf wird ein bestimmtes nullable-Feld auf null gesetzt und erneut gerendert. Vergleicht man die beiden Ausgaben, ist der HTML-Block, der verschwunden ist, der von diesem Feld gesteuerte bedingte Block, den man einfach mit <!--seam:if:...--> umschließen kann. Das Gleiche gilt für Arrays.

Vielleicht CTR?

Da wird sich bestimmt wieder jemand Sorgen machen: Explodieren die Kombinationen beim bedingten Rendering nicht exponentiell? Wenn beispielsweise 3–5 Variablen das bedingte Rendering steuern und jede Variable 10 Möglichkeiten hat, ergibt das miteinander multipliziert tatsächlich eine Zahl, die riesig aussieht. Aber in Wirklichkeit sieht diese Zahl nur bedrohlich aus. Erstens müssen wir bei vielen Werten vom Typ String den Inhalt gar nicht wirklich erschöpfend aufzählen; uns interessiert nur, ob ein Wert vorhanden ist oder nicht nullable – das sind hier effektiv nur 2 Möglichkeiten. Zweitens muss der Typ eines Feldes, das tatsächlich eine bedingte Prüfung erfordert, aufzählbar sein; wie etwa (man würde ja wohl kein open string für eine if-Prüfung verwenden, oder?). Und selbst wenn man wirklich alle Kombinationen erschöpfend durchgehen müsste, würde das auf einer modernen CPU vielleicht nur ein paar ms dauern. Zudem fällt diese Last vollständig in die Kompilierzeit – genau wie bei der Kompilierung von Rust: Man zahlt zur Kompilierzeit einen höheren Preis, aber zur Laufzeit wird daraus reine Zeichenkettenersetzung. Wie man es auch dreht und wendet, dieses Geschäft lohnt sich auf jeden Fall, zumal hier keinerlei besondere Magie im Spiel ist. An dieser Stelle lässt sich jedoch ein kleiner Ausblick geben: Später habe ich tatsächlich eine sehr kleine eingebettete JavaScript-Ausführungsumgebung für einige komplexe Ableitungen eingeführt. Das ist allerdings ein Kompromiss; die Gründe dafür und warum es theoretisch auch ohne ginge, werde ich später erklären.

Cartesian product build cost calculationEven worst case 100,000 combinations,each renderToString takes ~1ms,total build time is ~100 seconds — a one-time compile cost.Worst case math5 variablesx 10 values each=10⁵=100,000x~1ms eachrenderToString()=~100s build timeRealityMost are nullable / bool2 states,not 102⁵=32 combosx ~1ms each~32ms — one-time build cost

Das ist das, was ich als CTR (Compile-Time Rendering) bezeichne. Was die Einschränkungen betrifft, so entsprechen sie genau denen von SSR: Zum Beispiel wird beim Einfügen von Text über <!--seam:path--> automatisch eine HTML-Maskierung durchgeführt (&, <, > usw.); wenn Sie rohes HTML einfügen müssen, müssen Sie explizit <!--seam:path:html--> verwenden; fehlende Datenpfade werden im Text slot zu leeren Zeichenketten und überspringen die Injektion im Attribut slot; wenn ein each-Block kein Array erhält, wird er direkt übersprungen. Diese Verhaltensweisen unterscheiden sich im Wesentlichen nicht von den Grenzfällen, auf die Sie in SSR-Frameworks stoßen—ich habe lediglich den Rendering-Schritt von SSR in die Kompilierungsphase verlagert, mehr nicht. CTR führt zur build-time eine Kartesisches-Produkt-Traversierung über die Typwerte aller Bedingungsvariablen durch und verwendet entweder einen gemockten beliebigen Wert, der dem Typ entspricht, oder einen vom Benutzer überschriebenen (Override) speziellen Mock-Wert (normalerweise müssen Sie die für die Generierung von HTML-Varianten erforderlichen Mock-Werte nicht manuell schreiben, können sie aber manuell angeben; ansonsten wird automatisch ein für Ihren Typ passender Wert eingesetzt). Auf diese Weise wird jede Kombination einmal gerendert, um die Grenzen aller Bedingungs- und Schleifenblöcke zu diffen, was schließlich zu einem vollständig entfalteten HTML-Skelett führt. Mathematisch gesehen kann das Datenmaterial, solange es zur Runtime den Typdefinitionen entspricht, garantiert korrekt in dieses Skelett injiziert werden, da alle möglichen Zweigpfade bereits zur Kompilierungszeit durchprobiert wurden.

Konsistenz?

Wie lässt sich die Konsistenz also garantieren? Die Antwort ist tatsächlich dieser JTD-Contract. Obwohl Frontend und Backend getrennt sind, sind die Datentypen aufeinander abgestimmt und es gibt kein Problem mit Inkonsistenz, solange sich beide an dasselbe JTD schema halten.

Ich stand jedoch vor einer weiteren Herausforderung als andere Frameworks: Da das Backend von der JavaScript-Laufzeitumgebung befreit ist, muss es nicht mehr eins mit dem Frontend sein – man kann das Backend problemlos in Rust oder Go schreiben. In einem TypeScript-Fullstack-Framework kann die Konsistenz zwischen Frontend und Backend direkt über Typen gewährleistet werden; aber wie sieht es bei anderen Sprachen aus? Tatsächlich habe ich hier codegen eingesetzt: Unabhängig davon, in welcher Sprache das Backend geschrieben ist, dient das Backend als Referenz, und die vom Frontend verwendbaren Variablen und Typen werden direkt in TypeScript codegeniert, damit das Frontend sie importieren kann. Obwohl die Grenze zwischen Frontend und Backend dadurch klar gezogen ist, können sie wie bei einem TypeScript-Fullstack-Framework tatsächlich in einem einzigen Ordner leben, was den Monorepo-Gepflogenheiten entspricht. Sie können sich direkt gegenseitig aufrufen, ohne dass man APIs oder Dinge wie gen OpenAPI manuell schreiben muss. Tatsächlich habe ich hier einen privaten Pfad /_seam/ als Standard-Endpunkt des Frameworks verwendet (der wie bei Nuxt konfigurierbar ist). Durch das Ausführen von JTD Typed-RPC werden somit die Probleme von Datenübertragung und CORS gelöst.

CTR x SSR

Und dann ist da noch raw HTML slot; das führt im Grunde wieder zu diesen 5% Edge Case zurück. Bei Dingen wie Markdown oder Rich Text ist es zur Kompilierzeit schlicht unmöglich, ihren Wert zu kennen, und sie über Typen einzuschränken, ist extrem aufwendig. Natürlich könnte man sagen, man erweitert das Protokoll, listet sämtliche Syntaxes von Markdown auf und parst sie zur Laufzeit – aber dieser Arbeitsaufwand käme im Grunde dem Neuschreiben eines Markdown-Renderers gleich? Doch nur diese wenigen minimalen Typen einzuschränken, ist ein völlig anderer Aufwand als das Neuschreiben der gesamten Rendering-Engine. Das bringt uns auch wieder zu der Philosophie zurück, die ich zu Beginn erwähnt habe: Probleme mit geringerem Aufwand und auf elegantere Weise zu lösen.

CTR and SSR coexistence on a single pageA page where 95% of UI is handled by CTR with near-zero cost,and only the Markdown article area uses SSR via raw HTML slot.Nav — CTR slot injectionSidebarCTRMarkdownraw HTML slotSSRFooter — CTR slot injectionUser info — CTR slot injectionCTR (~0.1ms)SSR (raw HTML slot)Raw HTML slot backendTypeScript — your existing SSRorRust — pulldown-cmark / comrakorGo — goldmark→ HTML string → insert slot

Die gute Nachricht ist also, dass raw HTML slot tatsächlich die Koexistenz von CTR und SSR ermöglicht. Das bedeutet, dass der Großteil der UI Ihrer Seite mit CTR erstellt wird, mit nahezu keinen Laufzeitkosten; und für den zentralen Markdown-Artikel verwenden Sie SSR zum Rendern. Da das Backend befreit wurde, können Sie TypeScript verwenden, um Ihre ursprüngliche SSR-Render-Methode zu importieren, oder Sie können den Horizont erweitern! Wenn das Backend Rust verwendet, nutzen Sie natürlich den Markdown-Compiler von Rust zum Rendern. Solange am Ende ein HTML-String bereitgestellt und in dieses raw slot eingefügt wird, kann er angezeigt werden. Die Verwendung von CTR bedeutet nicht, dass Sie SSR nicht verwenden können – beide können vollkommen koexistieren.

Ideal und Realität von PPR

Wenn man es so betrachtet, ist CTR im Wesentlichen der Idealfall von PPR (Partial Prerendering): Alles, was sich cachen lässt, wird vollständig zur Compile-Zeit gerendert, mit null Laufzeit-Overhead — nur der Teil der Daten, der sich tatsächlich ändert, verursacht Kosten. Was unterscheidet sich also vom PPR von React 19.2? Die Antwort ist, dass ich gründlicher, aggressiver bin PPR Selbst im Idealfall, in dem alle statischen Teile bereits gecacht sind und nur ein kleines Stück dynamischer Inhalt aktualisiert werden muss, muss React trotzdem noch einmal renderToReadableStream() durchlaufen, selbst wenn sich der dynamische Inhalt nur um eine einfache Zeichenkette ändert — und das ist kein geringer Aufwand.

PPR vs CTR — how they handle dynamic contentPPR runs renderToReadableStream for any change. CTR splits into two parallel paths:string replacement for simple types,render for complex types.PPRStatic partsAny dynamic changerenderToReadableStream()Cached ✓Even a simple stringStill heavyCTRStatic partsstring / number / boolString replace (~0.1ms)Markdown / Rich TextRender (any language)Build-time ✓95% of cases5% edge cases95% string replacement — 5% actual render,in any language

CTR zieht diese Grenze hingegen sehr deutlich: Bei einfachen Typen wie Zeichenketten, Zahlen und Booleans wird direkt eine Zeichenkettenersetzung durchgeführt; nur komplexe Typen mit extrem hohem Aufzählungsaufwand wie Markdown durchlaufen den echten render-Prozess. Zudem ist render hier nicht mehr das Konzept von SSR im traditionellen Sinne, sondern kann die Rendering-Methode jeder beliebigen Programmiersprache sein.

Auch RSC darf nicht fehlen

Sprechen wir nun über RSC (React Server Component). Ich habe früher erwähnt, dass RSC ein Fehler sein könnte—weil es zu viele Grenzen zwischen Frontend und Backend verwischt hat, einige Sicherheitsrisiken mit sich brachte und zahlreiche CVEs—aber eigentlich liegt dieser Fehler eher an Next.js und anderen SSR-Trends: Die Nutzer haben gar keine Chance, es nicht zu nutzen. Es wurde so hingestellt, dass man SSR braucht, wenn man Dynamik will, aber in der Praxis funktioniert eine solche String-Ersetzung wie unsere ebenso einwandfrei. Außerdem muss man zugeben, dass RSC dem Server eine sehr wichtige Fähigkeit verleiht: das Rendern beliebiger React-Komponenten, sprich das Ausführen beliebigen Codes.

Wenn man jedoch ohnehin beliebigen Code ausführt, ist es praktisch unmöglich, diesen mit Typen vollständig zu erfassen – selbst wenn es möglich wäre, wäre der Arbeitsaufwand nicht unbedingt geringer als das Schreiben eines React-Compilers. Daher hat eine solche Fähigkeit von RSC durchaus ihre Daseinsberechtigung. Können CTR und RSC koexistieren? Die Antwort lautet ja, und sie stehen in keinerlei Konflikt zueinander. Dies ist jedoch etwas, das auf Framework-Ebene gelöst werden muss und nicht zum Umfang des Protokolls selbst gehört. Ich habe es vorerst noch nicht implementiert, aber man könnte sich das anscheinend bei TanStack Start abschauen: Solange man ein zusätzliches html-Paket und ein js-Paket sendet, ist die theoretische Umsetzung sehr einfach – zumindest ist es ein klar überschaubarer Arbeitsaufwand.

Raw HTML slot — dead HTML inside a React component boundaryShows how raw HTML slot injects static HTML via inner-HTML setter,surrounded by interactive React components for styling.React componentBorder,style,layout — interactive ✓dangerouslySetInnerHTMLMarkdown / Rich Text — dead HTMLNo interaction after hydrationWhen you need SSR60% — raw HTML slot~zero cost,static inject40% — RSCServer execution,TODO

Um auf raw HTML slot zurückzukommen: Diese Lösung zielt hauptsächlich auf Szenarien wie Markdown und Rich Text ab. Das gerenderte HTML wird über dangerouslySetInnerHTML injiziert, und nach der Hydratisierung nimmt dieser Bereich nicht an der Interaktion teil – er ist "tot"; dieser Teil des Inhalts sollte minimiert werden. Wenn Sie dem Artikel Rahmen oder Stile hinzufügen möchten, sollten diese mit React-Komponenten geschrieben werden, anstatt sie in dieses HTML zu mischen. Der Nachteil dieses Ansatzes ist, dass er sich nach der Hydratisierung nicht mehr ändern kann, aber unter dieser Voraussetzung drückt er die Kosten für "SSR" tatsächlich auf ein sehr niedriges Niveau, das nahe an Zero Cost liegt. In Szenarien, in denen wirklich SSR benötigt wird, machen solche statischen HTML-Injizierungen etwa 60 % aus; erst die restlichen 40 % erfordern die Fähigkeit von RSC, beliebige Komponenten auf dem Server auszuführen.

Frontend-UI-Agnostizismus

Schließlich gibt es noch die sehr verlockende Protokoll-Agnostizismus: Im Grunde müssen wir nur den einen Schlüsselpunkt renderToString erfassen; welches UI-Framework man davor verwendet, hat eigentlich nichts mit dem Protokoll zu tun. Was ist dann der Unterschied zu Astro? Aber keine Sorge, ich baue definitiv nicht noch ein Astro. Oberflächlich betrachtet ähnelt mein Ansatz zwar ein wenig dem Insel-Konzept von Astro, aber in Wirklichkeit gibt es große Unterschiede.

Astro vs Seam — key architectural differencesAstro hydrates multiple runtimes and stays MPA. Seam picks one runtime per page,hydrates from MPA to SPA.AstroSingle pageReactruntimeVueruntimeSvelteruntimeMultiple runtimes hydratedMPA — always MPANo client routingSeamSingle page,one runtimeReactVueSvelteSolidPick one per pageMPA beforeSPA afterClient routing + transitions ✓

Ich hydratisiere nicht mehrere Runtimes auf einer einzigen Seite – humm, ehrlich gesagt halte ich die Szenarien, in denen man das wirklich braucht, für sehr begrenzt. Die Kommunikation des Komponentenzustands zwischen verschiedenen Tech-Stacks wird extrem teuer; es ähnelt eher einer Übergangslösung für die Migration von einem Tech-Stack zu einem anderen, wenn man nicht alles auf einmal austauschen kann, also erst einmal einen Übergang schafft. Zweitens ist Astro im Grunde eine MPA, während wir erreichen können, vor der Hydratisierung eine MPA und nach der Hydratisierung eine SPA zu sein. Genau wie Next.js verfügen wir über clientseitiges Routing und können seitenübergreifende Animationen erstellen – etwas, das Astro nur zu gerne hätte.

Astro und SSG

Darüber hinaus ist das Design von Astro eigentlich erst in Szenarien wirklich nützlich, die mehrere Tech-Stacks umfassen. Wenn man es nur wegen der Geschwindigkeit nutzt und dabei lediglich eines seiner Frameworks verwendet – zum Beispiel nur React einbindet und auf Vue verzichtet –, halte ich dieses angebliche „schnelle Tempo“ für ein Scheinargument. Es stimmt zwar, dass die initiale Seite komplett als HTML geladen wird, aber sobald man irgendeine Interaktivität möchte, muss hydratisiert werden. Die Kosten der Hydratisierung bestehen darin, die gesamte React Runtime herunterzuladen, was sich im Grunde nicht von unserer Hydratisierung unterscheidet. Natürlich können wir in Zukunft auch das Island-Konzept umsetzen und einen shell router hinzufügen, um eine UI-framework-übergreifende SPA-Navigation zu realisieren, aber das ist Thema der weiteren Roadmap – zumindest habe ich es jetzt nicht eilig.

SSG vs CTR at request timeSSG serves the same static HTML every time. CTR serves the same skeleton but injects fresh data per request.SSGRequestStatic HTMLBuilt at deploy timeSame page every timeHello,Alice — always AliceFast,but frozen — no per-request dataCTRRequestHTML skeletonBuilt once,reused+Fresh dataPer requestDynamic pageHello,Bob — ~0.1msSame speed as SSG + different data every request

Schließlich ist da der Vergleich mit traditionellem SSG: Ich habe das Gleiche erreicht wie SSG – im Grunde alles vorab zu rendern, was sich zur Kompilierzeit bestimmen lässt. Aber wir sind noch dynamischer, da jene Werte von einfachen slot-Typen zur Laufzeit vollständig ersetzt werden können. Man kann es so verstehen, dass wir SSG zu einer Art Einstiegspunkt für eine MPA rendern: Vor der Hydratisierung ist es eine MPA, nach der Hydratisierung wird es zu einer SPA, während die „dynamische“ Fähigkeit erhalten bleibt.

Vielleicht Abschied vom Hydration-Mismatch?

Schließlich gibt es noch das von mir so gehasste hydration mismatch, und ich wette, du magst es auch nicht; im Grunde läuft es jedoch nur auf einen uneinheitlichen DOM-Zustand hinaus. Traditionelle Frameworks wie der App Router von Next.js versuchen, die gesamte Anwendung mit React zu umhüllen – sobald auf Benutzerseite durch den Browser irgendwelche Tags injiziert werden, besteht die Wahrscheinlichkeit eines Hydrationsfehlers. Unsere Einschränkungen sind eigentlich dieselben wie bei traditionellem SSR, aber wenn du TS nutzt, habe ich zusätzlich ein Hydrations-div namens __root gewrappt, sodass der Hydrationsbereich den metadata-Bereich nicht mehr abdeckt, was für eine wesentlich robustere Hydration sorgt. Zudem bietet React 19 eine integrierte Unterstützung für Dokument-Metadaten-Tags wie <title>, <meta> und <link>. Selbst wenn du nur ein bestimmtes <div> auf der Seite hydratisierst (statt des gesamten <html>), wird React <title>My Page</title> beim direkten Rendern in einer Komponente automatisch nach <head> hoisten.

Hydration mismatch — traditional SSR vs CTR equivalence checkTraditional SSR discovers mismatch at runtime. CTR checks DOM equivalence at build time using mock data.Traditional SSRServer HTMLRuntime generatedvsClient DOMReact hydrationMismatch discoveredAt runtime — too lateCTR — build-time equivalence checkCTR skeleton+ mock data filledvsrenderToReadableStream()Same mock dataDOM-tree diffAt build time — before deployMatch ✓ safeMismatch → CLI warnsEscape hatch:any — like TypeScript,you accept the risk

Zurück zu hydration mismatch: Da wir bereits zur Kompilierzeit den Typ jedes slot kennen, können wir eine zusätzliche CTR-Äquivalenzprüfung hinzufügen. Dabei werden aus Typdefinitionen abgeleitete Mock-Daten in das vollständig aufgelöste HTML eingefügt, dieses einmal mit dem traditionellen renderToReadableStream() ausgeführt und anschließend verglichen, ob die DOM-Tree-Semantik beider Seiten äquivalent ist – Formatierungsunterschiede (fmt) werden dabei ignoriert. Sie weisen möglicherweise geringfügige Formatabweichungen auf, aber solange die DOM-Struktur strikt und vollständig äquivalent ist, kann man sich eigentlich von hydration mismatch verabschieden; Warum ist das mit traditionellem SSR nicht möglich? Weil dies dort erst zur Laufzeit geschieht, während die CTR-Struktur festlegt, dass Sie diese Einschränkungen bereits zur Kompilierzeit vollständig umsetzen müssen. Natürlich bieten wir mit any auch einen Escape Hatch an, aber genau wie bei TypeScript gilt: Wenn Sie any verwenden, müssen Sie die Konsequenzen selbst tragen. Die CLI wird Sie beim Kompilieren warnen, dass der any-Escape-Open-String zu einem Mismatch führen kann.

Serverless erkunden

Natürlich darf auch die Möglichkeit von Serverless nicht fehlen. In den letzten Jahren ist die Erfahrung mit Serverless ausgesprochen gut geworden – ich würde sie als „hat außer, dass es teuer ist, keine Nachteile“ bewerten. CTR eignet sich jedoch von Natur aus hervorragend für dieses Szenario: Was unsere Runtime tut, ist sehr leicht und klein, sodass die Ausführung auf Serverless extrem schnell sein wird und Antwortzeiten sowie Overhead spürbar verbessert werden. Der verbleibende Bereich, der nicht weiter optimiert werden kann, sind Szenarien wie das Rendern von Markdown, aber das sollte vollständig in der Geschäftslogik optimiert werden – zum Beispiel, indem Markdown vorgerendert und gespeichert wird, sodass es nicht bei jeder Anfrage erneut gerendert werden muss, genau wie es auf meiner jetzigen Website gemacht wird. Das ist ein Problem der Geschäftsebene und nichts, was das Framework lösen kann, aber das Framework kann Ihnen helfen, den Overhead einfacher Logik darüber hinaus auf nahezu null zu drücken. Im Vergleich zu traditionellem SSR handelt es sich nicht mehr um dieselbe Größenordnung an Overhead; wie klein ist das? Es geht ungefähr um einige hundert us ~ 1ms, etwas, wovon man bei traditionellem SSR nicht einmal zu träumen wagt.

Traditional SSR pipelineEvery request rebuilds the component tree and runs renderToString,costing 100-300ms per request.Traditional SSREvery requestRequestBuild treeComponent treerenderToString()~100-300msHTML responsePer-request cost
CTR pipelineRendering happens once at build time producing an HTML skeleton with typed slots. At request time only slot injection runs,costing 0.1-1ms.CTR (Compile-Time Rendering)Build time (once)renderToString()Sentinel dataDiff & detectif / each / matchHTML skeletonTyped slot markersOne-time build costRequest timeRequestSlot injection~0.1-1msHTML responseClient hydrationReact takes overPer-request cost

Was aber, wenn das Backend in einer anderen Sprache geschrieben ist? Nehmen wir Cloudflare Workers als Beispiel: Tatsächlich unterstützen viele Serverless-Plattformen WASM, sodass andere Sprachen, nachdem sie zu einer WASM BIN kompiliert wurden, ebenso als Backend verwendet werden können. Im Grunde kompiliere ich das Frontend in reine statische Ressourcen, ähnlich wie bei CSR. Aber in Kombination mit einer privaten Brücke /_seam/ können diese statischen Ressourcen dieselben dynamischen Funktionen bieten wie echte Full-Stack-Frameworks und sind somit natürlich auch vollkommen Serverless-kompatibel.

SeamJS

Sind Sie also schon verwirrt über die Beziehung zwischen Seam und SeamJS? Eigentlich sind die beiden sehr einfach: Seam ist ein Protokoll. Es definiert, wie man dynamische Positionen mit Sentinel markiert, wie man sie in slot-Marker umwandelt, wie man die diff-Erkennung für Bedingungs- und Schleifenblöcke durchführt und wie die Dateninjektion zur Laufzeit auf Basis des AST erfolgt. Das Protokoll selbst ist sprachunabhängig; jedes Backend, das HTML-Kommentare parsen und String-Ersetzungen vornehmen kann, kann es implementieren. SeamJS ist ein Framework, eine konkrete Implementierung auf Basis dieses Protokolls. Es näht bereits vorhandene Räder wie Vite, TanStack Router und TanStack Query zusammen und deckt die Bereiche ab, die diese nicht abdecken, wie z. B. meine skeleton-Extraktion, die Injektions-Engine, die CLI und Ähnliches.

Seam (protocol) vs SeamJS (framework)Seam is the language-agnostic protocol defining sentinel,slot,diff and injection. SeamJS is a concrete implementation stitching existing tools with custom pipelines.Seam — protocolLanguage agnosticSentinelSlot markersDiff detectionAST injectionAny backend:Rust,Go,TypeScript,...SeamJS — frameworkConcrete implementationViteTanStack RouterTanStack QuerySkeletonCLIExisting tools stitchedCustom built

Aber um ehrlich zu sein, befindet sich SeamJS derzeit noch in einem sehr rudimentären Zustand. Es läuft zwar problemlos, aber um damit tatsächlich Projekte zu schreiben, bedarf es noch viel Feinschliff. Ich denke auch verstärkt über einige architektonische Reformen nach, wie etwa die Abstraktion des Datenübertragungskanals, bin mir aber nicht sicher, ob ich diese Richtung in zukünftigen Versionen beibehalten werde. Auf Framework-Ebene werde ich definitiv ein TypeScript-Full-Stack-Framework sowie ein Flaggschiff-Framework mit Rust als Backend entwickeln; was die Go-bezogene Implementierung betrifft, wird sie in zukünftigen Versionen entfernt, da ich spüre, dass mir die Energie fehlt, sie weiter zu pflegen.

::github
repo = "canmi21/seam"
align = "left"

Nicht nur ein Web-Framework

Seam protocol — swap transport,same resultSame Seam protocol works on Web via HTTP and Desktop via IPC.Seam protocolHTTPBrowserServerless,CDNFull SSR-like pageData ready on arrivalSeam protocolIPCElectronTauriNo loading screenInstant renderSame protocol,swap transport

Wozu ist das Ganze nun eigentlich nütze? Tatsächlich ist es keineswegs auf das Web beschränkt. Wenn ich beispielsweise auch den Transport-Kanal abstrahiere, lässt es sich später in Desktop-Umgebungen wie Electron oder Tauri portieren. Solange man die HTTP-Transportpipeline durch IPC-Kommunikation ersetzt, läuft weiterhin dasselbe Seam-Protokoll. Stattdessen müssen diese Electron-Apps beim Starten nie wieder einen loading-Bildschirm anzeigen, und viele Dinge können direkt wie bei SSR lokal gerendert werden – das ist die Magie von CTR!

Die Kosten von No JS Runtime

Warum hat SeamJS am Ende also doch einen JS Runtime hinzugefügt? Weil ich bei der Umsetzung dieser Lösung auf ein Problem gestoßen bin: Das Ideal von CTR stellt extrem strenge Anforderungen an die Daten für den ersten Bildschirm. Die Daten müssen eine deterministische Struktur sein, die sich vollständig ableiten lässt, und dürfen keinerlei Berechnungslogik enthalten, sondern nur Bedingungen. Das wurde äußerst streng. Wie man sich bei der Entwicklererfahrung eines Frameworks vorstellen kann, erwarten traditionelle React-Entwickler natürlich, in den Komponenten einige Berechnungen durchzuführen und die Werte direkt zu verwenden. Wenn man sich strikt an die Vorgaben von CTR halten müsste, um einen Zustand von ready-to-display zu erreichen, wäre der Prozess extrem umständlich und würde sogar dazu führen, dass man eine Komponente zweimal schreiben muss.

Why SeamJS needs a JS Runtime — the derive stepRaw data from backend goes through a small JS derive step to become ready-to-display,then CTR injects it.Strict mode — no JS RuntimeRaw dataFrom DB / APIMust be ready-to-displayNo compute,only conditionsComponent written 2xTedious DXWith JS Runtime — derive modeRaw dataFrom DB / APIJS derive()Compute logicready-to-displayTyped structCTR injectWrite once ✓

Aber es gibt tatsächlich eine Lösung: Aus Web-Gründen kann das Frontend nur auf JavaScript laufen, weshalb Komponenten natürlicherweise nur JS ausführen können. Daher muss das Backend über JS-Ausführungsfähigkeiten verfügen, um dieses Problem zu lösen. Bei einem TypeScript-Fullstack-Projekt kann dafür die eigene Laufzeitumgebung genutzt werden; in Rust hingegen muss man lediglich eine sehr winzige JS Runtime wie QuickJS einbetten. Beachten Sie, dass die JavaScript Runtime hier eine standardmäßige JS-Teilmenge ist und ganz und gar nicht eine Laufzeitumgebung wie Bun oder Node mit vollständigen Betriebssystem-APIs – sie dient wirklich nur zur Herleitung, zum derive.

JS Runtime size comparison — QuickJS vs Node vs BunQuickJS is 200-300KB,compared to Node at ~100MB and Bun at ~50MB.JS Runtime size comparisonQuickJS~200-300KBJS subset,derive onlyBun~50MBFull runtime + OS APIsNode.js~100MBFull runtime + OS APIs + npm

Auf diese Weise kann man weiterhin eine gewisse Berechnungslogik innerhalb der Komponenten schreiben. Nachdem das Backend die Daten abgerufen hat, führt diese Logik ein kleines Stück JS aus, um sie in den Zustand ready-to-display abzuleiten, und sendet sie dann für die Hydrierung des ersten Bildschirms an das Frontend zurück. Dadurch ist CTR zufrieden, da streng strukturierte abgeleitete Daten vorliegen, die DX der Entwickler verbessert sich und Komponenten müssen nur einmal geschrieben werden. Wenn Sie sich natürlich strikt an reine Typ-Constraints halten möchten, kann das Backend tatsächlich No JS Runtime erreichen – dieses Versprechen gilt eigentlich immer noch. Wie dem auch sei, diese neu hinzugefügte winzige JavaScript Runtime hat einen weit geringeren Performance-Overhead, Speicherverbrauch und Umfang als Dinge wie Node – im Grunde nur etwa 200–300 KB – bringt aber enorme Flexibilität mit sich.

Ausblick

Nach all dem Gerede: Wann kann man es denn endlich nutzen? Wahrscheinlich wird es noch sehr, sehr lange dauern. Ich will wirklich nicht kneifen, aber ich habe das Gefühl, dass mich dieses Ding in der jetzigen Phase einfach viel zu sehr einnimmt. Wenn ich es nur für die Entwicklung des Frameworks nutzen kann, kann ich im Grunde keine echten Anwendungen schreiben – egal was ich bauen will, ich stolpere sofort über das Framework selbst, ganz davon abgesehen, dass ich in letzter Zeit sowieso hauptsächlich an dieser Website gearbeitet habe. Also habe ich mir überlegt: Warum baue ich die Website nicht erst einmal bis zu einer gewissen Größe aus? Dann weiß ich genau, was meine tatsächlichen Anforderungen sind. Wenn ich mich später wieder an SeamJS mache, habe ich eine TODO List, und sobald die Funktionen nach und nach umgesetzt sind, ist es nutzbar. Und später kann ich dazu noch einen Beitrag über Migration und Benchmark nachschieben? So, genug Luftschlösser gebaut, das Konzept steht jedenfalls. Egal ob es heute schon benutzbar ist oder nicht – gedanklich steht der Prototyp. Gute Nacht 💤