

1/1/1970
Wie ich SSG in 2 Tagen implementiert habe
Howdy! Vor einem Jahr hätte ich gedacht, dass das unmöglich ist. Aber ich habe gerade Static Site Generation (SSG) für Foony in 2 Tagen implementiert und bin ziemlich begeistert davon. Das ist auch nicht das erste Mal, dass ich versucht habe, SSG für Foony zu lösen. Ich habe mir in der Vergangenheit NextJS, Vike, Astro, Gatsby und ein paar andere Lösungen angeschaut. Ich hatte sogar einen Fehlstart mit NextJS, bin aber an der Komplexität von Foonys SPA und tausenden Dateien gescheitert. Die Migration wäre ein Albtraum gewesen und hätte Monate gedauert. Außerdem hätte sie zusätzliche Komplexität für alle anderen gebracht, die an der Seite arbeiten, weil sie NextJS und seine Eigenheiten erst hätten lernen müssen.
Ich wollte etwas Leichtgewichtiges, das sich einfach umsetzen lässt. Etwas, das uns erlaubt, den Code weiterhin so zu schreiben wie bisher, ohne ständig an SSG denken zu müssen (mit der Ausnahme von useMediaQuery - da kommt man nicht wirklich drum herum). Unten erzähle ich, warum ich mich für eine maßgeschneiderte Lösung entschieden habe, welche konkreten Herausforderungen es gab (vor allem mit den Suspense-Boundaries von React) und wie ich sie gelöst habe.
Warum keine Standardlösungen?
Als ich mir zum ersten Mal überlegt habe, SSG zu Foony hinzuzufügen, habe ich mir natürlich NextJS (quasi der Industriestandard), Vike und Astro angeschaut.
NextJS: Zu viel Migration
NextJS ist mächtig, hätte aber eine riesige Migration der bestehenden React-SPA von Foony erfordert. Wir haben tausende Dateien, komplexe Routing-Logik und jede Menge eigene Infrastruktur. Eine Migration zu NextJS hätte bedeutet:
- Unser komplettes Routingsystem neu zu schreiben
- Umzustrukturieren, wie wir Spiele und Komponenten laden
- Monatelange Arbeit, nur um wieder Feature-Parität zu erreichen
- Mögliche Breaking Changes für Nutzerinnen und Nutzer
- Unsere komplette Bildverarbeitung umzustellen
- Deutlich langsamere Build-Zeiten (potenziell 5 bis 30 Minuten. Ich habe dafür keine harten Zahlen, außer dieser 5 Jahre alten Diskussion auf GitHub)
- Das ganze Team müsste etwas Neues lernen (NextJS), und unsere Entwicklungsgeschwindigkeit wäre dauerhaft niedriger
- Den Code jedes Mal migrieren, wenn NextJS wieder Breaking Changes einführt
Ich habe den Fehlstart mit NextJS sogar ausprobiert, aber ziemlich schnell gemerkt, dass die Migrationskosten viel zu hoch wären. Die zusätzliche Komplexität war es einfach nicht wert.
Vike: Ähnlich komplex
Vike (früher vite-plugin-ssr) hatte ähnliche Probleme. Es ist zwar flexibler als NextJS, hätte aber trotzdem eine massive Umstrukturierung unseres Codebases verlangt. Lernkurve und Migrationsaufwand standen für uns einfach in keinem Verhältnis zum Nutzen.
Astro: Falsche Architektur
Astro ist super für inhaltslastige Seiten, aber Foony ist eine komplexe Multiplayer-Spielplattform. Wir brauchen Echtzeit-Updates, WebSocket-Verbindungen und dynamische React-Komponenten. Die Architektur von Astro passt einfach nicht zu dem, was wir bauen.
Die Lösung: Maßgeschneidertes SSG
Bestärkt durch meinen „Fake-SSG“-Ansatz, den ich ein paar Tage zuvor nach dem i18n-Rollout gebaut hatte, habe ich mich für eine kleine, leichtgewichtige, maßgeschneiderte Lösung für das SSG von Foony entschieden.
Bei meinem „Fake-SSG“-Ansatz habe ich den Blog-Content von Seiten mit Blogposts (
/posts-Routen und Spielseiten) herausgezogen und genau dort platziert, wo der Client ihn rendern würde. Speziell Suchmaschinen und LLMs sollten Foony dadurch besser verstehen können. Außerdem habe ich noch ein ld+json-Schema und ein bisschen SEO-Kleinkram hinzugefügt.
Der Ansatz ist simpel:
- Auf der bestehenden React-SPA aufbauen: Keine Migration nötig, wir fügen nur zur Build-Zeit eine SSG-Generierung hinzu.
renderToReadableStreamverwenden: Die Streaming-SSR-API von React 18 kann Suspense nativ handhaben.- Statische HTML-Dateien generieren: Routen zur Build-Zeit vorab rendern und als statische Dateien ausliefern, wobei unser SitemapGenerator die Liste der Routen liefert.
- Minimale Änderungen am bestehenden Codebase: Die meisten Komponenten funktionieren einfach weiter wie bisher.
Die Kernimplementierung liegt in client/src/generators/GenerateShellSsgFromSitemap.ts. Das Script liest eine Sitemap ein, rendert jede Route mit Reacts renderToReadableStream und schreibt das HTML in statische Dateien. Einfach, genau so mag ich es!
Am Ende war das auch ziemlich schnell. Rund 2.800 Routen wurden in 10 Sekunden gerendert. Ziemlich gut. Das ist deutlich schneller als NextJS, Gatsby und Astro. <img alt="SSG-Konsolenlog mit der benötigten Zeit" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Über Einfachheit könnte ich ewig reden. Auch wenn sie dir in großen Firmen vielleicht keine Beförderung einbringt, weil „zu wenig komplex“, ist einfacher Code schön, wartbar und insgesamt viel besser für die Entwicklungsgeschwindigkeit. Das ist etwas, das ich an den Zen-Prinzipien wirklich bewundere.
Das Suspense-Boundary-Problem
Also hatte ich jetzt SSG, und der Content tauchte im HTML auf ... aber meine Seiten waren leer! Wie kann das sein?! <img alt="Leere SSG-Seite" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Es stellte sich heraus, dass renderToReadableStream immer noch Suspense-Boundaries enthält, selbst wenn du await stream.allReady verwendest. Meine Vermutung ist, dass das daran liegt, dass es sich um einen Stream handelt, der dafür gedacht ist, direkt an Clients geschickt zu werden, sobald Bytes reinkommen.
Was React ausgibt
Wenn du renderToReadableStream mit Suspense verwendest, gibt React in etwa folgendes HTML aus:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
Das <template id="B:0"> ist ein Platzhalter, an dessen Stelle der Inhalt landen soll. Das <div hidden id="S:0"> enthält den tatsächlich gerenderten Inhalt. B:0 und S:0 werden über die Zahl dahinter verknüpft (0-basierter Index).
Ohne JavaScript sehen Suchmaschinen (ja, Bing, ich meine dich) und LLMs fast nur eine leere Seite mit einem Template-Platzhalter. Damit ist der ganze Zweck von SSG dahin!
Ich habe keinen sauberen Weg gefunden, diese Suspense-Boundaries direkt loszuwerden, also habe ich Tests geschrieben und eine Funktion resolveSuspenseBoundaries, die sie austauscht. Das war schneller, als das HTML zu parsen und das Script mit etwas wie JSDOM auszuführen. Und noch wichtiger: Es war nötig für das, was ich vorhatte, nämlich eine schöne, gut lesbare Seite für Suchmaschinen und LLMs ohne JavaScript, aber mit Unterstützung für Suspense-Boundaries und Hydration auf dem Client.
Die Transformation testen
Angefangen habe ich damit, Tests für die Transformation zu schreiben. Dafür habe ich mir Beispiele aus dem DOM geschnappt: einmal den Zustand mit deaktiviertem JavaScript (also das, was ich hatte) und einmal den Zustand mit aktiviertem JavaScript (also das, was ich wollte). Diese Beispiele habe ich in ein LLM gefüttert und es die Testfälle generieren lassen, darin ist es ziemlich gut.
Die Tests liegen in client/src/generators/ssr/renderRoute.test.ts und stellen sicher, dass die Transformation korrekt funktioniert. Abgedeckt sind unter anderem:
- Einfacher Austausch einer Boundary (Blog-Listing)
- Komplexe Boundaries mit Inhalt zwischen Template und schließendem Kommentar
- Mehrere Boundaries
- Boundaries ohne Kommentar-Markierungen
- Edge Cases
Diese Art von „TDD“ ist in so einem Fall tatsächlich ziemlich nützlich, wenn man klare Eingaben und erwartete Ausgaben hat.
Das darf man nicht verwechseln mit „TDD überall, weil Robert C. Martin das gesagt hat“ (was eure Entwicklungsgeschwindigkeit massiv ausbremst). Du solltest TDD NICHT für UI oder Bereiche nutzen, die sich ständig ändern!
Die Lösung: resolveSuspenseBoundaries
Als die Tests standen, habe ich das LLM die Funktion resolveSuspenseBoundaries schreiben lassen. Ich habe mich dabei für cheerio entschieden, um nicht mit zerbrechlichen RegEx-Ansätzen arbeiten zu müssen, auch wenn Regex hier die SSG-Zeit vermutlich um etwa 40 % verkürzt hätte.
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
Das stellt sicher, dass Suchmaschinen und LLMs keine fast leere Seite mehr sehen, sondern eine voll gerenderte.
Damit funktioniert SSG jetzt auch ohne JavaScript richtig gut!
<img alt="SSG ohne JavaScript für die Blogposts von Foony" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
Langfristig ist es gut möglich, dass React das Suspense-Format ändert. Vielleicht werfe ich den Suspense-Resolving-Code wieder raus, sobald ich eine bessere Lösung für Seiten habe, die lazy geladen werden (und deshalb Suspense-Boundaries brauchen).
Hydration-Strategie (Update: Das hat 3 Tage + 1 Extratag gedauert)
Hydration ist knifflig. Das wusste ich. Aber nach ein bisschen Arbeit habe ich es zum Laufen bekommen!
Gesamte Zeit für die Hydration: 3 Tage plus 1 Extratag, um den Dehydration-Ansatz zu ersetzen.
Der schwierigste Teil war, überhaupt das erste minimale, funktionierende Hydrate hinzubekommen. Als ich es geschafft hatte, ein „Hello World“ zusammen mit der Navbar zu rendern, hatte ich endlich die Zuversicht: Okay, das muss vielleicht doch keinen ganzen Monat dauern!
<img alt="Foonys „Hello World“ hydratisiert erfolgreich mit Navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Für dieses erste minimale, funktionierende Hydrate gab es eine besondere Herausforderung: Ich wollte Hydration, aber ich wollte auch gutes SEO für Suchmaschinen und LLMs, ohne dass Entwicklerinnen und Entwickler sich Gedanken über Suspense-Boundaries machen müssen.
Die Herausforderung
React-Hydration ist extrem pingelig: Wenn das DOM beim ersten Render nicht exakt so aussieht, wie React es erwartet, bekommst du eine hübsche, aber praktisch nutzlose Fehlermeldung in der Konsole, und React wirft einfach alles weg und rendert komplett neu. Nicht einmal ein Diff, das dir sagt, was schiefgelaufen ist!
In unserem Fall hat SSG das Ganze auf mehrere Arten noch schlimmer gemacht:
- Wir haben das HTML nachverarbeitet, um die Streaming-Suspense-Artefakte von React 18 zu entfernen oder aufzulösen (was für Bots super ist).
- Der Client hatte zum Zeitpunkt (t = 0) nicht immer genau dieselben Daten zur Verfügung wie das Server-Rendering (SSG-Daten, Blog-Metadaten usw.).
- Unser i18n ist standardmäßig „lazy“, was bedeutet, dass Übersetzungen beim ersten Render fehlen können, wenn man nicht mitschreibt, welche Keys beim SSG benutzt wurden, und sie vor dem React-Render einspeist.
Was funktioniert hat (erster Ansatz: Dehydration)
Am Anfang habe ich etwas Cleveres und Niedliches versucht: Ich habe ein Command-Pattern genutzt, um die Befehle aufzuzeichnen, mit denen die Suspense-Boundaries im HTML aufgelöst wurden, und habe die umgekehrten Transformationsbefehle zurückgegeben, damit ich das HTML wieder in den Zustand bringen kann, den React für die Hydration erwartet.
Meine Hoffnung war, mit dieser Command-Methode deutlich weniger Bytes in der index.html ausliefern zu müssen. Aber wie bei den meisten zu cleveren Lösungen ist das daran gescheitert, dass Browser das HTML auf subtile Weise verändern, zum Beispiel ein ; oder / entfernen oder hinzufügen, was dann die Ersetz-Indizes zerschossen hat.
Technisch könnte man diese feinen Browser-Anpassungen wahrscheinlich berücksichtigen, aber so etwas Fragiles wollte ich nicht ausliefern.
Statt zu versuchen, die Suspense-Transformation wieder zurück in Reacts Streaming-Markup zu „reversen“, habe ich etwas super Einfaches gemacht:
Das ursprüngliche, ungelöste HTML in einem <script type="text"> mit ausliefern.
Dieser „Dehydration“-Ansatz hat funktioniert, aber ich habe dann noch einen Extratag damit verbracht, ihn durch eine bessere Lösung zu ersetzen.
Der bessere Ansatz: Critical-Path-Suspense-Boundary-Replacement
Nach der ersten Implementierung hatte ich immer noch ein paar Probleme mit den Suspense-Boundaries. Da wurde mir klar, dass es eine sauberere, bessere und einfachere Lösung gibt. Ich habe den Dehydration-Ansatz durch Critical-Path-Suspense-Boundary-Replacement ersetzt, das:
- Den kritischen Pfad vor der Hydration lädt: Komponenten, die während der SSR vorab geladen wurden, werden identifiziert und auch auf dem Client vor
hydrateRoot synchron vorab geladen
- Einfacher zu warten ist: Keine React-Interna oder AST-Parsing nötig (der Dehydration-Ansatz musste HTML parsen und wiederherstellen)
- Weniger Bytes ausliefert: Wir bundlen die ursprüngliche SSR-Antwort von React nicht mehr in einem Script-Tag
- Einen möglichen Flash verhindert: Es ist kein Dehydratisieren/Rehydratisieren von HTML nötig, was einen visuellen Flash vermeiden kann
Die Implementierung verfolgt, welche Lazy-Komponenten während der SSR vorab geladen wurden (über den SSRLazyComponentTracker), packt deren Import-Pfade in die Hydration-Daten und lädt sie vor der Hydration synchron vor. Critical-Path-Komponenten rendern dann direkt ohne Suspense-Boundaries und entsprechen damit exakt der SSR-Ausgabe.
Für alles andere lassen wir den ersten Client-Render wie SSR/SSG agieren. Das heißt: dieselben Eingaben verwenden und diese Eingaben synchron bereitstellen, bevor hydrateRoot aufgerufen wird. Das läuft über unser gebündeltes „ssg-data“.
Konkret sahen die Anpassungen so aus:
SSR-Eingaben in ein einziges Text-Script bündeln
- Während des SSG injizieren wir direkt vor dem Vite-Module-Entrypoint ein
<script type="text/foony-ssg" id="foony-ssg-data">...</script>.
- Dieses Script enthält:
html: das aufgelöste HTML, das wir tatsächlich in der statischen Datei ausliefern
ssgData: die serialisierte SSGData, die der SSR-Wrapper verwendet. Ich plane, das auf etwas wie einen Proxy umzustellen, damit nur tatsächlich genutzte Daten enthalten sind.
translationData: die Übersetzungs-Key-Value-Blobs, die wir während der SSR angefasst haben
Diese Eingaben direkt vor der Hydration einspeisen
- In
main.tsx setzen wir synchron:
#root.innerHTML auf das serialisierte, aufgelöste HTML (damit das DOM exakt dem entspricht, was die Hydration sieht)
- die App wird in einen
SSGDataProvider gewrappt, damit die Komponenten beim ersten Render dieselbe SSGData bekommen
i18n sofort verfügbar machen, indem wir Übersetzungswerte injizieren
- Wir zeichnen die tatsächlichen Übersetzungsobjekte auf, die während der SSR benutzt wurden, und liefern sie im SSG-Script mit aus.
- Auf dem Client injizieren wir sie direkt in den Cache von
LocaleQueryer über eine eigene Methode LocaleQueryer.inject(), damit die Übersetzungen sofort verfügbar sind.
Und damit hat der erste Render genau dieselben Daten wie die SSR!
Der Hook useIsSSRMode() ist bereits in client/src/generators/ssr/isSSRMode.ts implementiert:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Dieser Hook gibt während der SSR und beim ersten Client-Render (Hydration) true zurück und wechselt nach dem Mount auf false. Komponenten wie UserBanner, Navbar und Dialog nutzen das bereits, um Hydration-Mismatches zu vermeiden.
- React patchen für bessere Diffs
Ich hatte gehofft, ich könnte einfach hydration-overlay verwenden. Aber das Projekt wird nicht aktiv gepflegt, unterstützt nur bis React 18 und war nicht wirklich produktionsreif. Also habe ich ein LLM das Repo als Inspiration klonen lassen und es dann in ein paar Minuten ein minimales Hydration-Overlay bauen lassen. Ich brauchte nichts Ausgefallenes, nur etwas, das während der Entwicklung sichtbar ist und mir zeigt, wo etwas schief läuft.
Dieses neue Overlay ist extrem simpel, deshalb sind die Diffs noch nicht ganz perfekt. React entfernt Kommentare, fügt ; hinter Style-Attributen hinzu, verändert Whitespace und macht noch ein paar andere Kleinigkeiten, die unser Overlay (noch) nicht berücksichtigt. Außerdem enthält unser Overlay HTML-Kommentare, die React bei seiner Hydration ignoriert.
<img alt="Unser neues Hydration-Overlay" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Aber es reicht völlig, um zu sehen, was gefixt werden muss.
<img alt="Diff zwischen unserem SSG und dem ersten Client-Render für React-Hydration" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
Zahlen und Fakten
Damit du ein Gefühl dafür bekommst, was in dieser Implementierung steckt:
- 2 Tage Arbeit (vom Start bis zum funktionierenden SSG). Effektiv etwas über 24 Stunden, während ich im Urlaub war.
- 4 Tage Arbeit, damit sich Hydration sauber verhält, ohne asynchrone Übersetzungs-Races oder Ärger mit
useMediaQuery.
- 1 Extratag, um den Dehydration-Ansatz durch Critical-Path-Suspense-Boundary-Replacement zu ersetzen (einfacher, weniger Bytes, kein potenzieller Flash).
- ~200 Zeilen Kerncode für die SSG-Generierung (
GenerateShellSsgFromSitemap.ts)
- ~120 Zeilen für die Suspense-Boundary-Auflösung (
resolveSuspenseBoundaries in renderRoute.tsx) - Hinweis: Das wurde später durch den Critical-Path-Ansatz ersetzt
- ~50 Zeilen SSR-Utilities (
isSSRMode.ts)
- ~100 Zeilen Tests (
renderRoute.test.ts)
- ~150 Zeilen Polyfills für SSR (
setupSSREnvironment)
- Minimale Änderungen an bestehenden Komponenten (hauptsächlich das Hinzufügen von
useIsSSRMode()-Checks)
Die Lösung ist leichtgewichtig und gut wartbar. Sie erfordert keine Framework-Migration und funktioniert mit unserer bestehenden React-SPA.
Wichtige Erkenntnisse
Manchmal ist eine maßgeschneiderte Lösung besser
Nicht jedes Problem braucht ein Framework. Für Foony war eine kleine, maßgeschneiderte SSG-Lösung genau die richtige Wahl. Sie ist:
- Leichtgewichtig: Keine schweren Abhängigkeiten oder Framework-Overhead
- Wartbar: Einfacher Code, den wir verstehen
- Flexibel: Leicht anpassbar und erweiterbar, wenn wir es brauchen
- Kompatibel: Funktioniert mit unserer bestehenden React-SPA ohne Migration
Reacts Streaming-SSR hat ihre Eigenheiten
React renderToReadableStream ist super, um mit Suspense umzugehen, hat aber seine Eigenheiten. Selbst mit await stream.allReady bekommst du im Output immer noch Suspense-Boundaries. Das ist kein Bug, das ist für Streaming so gedacht. Für SSG brauchen wir aber vollständig aufgelöstes HTML. Es fühlt sich ein bisschen wie ein Versäumnis des React-Teams an, dass dieses Szenario nicht auf eine saubere Weise abgedeckt wird.
Meine Lösung war, das HTML nachträglich zu verarbeiten und die Boundaries aufzulösen. Das ist nicht wunderschön, aber schnell und flexibel genug für meinen Use Case.
TDD kann mit LLMs nützlich sein
HTML-Transformation ist fehleranfällig. Ein kleiner Bug, und du zerschießt das komplette SSG-Output und damit die User Experience. Ich habe ein LLM mit meinen Beispielen umfassende Tests schreiben lassen, um sicherzustellen, dass die Transformation korrekt funktioniert.
Fazit
SSG läuft jetzt für Foony. Die Seiten werden für Suchmaschinen und LLMs vollständig gerendert, und die Lösung ist wartbar und leichtgewichtig. Die Hydration für die SSG-Routen hat länger gedauert als gedacht (3 Tage), und ich habe noch einen Extratag darauf verwendet, den ersten Dehydration-Ansatz durch Critical-Path-Suspense-Boundary-Replacement zu ersetzen. Der neue Ansatz ist einfacher zu warten, liefert weniger Bytes aus und verhindert visuelle Flashes durch Dehydration/Rehydration von HTML.
Ich bin immer noch überrascht, dass es nur 2 Tage gedauert hat, eine eigene SSG-Lösung zu bauen. Aber manchmal ist die richtige Lösung einfach die simpelste.
Als Nächstes steht an, das Hydration-Matching fertigzustellen und React eventuell für besseres Debugging zu patchen. Aber fürs Erste hat Foony funktionierendes SSG. In den nächsten Wochen werde ich die Google Search Console und die Bing Webmaster Tools im Blick behalten, um zu sehen, welchen Effekt das auf unser SEO hat.