background blurbackground mobile blur

1/1/1970

Hoe ik in 2 dagen SSG heb geïmplementeerd

Hoi! Een jaar geleden dacht ik dat dit onmogelijk was. Maar ik heb zojuist in 2 dagen Static Site Generation (SSG) voor Foony geïmplementeerd, en ik ben er behoorlijk enthousiast over. Dit is trouwens niet de eerste keer dat ik probeer SSG voor Foony op te lossen. Ik heb in het verleden gekeken naar NextJS, Vike, Astro, Gatsby en een paar andere oplossingen. Ik had zelfs een valse start met NextJS, maar liep tegen problemen aan met de complexiteit van Foony's SPA en de 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 NextJS en zijn eigenaardigheden zouden moeten leren.

Ik wilde iets lichts en eenvoudigs te implementeren. Iets waarmee we code op dezelfde manier konden blijven schrijven als we dat al deden, zonder over SSG na te hoeven denken (met uitzondering van useMediaQuery, daar valt echt niet omheen). Hieronder leg ik uit waarom ik voor een maatwerkoplossing ging, tegen welke specifieke uitdagingen ik aanliep (vooral met React's Suspense-grenzen) en hoe ik ze heb opgelost.

Waarom geen standaardoplossingen?

Toen ik voor het eerst keek naar het toevoegen van SSG aan Foony, overwoog ik natuurlijk NextJS (industriestandaard), Vike en Astro.

NextJS: te veel migratie

NextJS is krachtig, maar het zou een enorme migratie van Foony's bestaande React SPA hebben vereist. We hebben duizenden bestanden, complexe routinglogica en veel maatwerk-infrastructuur. Migreren naar NextJS zou betekenen:

  • Ons hele routingsysteem herschrijven
  • De manier waarop we games en componenten laden herstructureren
  • Maanden werk om gewoon weer op hetzelfde featureniveau te komen
  • Mogelijke breaking changes voor gebruikers
  • De manier waarop we afbeeldingen behandelen veranderen
  • Aanzienlijk tragere buildtijden (mogelijk 5-30 minuten. Ik heb geen concrete cijfers om dit te onderbouwen behalve deze 5 jaar oude discussie op GitHub)
  • Het hele team iets nieuws leren (NextJS), en voor altijd langzamere ontwikkelsnelheid
  • De code migreren elke keer dat NextJS beslist om breaking changes door te voeren.

Ik heb zelfs een valse start gemaakt met NextJS, maar realiseerde me snel dat de migratiekosten te hoog waren. De complexiteit was het niet waard.

Vike: vergelijkbare complexiteit

Vike (voorheen vite-plugin-ssr) had vergelijkbare problemen. Hoewel het flexibeler is dan NextJS, zou het toch een aanzienlijke herstructurering van onze codebase hebben vereist. De leercurve en migratie-inspanning rechtvaardigden de voordelen niet.

Astro: verkeerde architectuur

Astro is geweldig voor content-zware sites, maar Foony is een complex multiplayer-gameplatform. We hebben real-time updates, WebSocket-verbindingen en dynamische React-componenten nodig. De architectuur van Astro past gewoon niet bij wat wij bouwen.

De oplossing: maatwerk-SSG

Aangemoedigd door mijn "fake SSG"-aanpak die ik een paar dagen geleden implementeerde na i18n, koos ik voor een kleine, lichtgewicht, maatwerkoplossing voor Foony's SSG.

Mijn "fake SSG"-aanpak hield in dat ik de blogpost-content uit pagina's met blogposts haalde (/posts-routes en gamepagina's), en ze precies daar plaatste waar de client ze zou renderen, specifiek voor zoekmachines en LLM's om Foony beter te begrijpen. Het paste ook ld+json-schema en wat kleine SEO-zaken toe.

De aanpak is eenvoudig:

  1. Voortbouwen op de bestaande React SPA: geen migratie nodig, gewoon SSG-generatie toevoegen tijdens build.
  2. renderToReadableStream gebruiken: React 18's streaming SSR-API behandelt Suspense van nature.
  3. Statische HTML-bestanden genereren: routes prerenderen tijdens build en serveren als statische bestanden, met onze SitemapGenerator om een lijst van routes te krijgen.
  4. Minimale wijzigingen aan de bestaande codebase: de meeste componenten werken zoals ze zijn.

De kernimplementatie staat in client/src/generators/GenerateShellSsgFromSitemap.ts. Het leest een sitemap, rendert elke route met React's renderToReadableStream, en schrijft de HTML naar statische bestanden. Eenvoudig, precies zoals ik het graag heb!

