

1/1/1970
Sådan implementerede jeg SSG på 2 dage
Hejsa! For et år siden troede jeg, det var umuligt. Men jeg er lige blevet færdig med at implementere Static Site Generation (SSG) for Foony på 2 dage, og det er jeg ret begejstret for. Det er heller ikke første gang, jeg prøver at løse SSG for Foony. Jeg har tidligere kigget på NextJS, Vike, Astro, Gatsby og et par andre løsninger. Jeg havde endda en falsk start med NextJS, men løb ind i problemer med kompleksiteten i Foony's SPA og tusindvis af filer. Migrationen ville have været et mareridt og taget måneder. Det ville også have tilføjet ekstra kompleksitet for alle andre, der arbejder på sitet, fordi de skulle til at lære NextJS og alle dets særheder.
Jeg ville have noget let og nemt at implementere. Noget der lod os fortsætte med at skrive kode på præcis samme måde, som vi allerede gør, uden at skulle tænke over SSG (med undtagelse af useMediaQuery--der er der ikke rigtig nogen vej udenom). Nedenfor gennemgår jeg, hvorfor jeg gik med en skræddersyet løsning, de konkrete udfordringer jeg løb ind i (især med Reacts Suspense-boundaries), og hvordan jeg løste dem.
Hvorfor ikke standardløsninger?
Da jeg først kiggede på at tilføje SSG til Foony, var det naturligt at overveje NextJS (branchestandard), Vike og Astro.
NextJS: For meget migration
NextJS er kraftfuldt, men det ville have krævet en enorm migration af Foony's eksisterende React-SPA. Vi har tusindvis af filer, kompleks routing-logik og en masse specialbygget infrastruktur. At migrere til NextJS ville have betydet:
- At vi skulle skrive hele vores routing-system om
- At vi skulle ændre måden, vi loader spil og komponenter på
- Måneder af arbejde bare for at komme tilbage til samme funktionsniveau
- Potentielle breaking changes for brugerne
- At vi skulle ændre måden, vi håndterer billeder på
- Markant langsommere build-tider (potentielt 5-30 minutter. Jeg har ikke konkrete tal at bakke det op med, udover denne 5 år gamle diskussion på GitHub)
- At hele teamet skulle lære noget nyt (NextJS) og dermed langsommere udviklingshastighed for altid
- At vi skulle migrere koden hver gang NextJS beslutter sig for at introducere breaking changes.
Jeg prøvede endda en falsk start med NextJS, men indså hurtigt, at migrationsomkostningen var alt for høj. Kompleksiteten var det ikke værd.
Vike: Lignende kompleksitet
Vike (tidligere vite-plugin-ssr) havde lignende problemer. Selvom det er mere fleksibelt end NextJS, ville det stadig have krævet en betydelig omstrukturering af vores kodebase. Læringskurven og migrationsarbejdet retfærdiggjorde simpelthen ikke gevinsten.
Astro: Forkert arkitektur
Astro er fedt til indholdstunge sider, men Foony er en kompleks multiplayer-spilplatform. Vi har brug for realtidsopdateringer, WebSocket-forbindelser og dynamiske React-komponenter. Astros arkitektur passer bare ikke til det, vi bygger.
Løsningen: Skræddersyet SSG
Opildnet af min "fake SSG"-approach, jeg implementerede for nogle dage siden efter i18n, landede jeg på en lille, let, skræddersyet løsning til Foony's SSG.
Min "fake SSG"-approach gik ud på at hive blogindholdet ud fra sider med blogindlæg (
/posts-routes og spilsider) og placere dem præcis der, hvor klienten ville rende dem, specifikt for at søgemaskiner og LLMs bedre kunne forstå Foony. Den tilføjede også ld+json-schema og lidt småting til SEO.
Approachen er simpel:
- Byg oven på den eksisterende React-SPA: Ingen migration, bare tilføj SSG-generering ved build-tid.
- Brug
renderToReadableStream: React 18's streaming-SSR-API håndterer Suspense naturligt. - Generér statiske HTML-filer: Pre-render routes ved build-tid og servér dem som statiske filer, ved at bruge vores SitemapGenerator til at få en liste af routes.
- Minimale ændringer i den eksisterende kodebase: De fleste komponenter virker som de er.
Kerneimplementeringen ligger i client/src/generators/GenerateShellSsgFromSitemap.ts. Den læser et sitemap, renderer hver route med Reacts renderToReadableStream og skriver HTML'en til statiske filer. Simpelt, lige som jeg kan lide det!
Det endte også med at være ret hurtigt. Cirka 2.800 routes blev renderet på 10 sekunder. Lækkert. Det er markant hurtigere end NextJS, Gatsby og Astro. <img alt="SSG-konsol-log der viser den brugte tid" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Jeg kunne snakke længe om simplicitet. Selv hvis det ikke giver dig en forfremmelse i store virksomheder på grund af "mangel på kompleksitet", er simpel kode smuk, vedligeholdelsesvenlig og generelt meget bedre for udviklingshastighed. Det er noget, jeg virkelig beundrer ved Zen-principperne.
Problemet med Suspense-boundaries
Så nu havde jeg SSG, og indholdet dukkede op i HTML'en... men mine sider var tomme! Hvad?! <img alt="Tom SSG-side" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Det viste sig, at renderToReadableStream stadig har Suspense-boundaries, selv hvis du await stream.allReady. Mit gæt er, at det er fordi det er en "stream" og designet til at blive sendt direkte til klienter i takt med, at bytes kommer ind.
Hvad React spytter ud
Når du bruger renderToReadableStream med Suspense, spytter React HTML ud som det her:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
<template id="B:0"> er en placeholder, hvor indholdet burde ligge. <div hidden id="S:0"> indeholder det egentlige renderede indhold. B:0 matcher S:0 via nummeret (0-baseret index).
Uden JavaScript vil søgemaskiner (ja, Bing, jeg kigger på dig) og LLMs se en næsten tom side med kun template-placeholderen. Det ødelægger jo hele pointen med SSG!
Jeg kunne ikke se nogen pæn måde at fjerne de her Suspense-boundaries på, så min løsning var at skrive nogle tests og en resolveSuspenseBoundaries-funktion til at bytte dem ud. Det var hurtigere end at parse HTML'en og eksekvere scriptet med noget som JSDOM. Og endnu vigtigere var det et krav i forhold til det, jeg havde planlagt: et pænt, læsbart site for søgemaskiner / LLMs uden JavaScript, men stadig med support for Suspense-boundaries og hydration i klienten.
Test af transformationen
Jeg startede med at skrive tests til transformationen ved at tage nogle eksempler i DOM'en fra det, jeg havde (JavaScript slået fra), og det, jeg ville have (JavaScript slået til). Jeg fodrede dem til en LLM og lod den håndtere testgenereringen, noget den faktisk er ret god til.
De her tests ligger i client/src/generators/ssr/renderRoute.test.ts og sikrer, at transformationen virker korrekt. Testene dækker:
- Simpel boundary-udskiftning (blog-liste)
- Komplekse boundaries med indhold mellem template og afsluttende kommentar
- Flere boundaries
- Boundaries uden kommentar-markører
- Edge cases
Den her form for "TDD" er faktisk ret nyttig i netop den her situation, hvor du har forventede input og output.
Det skal ikke forveksles med "TDD alt, fordi Robert C. Martin sagde det" (som vil sænke dit teams udviklingshastighed). Du skal IKKE bruge TDD til UI eller områder af koden, der konstant ændrer sig!
Løsningen: resolveSuspenseBoundaries
Nu hvor testene var på plads, bad jeg LLM'en om at skrive funktionen til resolveSuspenseBoundaries. Jeg valgte cheerio til det her for at undgå skrøbeligheden ved RegEx, selvom RegEx i det her tilfælde ville skære omkring 40% af SSG-tiden.
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};
}
Det sikrer, at i stedet for at se en næsten tom side, ser søgemaskiner og LLMs en fuldt renderet side.
Nu har vi SSG, der fungerer godt uden JavaScript!
<img alt="Ingen JavaScript SSG for Foony's blogs" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
På længere sigt er det muligt, at React ændrer deres Suspense-format. Jeg ender måske med at fjerne Suspense-resolution-koden, når jeg har en bedre løsning til siderne, der lazy-loades (og derfor kræver Suspense-boundaries).
Hydration-strategi (Opdatering: Det tog 3 dage + 1 ekstra dag)
Hydration er svært. Det vidste jeg godt. Men efter lidt arbejde lykkedes det faktisk at få det til at virke!
Samlet tid brugt på hydration: 3 dage, plus 1 ekstra dag til at erstatte dehydration-approachen.
Det sværeste var egentlig bare at få den allerførste, minimale, fungerende hydrate. Da jeg først fik renderet et "Hello World" med navbaren, fik jeg selvtillid til, at det her nok ikke ville tage en hel måned alligevel!
<img alt="Foony's Hello World hydrerer succesfuldt med 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" }} />
Til den første minimale, fungerende hydrate havde jeg en særlig udfordring: Jeg ville have hydration, men jeg ville også have god SEO for søgemaskiner og LLMs uden at udviklere behøvede at tænke over Suspense-boundaries.
Udfordringen
React-hydration er ekstremt bogstavelig: Hvis DOM'en ikke ligner det, React forventer ved første render, får du en lækker, næsten ubrugelig fejlbesked i konsollen, og React smider alt ud og re-render fra scratch. Ikke engang en diff, der kan fortælle dig, hvad der gik galt!
I vores tilfælde gjorde SSG det her værre på et par måder:
- Vi post-processede HTML'en for at fjerne/løse React 18 streaming-Suspense-artifakter (hvilket er fantastisk for bots).
- Klienten havde ikke altid helt de samme data tilgængelige ved tid (t = 0), som server-renderen havde (SSG-data, blog-metadata osv.).
- Vores i18n er "lazy" som standard, hvilket betyder, at oversættelser kan mangle ved første render, medmindre du registrerer, hvilke oversættelser der blev brugt til SSG, og injicerer dem, før React renderer.
Hvad der virkede (første approach: Dehydration)
I starten prøvede jeg noget smart og lidt for fikst: Jeg brugte et command pattern til at optage de kommandoer, der blev brugt til at løse HTML'ens Suspense-boundaries, og returnerede de omvendte transformations-kommandoer, så jeg kunne gendanne HTML'en til det, React skal bruge til hydration.
Jeg håbede på, at jeg kunne sende langt færre bytes i index.html med den her command-metode. Men som med de fleste smarte løsninger fejlede det, fordi browsere ændrer HTML'en på små, subtile måder, som at fjerne eller tilføje et ; eller /, hvilket ødelagde alle replacement-indekserne.
Teknisk set kunne man sikkert tage højde for de små browser-ændringer, men jeg havde ikke lyst til at sende noget så skrøbeligt i produktion.
I stedet for at prøve at "vende" Suspense-boundary-transformationen tilbage til Reacts streaming-markup, gjorde jeg noget super simpelt:
Bundle den originale, uopløste HTML i et <script type="text">.
Den her "dehydration"-approach virkede, men jeg brugte en ekstra dag på at erstatte den med en bedre løsning.
Den bedre løsning: Critical path-Suspense-boundary-udskiftning
Efter den første implementering løb jeg stadig ind i nogle problemer med Suspense-boundaries. Det var der, jeg indså, at der fandtes en renere, bedre og simplere løsning. Jeg erstattede dehydration-approachen med critical path-Suspense-boundary-udskiftning, som:
- Loader critical path før hydration: Komponenter, der blev preloaded under SSR, bliver identificeret og preloaded på klienten, før
hydrateRoot bliver kaldt
- Er nemmere at vedligeholde: Ingen React-internals eller AST-parsing nødvendig (dehydration-approachen krævede at parse og gendanne HTML)
- Sender færre bytes: Vi bundler ikke længere den originale SSR-respons fra React i et script-tag
- Forhindrer et potentielt flash: Der er ikke behov for at dehydrate/rehydrate HTML, så vi undgår et muligt visuelt flash
Implementeringen tracker, hvilke lazy-komponenter der blev preloaded under SSR (via SSRLazyComponentTracker), inkluderer deres import-stier i hydration-dataene og preloader dem synkront før hydration. Critical path-komponenter renderer direkte uden Suspense-boundaries, så outputtet matcher SSR præcis.
For alt andet lader vi den første client-render opføre sig som SSR/SSG. Det betyder at bruge de samme input og gøre de input tilgængelige synkront før hydrateRoot. Det gør vi ved at bundle dem via vores "ssg-data".
Helt konkret var justeringerne:
Bundle SSR-input i et enkelt tekst-script
- Under SSG injicerer vi et
<script type="text/foony-ssg" id="foony-ssg-data">...</script> lige før Vite-module-entrypointet.
- Det script indeholder:
html: den resolvede HTML, vi rent faktisk sender i den statiske fil
ssgData: den serialiserede SSGData, som SSR-wrapperen bruger. Jeg har planer om at opdatere det til en Proxy eller lignende, så kun data der faktisk bruges, kommer med.
translationData: de oversættelses-key/value-blobs, vi rørte ved under SSR
Injicer de input lige før hydration
- I
main.tsx sætter vi synkront:
#root.innerHTML til den serialiserede resolvede HTML (så DOM'en er præcis det, hydration ser)
- wrapper appen i
SSGDataProvider, så komponenter har den samme SSGData ved første render
Gør i18n øjeblikkelig ved at injicere oversættelsesværdier
- Vi registrerer de konkrete oversættelses-objekter, der blev brugt under SSR, og sender dem med i SSG-scriptet.
- På klienten injicerer vi dem direkte i
LocaleQueryer's cache via en dedikeret LocaleQueryer.inject()-metode, så oversættelser er tilgængelige med det samme.
Og med det har den første render de samme data, som SSR havde!
Hooken useIsSSRMode() er allerede implementeret i 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;
}
Den her hook returnerer true under SSR og ved første client-render (hydration), og skifter så til false efter mount. Komponenter som UserBanner, Navbar og Dialog bruger allerede det her til at undgå hydration-mismatches.
- Patch React for bedre diffs
Jeg håbede, at jeg bare kunne bruge hydration-overlay. Men den bliver ikke aktivt vedligeholdt, er kun supporteret op til React 18 og var ikke rigtig produktionsklar. Så jeg lod en LLM clone repoen som inspiration, og derefter lavede den et minimalistisk hydration-overlay på få minutter. Jeg havde ikke brug for noget fancy, bare noget der dukkede op under udvikling, så jeg kunne se, hvor tingene gik galt.
Det nye overlay er super simpelt, så diffs er ikke helt perfekte. React fjerner kommentarer, tilføjer ; efter style-attributter, ændrer whitespace og et par andre småting, som vores overlay (endnu) ikke tager højde for. Overlayet inkluderer også HTML-kommentarer, som React ignorerer i sin hydration.
<img alt="Vores nye 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" }} />
Men det er godt nok til at finde ud af, hvad der skal fikses.
<img alt="diff mellem vores SSG og klientens første render til 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" }} />
Tal på det
For at give en idé om, hvad implementeringen indebar:
- 2 dage arbejde (fra start til fungerende SSG). Det var lidt over 24 timer mens jeg var på ferie.
- 4 dage arbejde for at få hydration til at opføre sig pænt uden async-oversættelses-races eller
useMediaQuery, der ødelagde det hele.
- 1 ekstra dag til at erstatte dehydration-approachen med critical path-Suspense-boundary-udskiftning (simplere, færre bytes, intet potentielt flash).
- ~200 linjer kerne-SSG-genereringskode (
GenerateShellSsgFromSitemap.ts)
- ~120 linjer Suspense-boundary-resolution (
resolveSuspenseBoundaries i renderRoute.tsx) - Bemærk: Det blev senere erstattet af critical path-approachen
- ~50 linjer SSR-utilities (
isSSRMode.ts)
- ~100 linjer tests (
renderRoute.test.ts)
- ~150 linjer polyfills til SSR (
setupSSREnvironment)
- Minimale ændringer i eksisterende komponenter (mest tilføjelse af
useIsSSRMode()-checks)
Løsningen er letvægt og til at vedligeholde. Den kræver ingen framework-migration og fungerer sammen med vores eksisterende React-SPA.
Det vigtigste at tage med
Nogle gange er en skræddersyet løsning bedre
Ikke hvert problem kræver et framework. For Foony var en lille, skræddersyet SSG-løsning det rigtige valg. Den er:
- Letvægt: Ingen tunge afhængigheder eller framework-overhead
- Vedligeholdelsesvenlig: Simpel kode, vi selv forstår
- Fleksibel: Nem at ændre og udvide efter behov
- Kompatibel: Virker med vores eksisterende React-SPA uden migration
React's streaming-SSR har sine finurligheder
React's renderToReadableStream er lækker til at håndtere Suspense, men den har sine quirks. Selv med await stream.allReady får du stadig Suspense-boundaries i outputtet. Det er ikke en bug, det er designet til streaming. Men til SSG har vi brug for fuldt resolved HTML. Det føles som en svipser fra React-teamet, at de ikke håndterer det her scenarie på en mere enkel måde.
Min løsning var at post-processe HTML'en og resolve boundaries. Det er ikke kønt, men det er hurtigt og fleksibelt nok til det, jeg skulle bruge det til.
TDD kan faktisk være nyttigt sammen med LLM'er
HTML-transformation er skrøbelig. En lille fejl, og du kan ødelægge hele SSG-outputtet og dermed brugeroplevelsen. Jeg lod en LLM skrive omfattende tests (med min input) for at sikre, at transformationen virkede korrekt.
Konklusion
SSG virker nu for Foony. Siderne er fuldt renderet for søgemaskiner og LLMs, og løsningen er til at vedligeholde og letvægt. Hydration for SSG-routes tog længere tid end forventet (3 dage), og jeg brugte en ekstra dag på at erstatte den første dehydration-approach med critical path-Suspense-boundary-udskiftning. Den nye approach er nemmere at vedligeholde, sender færre bytes og forhindrer potentielle visuelle flashes fra at dehydrate/rehydrate HTML.
Jeg er stadig lidt chokeret over, at det kun tog 2 dage at implementere en skræddersyet løsning til SSG. Men nogle gange er den rigtige løsning bare den simpleste.
Fremtidigt arbejde inkluderer at få hydration-matching helt på plads og måske patche React for bedre debugging. Men lige nu har Foony fungerende SSG. Jeg holder øje med Google Search Console og Bing Webmaster Tools de næste par uger for at se, hvilken effekt det har på vores SEO.