

1/1/1970
Hoe ik SSG in 2 dagen heb geïmplementeerd
Howdy! Een jaar geleden dacht ik dat dit onmogelijk was. Maar ik heb zojuist Static Site Generation (SSG) voor Foony in 2 dagen ingebouwd, en daar ben ik best enthousiast over. Het is ook niet de eerste keer dat ik probeer SSG voor Foony op te lossen. Ik heb in het verleden naar NextJS, Vike, Astro, Gatsby en nog een paar andere oplossingen gekeken. Ik heb zelfs een valse start gemaakt met NextJS, maar liep al snel vast op de complexiteit van Foony's SPA en duizenden bestanden. De migratie zou een nachtmerrie zijn geweest en maanden hebben geduurd. Het zou ook extra complexiteit hebben toegevoegd voor iedereen die aan de site werkt, omdat ze dan NextJS en al zijn eigenaardigheden zouden moeten leren.
Ik wilde iets lichts en makkelijk te implementeren. Iets waarmee we code gewoon konden blijven schrijven zoals we dat al doen, zonder steeds aan SSG te hoeven denken (met uitzondering van useMediaQuery, daar is niet echt een nette omweg voor). Hieronder leg ik uit waarom ik voor een maatwerkoplossing ben gegaan, welke specifieke uitdagingen ik tegenkwam (vooral met de Suspense-boundaries van React) en hoe ik die heb opgelost.
Waarom geen standaardoplossingen?
Toen ik voor het eerst naar SSG voor Foony keek, waren NextJS (de industry standard), Vike en Astro natuurlijk de eerste kandidaten.
NextJS: te veel migratie
NextJS is krachtig, maar het had een gigantische migratie van Foony's bestaande React SPA gevraagd. We hebben duizenden bestanden, complexe routering en een hoop eigen infrastructuur. Migreren naar NextJS zou betekend hebben:
- Ons hele routeringssysteem herschrijven
- Opnieuw bepalen hoe we games en componenten laden
- Maanden werk, alleen al om terug te komen op hetzelfde niveau qua features
- Potentiële breaking changes voor gebruikers
- De manier waarop we images afhandelen veranderen
- Aanzienlijk tragere build-tijden (mogelijk 5-30 minuten. Ik heb geen harde cijfers om dit te staven behalve deze 5 jaar oude discussie op GitHub)
- Het hele team dat iets nieuws moet leren (NextJS), en voor altijd tragere ontwikkelsnelheid
- De code opnieuw migreren elke keer dat NextJS besluit om breaking changes te introduceren.
Ik heb zelfs een valse start gemaakt met NextJS, maar kwam al snel tot de conclusie dat de migratiekosten veel te hoog waren. De complexiteit was het niet waard.
Vike: vergelijkbare complexiteit
Vike (voorheen vite-plugin-ssr) had vergelijkbare problemen. Het is flexibeler dan NextJS, maar het zou nog steeds een flinke herstructurering van onze codebase betekenen. De leercurve en migratie-inspanning wogen niet op tegen de voordelen.
Astro: de verkeerde architectuur
Astro is geweldig voor content-zware sites, maar Foony is een complex multiplayer gameplatform. We hebben realtime updates, WebSocket-verbindingen en dynamische React-componenten nodig. De architectuur van Astro past simpelweg niet bij wat wij bouwen.
De oplossing: maatwerk-SSG
Gesterkt door mijn "fake SSG"-aanpak die ik een paar dagen geleden na i18n heb gebouwd, ben ik uitgekomen op een kleine, lichte, maatwerkoplossing voor de SSG van Foony.
Mijn "fake SSG"-aanpak haalde de blogcontent van pagina's met blogposts (de
/posts-routes en gamepagina's), en plaatste die precies op de plek waar de client hem zou renderen, speciaal voor zoekmachines en LLMs zodat ze Foony beter kunnen begrijpen. Het voegde ook een ld+json-schema toe en nog wat kleine SEO-dingen.
De aanpak is simpel:
- Bouwen bovenop de bestaande React SPA: Geen migratie nodig, alleen SSG-generatie toevoegen tijdens de build.
renderToReadableStreamgebruiken: De streaming-SSR-API van React 18 gaat vanzelf goed om met Suspense.- Statische HTML-bestanden genereren: Routes vooraf renderen tijdens de build en als statische bestanden serveren, met onze SitemapGenerator om een lijst van routes op te halen.
- Minimale wijzigingen in de bestaande codebase: De meeste componenten werken gewoon zoals ze zijn.
De kernimplementatie staat in client/src/generators/GenerateShellSsgFromSitemap.ts. Die leest een sitemap, rendert elke route met behulp van React's renderToReadableStream en schrijft de HTML naar statische bestanden. Simpel, precies zoals ik het graag heb!
Dit bleek ook best snel te zijn. Ongeveer 2.800 routes gerenderd in 10 seconden. Nice. Dat is flink sneller dan NextJS, Gatsby en Astro. <img alt="SSG-console log die de benodigde tijd laat zien" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Ik zou eindeloos door kunnen gaan over eenvoud. Zelfs als je er bij grote bedrijven geen promotie mee krijgt vanwege een “gebrek aan complexiteit”, simpele code is mooi, onderhoudbaar, en over het algemeen veel beter voor ontwikkelsnelheid. Dat is iets wat ik echt bewonder aan Zen-principes.
Het Suspense-boundary-probleem
Dus nu had ik SSG, en verscheen de content netjes in de HTML... maar mijn pagina's waren leeg! Hoe dan?! <img alt="SSG-lege pagina" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Het bleek dat renderToReadableStream nog steeds Suspense-boundaries heeft, zelfs als je await stream.allReady doet. Mijn gok is dat dit komt doordat het een "stream" is, bedoeld om naar clients gestuurd te worden terwijl de bytes binnenkomen.
Wat React uitstuurt
Als je renderToReadableStream met Suspense gebruikt, geeft React HTML zoals dit terug:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
De <template id="B:0"> is een placeholder waar de content zou moeten komen. De <div hidden id="S:0"> bevat de echte gerenderde content. B:0 hoort bij S:0 via het nummer (0-based index).
Zonder JavaScript zouden zoekmachines (kijk naar jou, Bing) en LLMs bijna een lege pagina zien met alleen de template-placeholder. Dat haalt het hele doel van SSG onderuit!
Ik zag geen nette manier om die Suspense-boundaries te verwijderen, dus mijn oplossing was om een paar tests te schrijven en een functie resolveSuspenseBoundaries te maken die ze omwisselt. Dit was sneller dan de HTML parsen en het script uitvoeren met iets als JSDOM. En, nog belangrijker, het was nodig voor wat ik voor ogen had: een nette, leesbare site voor zoekmachines en LLMs zonder JavaScript, maar mét support voor Suspense-boundaries en hydration aan de clientkant.
De transformatie testen
Ik begon met tests voor de transformatie door voorbeelden uit de DOM te pakken van wat ik had (JavaScript uitgeschakeld) en wat ik wilde (JavaScript ingeschakeld). Die heb ik in een LLM gegooid en die de testgeneratie laten doen, iets waar het best goed in is.
Deze tests staan in client/src/generators/ssr/renderRoute.test.ts en checken dat de transformatie goed werkt. De tests dekken:
- Simpele boundary-replacement (blogoverzicht)
- Complexe boundaries met content tussen de template en de afsluitende comment
- Meerdere boundaries
- Boundaries zonder comment-markers
- Randgevallen
Dit soort "TDD" is eigenlijk best handig voor dit soort gevallen waarin je verwachte input en output hebt.
Dit moet je niet verwarren met "TDD alles, omdat Robert C. Martin dat zei" (wat de ontwikkelsnelheid van je team zal vertragen). Je zou TDD NIET moeten gebruiken voor UI of delen van je code die constant veranderen!
De oplossing: resolveSuspenseBoundaries
Nu de tests er waren, liet ik de LLM de functie voor resolveSuspenseBoundaries schrijven. Ik heb hiervoor cheerio gekozen om de kwetsbaarheid van RegEx te vermijden, ook al zou RegEx hier de SSG-tijd met zo'n 40% verminderen.
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};
}
Dit zorgt ervoor dat zoekmachines en LLMs geen bijna-lege pagina zien, maar een volledig gerenderde pagina.
Nu werkt SSG lekker zonder JavaScript!
<img alt="SSG zonder JavaScript voor de blogs van 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" }} />
Op de lange termijn is het mogelijk dat React hun Suspense-formaat verandert. Misschien haal ik de Suspense-oplossingscode er weer uit zodra ik een betere oplossing heb voor pagina's die lazy geladen worden (en dus Suspense-boundaries nodig hebben).
Hydration-strategie (update: dit kostte 3 dagen + 1 extra dag)
Hydration is lastig. Dat wist ik. Maar na wat werk kreeg ik het aan de praat!
Totale tijd die hydration kostte: 3 dagen, plus 1 extra dag om de dehydration-aanpak te vervangen.
Het lastigste was die allereerste minimale, werkende hydrate. Toen ik eenmaal een "Hello World" met de navbar kon renderen, kreeg ik het vertrouwen dat dit misschien geen hele maand zou kosten!
<img alt="Foony's Hello World die succesvol hydrate met 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" }} />
Voor die eerste minimale, werkende hydrate had ik een unieke uitdaging: ik wilde hydration, maar ik wilde ook goede SEO voor zoekmachines en LLMs, zonder dat developers na hoeven te denken over Suspense-boundaries.
De uitdaging
React-hydration is extreem letterlijk: als de DOM er niet precies uitziet zoals React verwacht bij de eerste render, krijg je een mooie, bijna nutteloze foutmelding in je console, en gooit React alles weg om vervolgens opnieuw vanaf nul te renderen. Je krijgt niet eens een diff om te laten zien wat er misging!
In ons geval maakte SSG dit op een paar manieren nog erger:
- We post-processen de HTML om de React 18 streaming-Suspense-artefacten te verwijderen/op te lossen (wat geweldig is voor bots).
- De client had niet altijd exact dezelfde data beschikbaar op tijd (t = 0) als de server tijdens de render had (SSG-data, blog-metadata, enzovoort).
- Onze i18n is standaard “lazy”, wat betekent dat vertalingen bij de eerste render kunnen ontbreken, tenzij je bijhoudt welke vertalingen tijdens SSG zijn gebruikt en die injecteert voordat React rendert.
Wat werkte (eerste aanpak: dehydration)
In het begin probeerde ik iets slims en “schattigs”: ik gebruikte een command-pattern om de commando's op te nemen die werden gebruikt om de Suspense-boundaries in de HTML op te lossen, en gaf de omgekeerde transformatiecommando's terug zodat ik de HTML kon herstellen naar wat React nodig heeft voor hydration.
Ik hoopte op deze manier veel minder bytes in index.html te hoeven versturen. Maar, zoals met de meeste slimme trucjes, mislukte dit omdat browsers de HTML op subtiele manieren aanpassen, zoals het verwijderen of toevoegen van een ; of /, waardoor de vervangingsindices niet meer klopten.
Technisch gezien kun je waarschijnlijk rekening houden met dit soort subtiele browserwijzigingen, maar ik wilde niet zoiets fragiels shippen.
In plaats van te proberen de Suspense-boundary-transformatie “terug te draaien” naar de streaming-markup van React, deed ik iets super simpels:
Bundel de originele, onopgeloste HTML in een <script type="text">.
Deze "dehydration"-aanpak werkte, maar ik heb nog een extra dag besteed om hem te vervangen door een betere oplossing.
De betere aanpak: vervanging van Suspense-boundaries op het kritieke pad
Na de eerste implementatie liep ik nog steeds tegen wat issues aan met Suspense-boundaries. Toen realiseerde ik me dat er een schonere, betere, simpelere oplossing was. Ik verving de dehydration-aanpak door vervanging van Suspense-boundaries op het kritieke pad, wat:
- Het kritieke pad laadt vóór hydration: Componenten die tijdens SSR zijn voorgeladen worden gedetecteerd en aan de clientkant voorgeladen voordat
hydrateRoot wordt aangeroepen
- Makkelijker te onderhouden is: Geen React-internals of AST-parsing nodig (de dehydration-aanpak moest HTML parsen en herstellen)
- Minder bytes verstuurt: We bundelen de originele SSR-respons van React niet langer in een script-tag
- Een mogelijke flash voorkomt: We hoeven de HTML niet te de- en rehydraten, wat een mogelijke visuele flikkering scheelt
De implementatie houdt bij welke lazy componenten tijdens SSR werden voorgeladen (via SSRLazyComponentTracker), neemt hun import-paden op in de hydration-data en laadt ze synchroon in voordat de hydration start. Componenten op het kritieke pad renderen direct zonder Suspense-boundaries, waardoor de output exact overeenkomt met de SSR-output.
Voor de rest laten we de eerste client-render zich gedragen als SSR/SSG. Dat betekent dezelfde input gebruiken en die input synchroon beschikbaar maken vóór hydrateRoot. Dat doen we door alles te bundelen via onze "ssg-data".
Concreet waren de aanpassingen:
SSR-inputs bundelen in één tekstscript
- Tijdens SSG injecteren we een
<script type="text/foony-ssg" id="foony-ssg-data">...</script> direct vóór het Vite-module-entrypoint.
- Dat script bevat:
html: de opgeloste HTML die we daadwerkelijk in het statische bestand shippen
ssgData: de geserialiseerde SSGData die door de SSR-wrapper gebruikt wordt. Ik ben van plan dit te veranderen naar een Proxy of iets dergelijks zodat alleen gebruikte data wordt meegenomen.
translationData: de key-value vertaalblobs die we tijdens SSR hebben aangeraakt
Die inputs injecteren vlak voor hydration
- In
main.tsx doen we synchroon:
#root.innerHTML instellen op de geserialiseerde, opgeloste HTML (zodat de DOM er exact uitziet zoals hydration hem verwacht)
- de app wrappen in
SSGDataProvider zodat componenten dezelfde SSGData hebben bij de eerste render
i18n instant maken door vertaalwaardes te injecteren
- We loggen de daadwerkelijke vertaalobjecten die tijdens SSR zijn gebruikt en shippen die in het SSG-script.
- Aan de clientkant injecteren we die rechtstreeks in de cache van
LocaleQueryer via een speciale LocaleQueryer.inject()-methode, zodat vertalingen meteen beschikbaar zijn.
En daarmee heeft de eerste render dezelfde data als SSR!
De hook useIsSSRMode() is al geïmplementeerd in client/src/generators/ssr/isSSRMode.ts:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Deze hook geeft true terug tijdens SSR en bij de eerste client-render (hydration), en schakelt daarna naar false na mount. Componenten zoals UserBanner, Navbar en Dialog gebruiken dit al om hydration-mismatches te voorkomen.
- React patchen voor betere diffs
Ik hoopte dat ik gewoon hydration-overlay kon gebruiken. Maar dat project wordt niet actief onderhouden, ondersteunt alleen tot React 18 en was niet production-ready. Dus liet ik een LLM de repo clonen als inspiratie, en vervolgens een minimale hydration-overlay bouwen in een paar minuten. Ik had niets fancy nodig, alleen iets dat tijdens development in beeld komt zodat ik kan zien waar het misgaat.
Deze nieuwe overlay is super basic, dus de diffs zijn niet helemaal perfect. React stript comments, voegt ; toe na style-attributen, past whitespace aan en doet nog een paar andere kleine dingen waar onze overlay (nog) geen rekening mee houdt. Onze overlay bevat ook HTML-comments die React negeert voor zijn eigen hydration.
<img alt="Onze nieuwe 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" }} />
Maar het is goed genoeg om uit te vinden wat er gefixt moet worden.
<img alt="diff van onze SSG versus de eerste client-render voor 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" }} />
In cijfers
Om je een idee te geven wat deze implementatie inhield:
- 2 dagen werk (van start tot werkende SSG). Dit was net iets meer dan 24 uur terwijl ik op vakantie was.
- 4 dagen werk om hydration netjes te laten werken zonder async-vertalingsraces of
useMediaQuery die roet in het eten gooit.
- 1 extra dag om de dehydration-aanpak te vervangen door vervanging van Suspense-boundaries op het kritieke pad (simpler, minder bytes, geen potentiële flash).
- ~200 regels kerncode voor SSG-generatie (
GenerateShellSsgFromSitemap.ts)
- ~120 regels Suspense-boundary-oplossing (
resolveSuspenseBoundaries in renderRoute.tsx) - Opmerking: dit is later vervangen door de critical path-aanpak
- ~50 regels SSR-hulpfuncties (
isSSRMode.ts)
- ~100 regels tests (
renderRoute.test.ts)
- ~150 regels polyfills voor SSR (
setupSSREnvironment)
- Minimale wijzigingen aan bestaande componenten (voornamelijk het toevoegen van
useIsSSRMode()-checks)
De oplossing is lichtgewicht en onderhoudbaar. Er is geen frameworkmigratie nodig en hij werkt met onze bestaande React SPA.
Belangrijkste inzichten
Soms is een maatwerkoplossing beter
Niet elk probleem heeft een framework nodig. Voor Foony was een kleine, maatwerk-SSG-oplossing de juiste keuze. Die is:
- Lichtgewicht: Geen zware dependencies of framework-overhead
- Onderhoudbaar: Simpele code die we zelf begrijpen
- Flexibel: Makkelijk aan te passen en uit te breiden waar nodig
- Compatibel: Werkt met onze bestaande React SPA zonder migratie
Reacts streaming-SSR heeft eigenaardigheden
React's renderToReadableStream is fijn om met Suspense om te gaan, maar heeft zijn eigenaardigheden. Zelfs met await stream.allReady krijg je nog steeds Suspense-boundaries in de output. Dit is geen bug, het is expres zo ontworpen voor streaming. Maar voor SSG hebben we volledig opgeloste HTML nodig. Het voelt als een misser van het React-team dat ze dit scenario niet op een nette manier ondersteunen.
Mijn oplossing was om de HTML achteraf te verwerken en de boundaries op te lossen. Het is niet geweldig mooi, maar het is snel en flexibel genoeg voor mijn use case.
TDD kan handig zijn voor LLMs
HTML-transformatie is foutgevoelig. Eén klein bugje en je kunt de hele SSG-output breken en daarmee de ervaring van de eindgebruiker. Ik heb een LLM uitgebreide tests laten schrijven (met mijn input) om ervoor te zorgen dat de transformatie correct werkt.
Conclusie
SSG werkt nu voor Foony. Pagina's zijn volledig gerenderd voor zoekmachines en LLMs, en de oplossing is onderhoudbaar en lichtgewicht. Hydration voor de SSG-routes kostte langer dan ik dacht (3 dagen), en ik heb nog een extra dag besteed om de eerste dehydration-aanpak te vervangen door vervanging van Suspense-boundaries op het kritieke pad. De nieuwe aanpak is eenvoudiger te onderhouden, verstuurt minder bytes en voorkomt mogelijke visuele flashes door de- en rehydratie van HTML.
Ik ben nog steeds verbaasd dat het maar 2 dagen kostte om een maatwerkoplossing voor SSG te bouwen. Maar soms is de juiste oplossing gewoon de simpelste.
Toekomstig werk omvat het verder afronden van de hydration-matching en mogelijk React patchen voor betere debugging. Maar voor nu heeft Foony werkende SSG. De komende weken houd ik Google Search Console en Bing Webmaster Tools goed in de gaten om te zien wat dit met onze SEO doet.