Dit bleek ook behoorlijk snel te zijn. Ongeveer 2.800 routes gerenderd in 10 seconden. Lekker. Dat is aanzienlijk sneller dan NextJS, Gatsby en Astro. <img alt="SSG-consolelog met de tijd die het kostte" 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 kan eindeloos doorgaan over eenvoud. Zelfs als het je geen promotie oplevert bij grote bedrijven vanwege "gebrek aan complexiteit", is eenvoudige code mooi, onderhoudbaar en over het algemeen veel beter voor de ontwikkelsnelheid. Dit is iets wat ik echt bewonder aan de Zen-principes.

Het Suspense-grensprobleem

Dus nu had ik SSG, en de content verscheen in de HTML... maar mijn pagina's waren leeg! Hoe?! <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 blijkt dat renderToReadableStream nog steeds Suspense-grenzen heeft, zelfs als je await stream.allReady gebruikt. Mijn vermoeden is dat dit komt omdat het een "stream" is, ontworpen om aan clients te worden doorgegeven naarmate bytes binnenkomen.

Wat React uitvoert

Wanneer je renderToReadableStream met Suspense gebruikt, geeft React HTML als deze:

<!--$?-->
<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 content moet komen. De <div hidden id="S:0"> bevat de daadwerkelijk gerenderde content. De B:0 matcht met S:0 op nummer (op basis van een 0-index).

Zonder JavaScript zouden zoekmachines (jij dus, Bing) en LLM's een vrijwel lege pagina zien met alleen de template-placeholder. Dat verslaat het hele doel van SSG!

Ik zag geen nette manier om deze Suspense-grenzen te verwijderen, dus mijn oplossing was om wat tests te schrijven en een resolveSuspenseBoundaries-functie om deze om te wisselen. Dit was sneller dan de HTML te parsen en het script uit te voeren met iets als JSDOM. En, belangrijker nog, het was een vereiste voor wat ik gepland had: een nette, leesbare site voor zoekmachines / LLM's zonder JavaScript, maar met ondersteuning voor Suspense-grenzen en hydratie op de client.

De transformatie testen

Ik begon met het schrijven van tests voor de transformatie door wat voorbeelden uit de DOM te pakken van wat ik had (JavaScript uitgeschakeld) en wat ik wilde (JavaScript ingeschakeld). Ik voerde deze in een LLM en liet die de testgeneratie afhandelen, iets waar het behoorlijk goed in is. Deze tests staan in client/src/generators/ssr/renderRoute.test.ts en zorgen ervoor dat de transformatie correct werkt. De tests dekken:

  • Eenvoudige grensvervanging (bloglijst)
  • Complexe grenzen met content tussen template en sluitende comment
  • Meerdere grenzen
  • Grenzen zonder commentmarkeringen
  • Edge cases

Dit type "TDD" is eigenlijk best handig voor deze use case waar je verwachte inputs en outputs hebt.

Dit moet niet verward worden met "TDD voor alles omdat Robert C. Martin het zei" (wat de ontwikkelsnelheid van je team zal vertragen). Je moet GEEN TDD gebruiken voor UI of delen van je code die voortdurend veranderen!

De oplossing: resolveSuspenseBoundaries

Nu de tests er waren, liet ik de LLM de functie voor resolveSuspenseBoundaries schrijven. Ik koos voor cheerio om de breekbaarheid van RegEx te vermijden, ook al zou RegEx hier de SSG-tijd met ongeveer 40% verkorten.

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 LLM's, in plaats van een vrijwel lege pagina, een volledig gerenderde pagina zien.

Nu hebben we SSG werkend zonder JavaScript! <img alt="SSG zonder JavaScript voor 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" }} />

Op de lange termijn is het mogelijk dat React zijn Suspense-formaat gaat veranderen. Ik verwijder misschien de Suspense-resolutiecode zodra ik een betere oplossing heb voor de pagina's die lazy-loaded zijn (en dus Suspense-grenzen vereisen).

Hydratiestrategie (Update: dit kostte 3 dagen + 1 extra dag)

Hydratie is uitdagend. Dat wist ik. Maar na wat werk lukte het me om het werkend te krijgen!

Totale tijd voor hydratie: 3 dagen, plus 1 extra dag om de dehydratie-aanpak te vervangen.

Het lastigste was gewoon die eerste minimale, werkende hydrate voor elkaar krijgen. Toen het me lukte om een "Hello World" met de navbar te renderen, kreeg ik het vertrouwen dat dit, ja, misschien geen hele maand zou kosten!

<img alt="Foony's Hello World die succesvol hydrateert 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 hydratie, maar ik wilde ook goede SEO voor zoekmachines en LLM's zonder dat ontwikkelaars over Suspense-grenzen hoeven na te denken.

De uitdaging

React-hydratie is extreem letterlijk: als de DOM er niet uitziet zoals React verwacht voor die eerste render, krijg je deze leuke, bijna nutteloze foutmelding in je console, en gooit React alles weg en re-rendert vanaf nul. Niet eens een diff om je te laten weten wat er mis ging!

In ons geval maakte SSG dit op een paar manieren erger:

  1. We hebben de HTML nabewerkt om React 18's streaming Suspense-artefacten te verwijderen/op te lossen (wat geweldig is voor bots).
  2. De client had niet altijd exact dezelfde data beschikbaar op tijdstip (t = 0) als de serverrender (SSG-data, blog-metadata, etc).
  3. Onze i18n is standaard "lazy", wat betekent dat vertalingen bij de eerste render kunnen ontbreken, tenzij je registreert welke vertalingen voor SSG werden gebruikt en ze injecteert voordat React rendert.

Wat werkte (initiële aanpak: dehydratie)

Eerst probeerde ik iets slims en leuks: ik gebruikte een command pattern om de commando's vast te leggen die werden gebruikt om de Suspense-grenzen van de HTML op te lossen, en gaf de omgekeerde transformatiecommando's terug zodat ik de HTML kon herstellen naar wat React nodig heeft voor hydratie. Mijn hoop was dat ik veel minder bytes in index.html zou kunnen versturen met deze commandomethode. Maar zoals met de meeste slimme oplossingen mislukte dit omdat browsers de HTML op subtiele manieren wijzigen, zoals het verwijderen of toevoegen van een ; of /, waardoor de vervangingsindexen in de war raakten. Technisch zou je waarschijnlijk rekening kunnen houden met deze subtiele browserwijzigingen, maar ik was niet van plan iets zo broos uit te brengen. In plaats van te proberen de Suspense-grenstransformatie "om te keren" terug naar React's streamingmarkup, deed ik iets supereenvoudigs:

De originele, onopgeloste HTML bundelen in een <script type="text">.

Deze "dehydratie"-aanpak werkte, maar ik besteedde een extra dag om hem te vervangen door een betere oplossing.

De betere aanpak: Critical Path Suspense-grensvervanging

Na de initiële implementatie liep ik nog steeds tegen wat problemen aan met Suspense-grenzen. Toen besefte ik dat er een nettere, betere, eenvoudigere oplossing was. Ik verving de dehydratie-aanpak door critical path Suspense-grensvervanging, die:

  • Het kritieke pad laadt vóór hydratie: componenten die tijdens SSR vooraf werden geladen, worden geïdentificeerd en op de client vooraf geladen voordat hydrateRoot wordt aangeroepen
  • Eenvoudiger te onderhouden is: geen React-internals of AST-parsing nodig (de dehydratie-aanpak moest HTML parsen en herstellen)
  • Minder bytes verstuurt: we bundelen het originele SSR-antwoord van React niet meer in een script-tag
  • Een potentiële flits voorkomt: geen noodzaak om HTML te dehydrateren/rehydrateren, waardoor een potentiële visuele flits wordt geëlimineerd

De implementatie houdt bij welke lazy componenten tijdens SSR vooraf zijn geladen (via SSRLazyComponentTracker), neemt hun importpaden op in de hydratiedata, en laadt ze synchroon vooraf vóór hydratie. Componenten in het kritieke pad renderen direct zonder Suspense-grenzen, wat exact overeenkomt met de SSR-output.

Voor al het andere zorgen we ervoor dat de eerste client-render handelt als SSR/SSG. Dat betekent dezelfde inputs gebruiken en die inputs synchroon beschikbaar maken vóór hydrateRoot. Dit wordt gedaan door te bundelen via onze "ssg-data".

Concreet waren de aanpassingen:

  1. 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 hebben verzonden
      • ssgData: de geserialiseerde SSGData die door de SSR-wrapper wordt gebruikt. Ik ben van plan dit bij te werken naar een Proxy of iets dergelijks zodat alleen de gebruikte data wordt opgenomen.
      • translationData: de vertalings-key-value-blobs die we tijdens SSR hebben aangeraakt
  2. Die inputs vlak voor hydratie injecteren

    • In main.tsx doen we synchroon:
      • #root.innerHTML instellen op de geserialiseerde opgeloste HTML (zodat de DOM precies is wat hydratie ziet)
      • de app inpakken in SSGDataProvider zodat componenten dezelfde SSGData hebben bij de eerste render
  3. i18n direct beschikbaar maken door vertaalwaarden te injecteren

    • We registreren de daadwerkelijke vertaalobjecten die tijdens SSR worden benaderd en versturen ze in het SSG-script.
    • Op de client injecteren we ze direct 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 had!

De useIsSSRMode()-hook 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 (hydratie), en schakelt daarna naar false na mount. Componenten zoals UserBanner, Navbar en Dialog gebruiken dit al om hydratiemismatches te voorkomen.

  1. React patchen voor betere diffs

Ik hoopte dat ik gewoon hydration-overlay kon gebruiken. Maar het wordt niet actief onderhouden, ondersteunt alleen tot React 18, en was niet productiewaardig. Dus liet ik een LLM de repo klonen voor inspiratie, en daarna creëerde het in een paar minuten een minimale hydratie-overlay. Ik had niets fancy nodig, alleen iets dat tijdens ontwikkeling zou verschijnen zodat ik kon achterhalen waar dingen misgingen.

Deze nieuwe overlay is supereenvoudig, dus de diffs zijn niet helemaal perfect. React verwijdert comments, voegt ;s toe na style-attributen, wijzigt witruimte en een paar andere kleine dingen waar onze overlay (nog) geen rekening mee houdt. Onze overlay bevat ook HTML-comments die React negeert voor zijn hydratie.

<img alt="Onze nieuwe hydratie-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 zoeken wat er gefixt moet worden.

<img alt="diff van onze SSG vs eerste-pagina-clientrender voor React-hydratie" 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 van wat deze implementatie inhield:

  • 2 dagen werk (van begin tot werkende SSG). Dit was iets meer dan 24 uur tijdens vakantie.
  • 4 dagen werk om hydratie zich netjes te laten gedragen zonder asynchrone vertalingsraces of useMediaQuery die dingen verstoort.
  • 1 extra dag om de dehydratie-aanpak te vervangen door critical path Suspense-grensvervanging (eenvoudiger, minder bytes, geen potentiële flits).
  • ~200 regels kerncode voor SSG-generatie (GenerateShellSsgFromSitemap.ts)
  • ~120 regels voor het oplossen van Suspense-grenzen (resolveSuspenseBoundaries in renderRoute.tsx) - Opmerking: dit werd later vervangen door de critical path-aanpak
  • ~50 regels SSR-utilities (isSSRMode.ts)
  • ~100 regels tests (renderRoute.test.ts)
  • ~150 regels polyfills voor SSR (setupSSREnvironment)
  • Minimale wijzigingen aan bestaande componenten (vooral het toevoegen van useIsSSRMode()-checks)

De oplossing is lichtgewicht en onderhoudbaar. Het vereist geen frameworkmigratie en werkt met onze bestaande React SPA.

Belangrijkste lessen

Soms is een maatwerkoplossing beter

Niet elk probleem heeft een framework nodig. Voor Foony was een kleine, maatwerk-SSG-oplossing de juiste keuze. Het is:

  • Lichtgewicht: geen zware dependencies of framework-overhead
  • Onderhoudbaar: eenvoudige code die we begrijpen
  • Flexibel: makkelijk te wijzigen en uit te breiden waar nodig
  • Compatibel: werkt met onze bestaande React SPA zonder migratie

React's streaming SSR heeft eigenaardigheden

React's renderToReadableStream is fijn voor het omgaan met Suspense, maar heeft eigenaardigheden. Zelfs met await stream.allReady krijg je nog steeds Suspense-grenzen in de output. Dit is geen bug, het is by design voor streaming. Maar voor SSG hebben we volledig opgeloste HTML nodig. Het voelt als een falen van het React-team om dit scenario niet op een nette manier af te handelen.

Mijn oplossing was om de HTML na te bewerken en grenzen op te lossen. Het is niet mooi, maar het is snel en flexibel genoeg voor mijn use case.

TDD kan nuttig zijn voor LLM's

HTML-transformatie is foutgevoelig. Eén kleine bug en je kunt de hele SSG-output kapotmaken en de eindgebruikerservaring verpesten. Ik liet een LLM uitgebreide tests schrijven (met mijn input) om ervoor te zorgen dat de transformatie correct werkt.

Conclusie

SSG werkt nu voor Foony. Pagina's worden volledig gerenderd voor zoekmachines en LLM's, en de oplossing is onderhoudbaar en lichtgewicht. Hydratie voor de SSG-routes duurde langer dan ik verwachtte (3 dagen), en ik besteedde een extra dag aan het vervangen van de initiële dehydratie-aanpak door critical path Suspense-grensvervanging. De nieuwe aanpak is eenvoudiger te onderhouden, verstuurt minder bytes en voorkomt potentiële visuele flitsen door het dehydrateren/rehydrateren van HTML.

Ik ben nog steeds verbaasd dat het maar 2 dagen kostte om een maatwerkoplossing voor SSG te implementeren. Maar soms is de juiste oplossing de eenvoudigste.

Toekomstig werk omvat het voltooien van de hydratiematching en mogelijk het patchen van React voor betere debugging. Maar voor nu heeft Foony werkende SSG. Ik zal de komende weken een oogje houden op Google Search Console en Bing Webmaster Tools om te zien welk effect dit heeft op onze SEO.

8 Ball Pool online multiplayer billiards icon