

1/1/1970
Paano Ko In-implement ang SSG sa loob ng 2 Araw
Kamusta! Mga isang taon na ang nakalipas, akala ko imposible 'to. Pero kakakatapos ko lang i-implement ang Static Site Generation (SSG) para sa Foony sa loob ng 2 araw, at sobrang saya ko pa rin dito. Hindi rin ito ang unang beses na sinubukan kong ayusin ang SSG para sa Foony. Tiningnan ko na dati ang NextJS, Vike, Astro, Gatsby, at iba pang solusyon. Nagkaroon pa nga ako ng false start sa NextJS, pero nabangga ako sa sobrang complexity ng SPA ng Foony at sa libo-libong files. Parang bangungot ang migration at aabutin ng buwan. Magdadagdag pa 'yon ng extra complexity para sa lahat ng nagtatrabaho sa site dahil kailangan pa nilang aralin ang NextJS at lahat ng kakaibang ugali nito.
Gusto ko ng magaan at madaling i-implement. Gusto ko ng paraan na kaya naming ituloy ang pagsusulat ng code gaya ng dati nang hindi iniisip masyado ang SSG (maliban na lang sa useMediaQuery - wala talagang lusot doon). Sa ibaba, hahatiin ko kung bakit ako napunta sa isang bespoke/custom na solusyon, kung anu-anong specific challenges ang na-encounter ko (lalo na sa Suspense boundaries ng React), at paano ko sila na-solve.
Bakit Hindi na lang Standard na Solutions?
Nung una kong pinag-isipan kung paano magdagdag ng SSG sa Foony, natural na pumasok sa isip ko ang NextJS (industry standard), Vike, at Astro.
NextJS: Sobrang Laking Migration
Malakas at powerful ang NextJS, pero mangangailangan ito ng napakalaking migration mula sa existing na React SPA ng Foony. Mayroon kaming libo-libong files, complex na routing logic, at maraming custom na infrastructure. Ang paglipat sa NextJS ay ibig sabihin:
- Ire-rewrite namin ang buong routing system
- Babaguhin kung paano namin niloload ang games at components
- Buwan ng trabaho bago pa lang makabalik sa parehong level ng features
- Panganib ng mga breaking changes para sa users
- Pagbabago sa paraan ng pag-handle namin ng images
- Mas mabagal na build times (posibleng 5–30 minuto. Wala akong solid na numbers para patunayan 'to maliban sa 5-year-old discussion na 'to sa GitHub)
- Kailangan pang aralin ng buong team ang isang bagong bagay (NextJS), at habambuhay na mas mabagal na developer velocity
- Kailangan mag-migrate ng code tuwing magde-decide ang NextJS na mag-break ng compatibility.
Sinubukan ko na rin mag false start sa NextJS, pero mabilis kong na-realize na sobrang taas ng migration cost. Hindi sulit 'yung complexity.
Vike: Pareho ring Kumplikado
Si Vike (dating vite-plugin-ssr) may parehong problema. Mas flexible siya kaysa NextJS, pero mangangailangan pa rin ng malaking restructuring ng codebase namin. 'Yung learning curve at migration effort, hindi justified sa makukuha naming benefits.
Astro: Maling Architecture
Ang Astro ay angas para sa mga content-heavy na sites, pero ang Foony ay isang complex multiplayer game platform. Kailangan namin ng real-time updates, WebSocket connections, at dynamic na React components. Hindi talaga tugma sa ginagawa namin ang architecture ng Astro.
Ang Solusyon: Custom na SSG
Dahil lumakas loob ko sa "fake SSG" approach na ginawa ko ilang araw matapos ang i18n, napunta ako sa isang maliit, magaan, custom na solusyon para sa SSG ng Foony.
Sa "fake SSG" approach ko, kinukuha ko ang blog post content mula sa mga page na may blog posts (
/postsroutes at game pages), tapos nilalagay ko sila eksakto kung saan sila ire-render ng client, para sa search engines at LLMs na mas madaling maintindihan ang Foony. Naglalagay din ito ng ld+json schema at ilang maliliit na SEO tweaks.
Simple lang ang approach:
- I-build sa ibabaw ng existing na React SPA: Walang migration, magdadagdag lang ng SSG generation sa build time.
- Gamitin ang
renderToReadableStream: Si React 18 streaming SSR API na bahala sa Suspense nang native. - Gumawa ng static HTML files: Pre-render ng routes sa build time at i-serve sila bilang static files, gamit ang SitemapGenerator namin para makuha ang list ng routes.
- Minimal na pagbabago sa existing codebase: Karamihan sa components, gumagana na agad.
Nakatira ang core implementation sa client/src/generators/GenerateShellSsgFromSitemap.ts. Nagbabasa ito ng sitemap, nirender ang bawat route gamit ang renderToReadableStream ng React, at sinusulat ang HTML sa static files. Simple, sakto sa trip ko!
At mabilis pa pala. Mga 2,800 routes ang na-render sa loob lang ng 10 segundo. Ang sarap. Mas mabilis 'yan nang malayo kaysa NextJS, Gatsby, at Astro. <img alt="SSG console log na nagpapakita ng oras na inabot" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Pwede pa akong mag sermon nang mahaba tungkol sa simplicity. Kahit hindi ka nito ma-promote sa malalaking kumpanya dahil "kulang sa complexity", ang simple na code ay maganda, madaling i-maintain, at sa pangkalahatan mas maganda para sa developer velocity. Isa 'to sa mga talagang hinahangaan ko sa Zen principles.
Ang Problema sa Suspense Boundary
So ngayon may SSG na ako, at lumalabas na ang content sa HTML... pero blanco ang mga page ko! Paano nangyari 'yon?! <img alt="blangkong page ng SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Ang nangyari, si renderToReadableStream ay may Suspense boundaries pa rin, kahit mag-await stream.allReady ka pa (await stream.allReady). Hula ko, dahil "stream" siya, at dine-design para i-pass sa clients habang dumarating pa lang ang bytes.
Ano ang Ina-output ng React
Kapag gumamit ka ng renderToReadableStream na may Suspense, ganito ang ina-output ni React na HTML:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
Yung <template id="B:0"> ay placeholder kung saan dapat mapupunta ang content. Yung <div hidden id="S:0"> naman ang may actual na rendered content. Nagmamatch ang B:0 sa S:0 base sa number (0-based index).
Kapag walang JavaScript, ang mga search engine (oo, pati ikaw, Bing) at LLMs ay halos blangkong page lang ang makikita, na may template placeholder lang. Nasasayang tuloy ang buong point ng SSG!
Hindi ako nakakakita ng malinis na paraan para alisin ang mga Suspense boundaries na 'to, kaya ang solusyon ko ay magsulat ng tests at isang function na resolveSuspenseBoundaries para iswap sila. Mas mabilis ito kaysa i-parse ang HTML at i-execute ang script gamit ang mga tool tulad ng JSDOM. At mas importante, kailangan ko siya para sa plano ko: isang malinaw at mababasang site para sa search engines / LLMs kahit walang JavaScript, pero may support pa rin sa Suspense boundaries at hydration sa client.
Pagtetest sa Transformation
Nagsimula ako sa pagsusulat ng tests para sa transformation sa pamamagitan ng pagkuha ng mga halimbawa sa DOM: kung ano ang meron ako (JavaScript disabled), at kung ano ang gusto ko (JavaScript enabled). Pinakain ko 'to sa isang LLM at pinagawa ko sa kanya ang test generation, na medyo forte naman niya.
Nasa client/src/generators/ssr/renderRoute.test.ts ang mga tests na 'to at sila ang nag-a-assure na tama ang transformation. Sinasaklaw ng tests ang:
- Simple boundary replacement (blog listing)
- Complex boundaries na may content sa pagitan ng template at closing comment
- Multiple boundaries
- Boundaries na walang comment markers
- Iba't ibang edge cases
Itong klaseng "TDD" ay sobrang useful para sa ganitong kaso kung saan may malinaw kang expected inputs at outputs.
Hindi ito pareho sa "TDD everything because Robert C. Martin said so" (na siguradong mababagalan ang dev velocity ng team mo). HINDI mo dapat ginagamit ang TDD para sa UI o sa parts ng code na laging nagbabago!
Ang Solusyon: resolveSuspenseBoundaries
Ngayong ayos na ang mga tests, pinasulat ko sa LLM ang function na resolveSuspenseBoundaries. Gumamit ako ng cheerio dito para hindi brittle tulad ng RegEx, kahit na kung RegEx ang gagamitin, mababawasan nang mga 40% ang SSG time.
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};
}
Dahil dito, imbes na halos blangkong page ang makita, makikita ng search engines at LLMs ang fully-rendered na page.
Ngayon, maayos nang gumagana ang SSG kahit walang JavaScript!
<img alt="SSG ng Foony blogs na walang JavaScript" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
Sa long-term, posible na baguhin pa ng React ang Suspense format nila. Baka alisin ko rin balang araw ang Suspense resolution code kapag may mas magandang solusyon na ako para sa mga pages na lazy-loaded (na nangangailangan ng Suspense boundaries).
Hydration Strategy (Update: Umabot ito ng 3 Araw + 1 Extra Day)
Challenging talaga ang hydration. Alam ko na 'yon. Pero pagkatapos ng kaunting paghihirap, napaandar ko rin!
Total time na inabot para sa hydration: 3 araw, plus 1 extra day para palitan ang dehydration approach.
Pinaka-mahirap na parte ay makuha lang 'yung unang minimal, gumaganang hydrate. Nung naipan-render ko na ang "Hello World" kasama ang navbar, nagkaroon ako ng confidence na, oo, hindi pala ito aabot ng isang buong buwan!
<img alt="Hello World ng Foony na matagumpay na nag-hydrate kasama ang 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" }} />
Para sa unang minimal, gumaganang hydrate na 'yon, may unique akong challenge: gusto ko ng hydration, pero gusto ko rin ng magandang SEO para sa search engines at LLMs kahit hindi iniisip ng mga dev ang Suspense boundaries.
Ang Hamon
Sobrang literal ng React hydration: kapag hindi tugma ang DOM sa inaasahan ni React para sa first render, bibigyan ka lang niya ng isang magandang pero halos walang silbing error message sa console, at itatapon niya ang lahat tapos magre-render siya ulit mula sa simula. Wala man lang diff para malaman mo kung saan nagkamali!
Sa kaso namin, mas pinalala pa ito ng SSG sa ilang paraan:
- Pinopost-process namin ang HTML para alisin/ayusin ang React 18 streaming Suspense artifacts (maganda para sa bots).
- Hindi laging pareho ang data na meron ang client sa oras na (t = 0) kumpara sa server render (SSG data, blog metadata, atbp).
- "Lazy" by default ang i18n namin, kaya pwedeng kulang ang translations sa first render kung hindi mo i-record kung aling translations ang ginamit sa SSG at i-inject sila bago mag-render si React.
Ano ang Gumana (Initial Approach: Dehydration)
Sa simula, sinubukan ko ang isang medyo clever at cute na approach: gumamit ako ng command pattern para i-record ang mga command na ginamit sa pag-resolve ng HTML Suspense boundaries, at binalik ko ang reverse transformation commands para ma-restore ang HTML pabalik sa format na kailangan ni React para sa hydration.
Umaasa ako na makakapag-ship ako ng mas kakaunting bytes sa index.html gamit ang command method na 'to. Pero, gaya ng karamihang "clever" na solusyon, pumalya ito dahil binabago ng browsers ang HTML sa maliliit na paraan, tulad ng pag-alis o pagdagdag ng ; o /, at nasisira tuloy ang replacement indices.
Technically, pwede mong subukang i-account lahat ng maliliit na pagbabago ng browser, pero hindi ako willing mag-ship ng isang sobrang brittle na solusyon.
Sa halip na pilitin kong i-"reverse" ang Suspense-boundary transformation pabalik sa streaming markup ni React, ginawa ko na lang ang sobrang simpleng 'to:
I-bundle ang original, unresolved HTML sa loob ng <script type="text">.
Gumana ang "dehydration" approach na 'to, pero gumugol pa ako ng isang extra day para palitan siya ng mas magandang solusyon.
Ang Mas Magandang Approach: Critical Path Suspense Boundary Replacement
Pagkatapos ng initial implementation, na-e-encounter ko pa rin ang ilang issues sa Suspense boundaries. Doon ko na-realize na may mas malinis, mas maganda, at mas simple palang solusyon. Pinalitan ko ang dehydration approach gamit ang critical path Suspense boundary replacement, na:
- Lineload muna ang critical path bago mag-hydrate: Lahat ng components na na-preload sa SSR ay tina-track at pinre-preload din sa client bago tawagin ang
hydrateRoot
- Mas simple i-maintain: Walang kailangang galawin na React internals o AST parsing (kailangan 'yon sa dehydration approach para ma-parse at ma-restore ang HTML)
- Mas kaunti ang bytes na isi-ship: Hindi na namin kailangang i-bundle ang original SSR response ni React sa isang script tag
- Iwas potential na flash: Hindi na kailangan mag-dehydrate/rehydrate ng HTML, kaya iwas sa posibleng visual flash
Tina-track ng implementation kung aling lazy components ang na-preload noong SSR (via SSRLazyComponentTracker), sinasama ang import paths nila sa hydration data, at pina-preload sila nang synchronous bago ang hydration. Ang mga critical path components ay nagre-render nang direkta nang walang Suspense boundaries, kaya eksaktong tumutugma sa SSR output.
Para sa lahat ng iba pa, pinaparamdam naming parang SSR/SSG din ang first client render. Ibig sabihin, pareho ang inputs at kailangang maging available ang inputs na 'yon nang synchronously bago tumakbo ang hydrateRoot. Ginagawa namin 'to sa pamamagitan ng pag-bundle gamit ang "ssg-data" namin.
Sa mas konkretong paraan, ito ang mga adjustments:
I-bundle ang SSR inputs sa isang text script
- Sa SSG, nag-i-inject kami ng
<script type="text/foony-ssg" id="foony-ssg-data">...</script> bago ang Vite module entrypoint.
- Nasa script na 'yon ang:
html: ang resolved HTML na talagang sinama namin sa static file
ssgData: ang serialized na SSGData na ginamit ng SSR wrapper. Plano kong gawing Proxy o kung ano man ito balang araw para 'yung ina-access lang ang isasama.
translationData: ang translation key-value blobs na nagamit namin habang SSR
I-inject ang mga inputs na 'yon bago mag-hydrate
- Sa
main.tsx, synchronously naming:
- sine-set ang
#root.innerHTML sa serialized resolved HTML (para eksaktong tugma ang DOM sa nakikita ng hydration)
- ini-wrap ang app sa
SSGDataProvider para may parehong SSGData ang mga components sa first render
Gawing instant ang i18n sa pamamagitan ng pag-inject ng translation values
- Nire-record namin kung aling mga translation object ang actual na na-access noong SSR at sinasama namin sila sa SSG script.
- Sa client, ini-inject namin sila diretso sa cache ng
LocaleQueryer gamit ang dedicated na LocaleQueryer.inject() method, para available na agad ang translations.
At dahil doon, pareho na ang data ng first render at ng SSR!
Na-implement na rin ang useIsSSRMode() hook sa 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;
}
Magre-return ang hook na 'to ng true habang SSR at sa first client render (hydration), tapos lilipat sa false pagkatapos ng mount. Gamit na ito ng mga components tulad ng UserBanner, Navbar, at Dialog para iwasan ang hydration mismatches.
- I-patch ang React para sa mas malinaw na diffs
Sana pwede ko na lang gamitin ang hydration-overlay. Pero hindi na ito actively maintained, hanggang React 18 lang ang support, at hindi production-ready. Kaya pinakopya ko muna sa LLM ang repo para may inspirasyon, tapos pinagawa ko rito ang isang minimal na hydration overlay sa loob lang ng ilang minuto. Hindi ko naman kailangan ng sobrang fancy - gusto ko lang ng isang bagay na lilitaw sa development para malaman ko kung saan nagkakaproblema.
Sobrang basic ng bagong overlay na 'to, kaya hindi pa ganun ka-perfect ang diffs. Tinatanggal ni React ang comments, naglalagay ng ; pagkatapos ng style attributes, binabago ang whitespace, at kung anu-ano pang maliliit na bagay na hindi pa accounted for ng overlay namin (para sa ngayon). Kasama rin sa overlay namin ang HTML comments na hindi naman tinitingnan ni React para sa hydration.
<img alt="Bagong hydration overlay namin" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Pero sapat na siya para makita kung ano ang dapat ayusin.
<img alt="diff ng SSG namin kumpara sa client first-page render para sa 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" }} />
Sa Usapang Numbers
Para magkaroon ka ng ideya kung ano ang involved sa implementation na 'to:
- 2 araw ng trabaho (mula simula hanggang working SSG). Medyo higit lang sa 24 oras habang naka-holiday pa ako.
- 4 na araw ng trabaho para gumanda ang ugali ng hydration nang walang async translation races o tantrums mula sa
useMediaQuery.
- 1 extra day para palitan ang dehydration approach ng critical path Suspense boundary replacement (mas simple, mas kaunting bytes, walang potential flash).
- ~200 lines ng core SSG generation code (
GenerateShellSsgFromSitemap.ts)
- ~120 lines ng Suspense boundary resolution (
resolveSuspenseBoundaries sa renderRoute.tsx) - Note: Pinalitan na rin ito kalaunan ng critical path approach
- ~50 lines ng SSR utilities (
isSSRMode.ts)
- ~100 lines ng tests (
renderRoute.test.ts)
- ~150 lines ng polyfills para sa SSR (
setupSSREnvironment)
- Minimal na changes sa existing na components (kadalasan pagdagdag lang ng
useIsSSRMode() checks)
Magaan at madaling i-maintain ang solusyon. Hindi ito nangangailangan ng framework migration, at gumagana ito sa existing naming React SPA.
Mga Natutunan
Minsan, Mas Okay ang Bespoke/Custom na Solusyon
Hindi lahat ng problema kailangan ng framework. Para sa Foony, isang maliit, bespoke na SSG solution ang tamang choice. Ito ay:
- Magaan: Walang mabibigat na dependencies o framework overhead
- Madaling i-maintain: Simple na code na naiintindihan namin
- Flexible: Madaling baguhin at palawakin kung kailangan
- Compatible: Gumagana agad sa existing naming React SPA nang walang migration
May Mga Quirk ang Streaming SSR ng React
Maganda ang renderToReadableStream ni React para sa pag-handle ng Suspense, pero may mga quirks siya. Kahit mag-await stream.allReady ka, may Suspense boundaries pa rin sa output. Hindi ito bug - ganoon talaga ang design para sa streaming. Pero para sa SSG, kailangan namin ng fully-resolved na HTML. Medyo ramdam ko na parang may kulang sa React team dito na hindi nila inayos nang malinis ang ganitong scenario.
Ang solusyon ko ay i-post-process na lang ang HTML at i-resolve ang boundaries. Hindi siya glamorous, pero mabilis siya at sapat ang flexibility para sa use case ko.
Puwedeng Maging Useful ang TDD Kapag May LLM
Error-prone ang HTML transformation. Kahit maliit lang na bug, puwedeng masira ang buong SSG output at maapektuhan ang end-user experience. Pinagawa ko sa isang LLM ang comprehensive tests (syempre may input ko pa rin) para masiguradong tama ang transformation.
Panghuli
Gumagana na ang SSG para sa Foony. Fully rendered na ang mga page para sa search engines at LLMs, at magaan at madaling i-maintain ang solusyon. Medyo mas matagal kaysa inaasahan ang hydration para sa SSG routes (3 araw), at gumugol pa ako ng isang extra day para palitan ang initial dehydration approach ng critical path Suspense boundary replacement. Mas simple i-maintain ang bagong approach, mas kaunti ang bytes na sine-ship, at iniiwasan nito ang potential na visual flashes galing sa pag-dehydrate/rehydrate ng HTML.
Hanggang ngayon, medyo nabibigla pa rin ako na umabot lang ng 2 araw ang pag-implement ng bespoke solution para sa SSG. Pero minsan, ang tamang solusyon talaga ay 'yung pinaka-simple.
Sa susunod, balak ko pang tapusin nang mas pulido ang hydration matching at posibleng mag-patch pa ng React para sa mas magandang debugging. Pero sa ngayon, may working SSG na ang Foony. Tututukan ko ang Google Search Console at Bing Webmaster Tools sa mga susunod na linggo para makita kung ano ang epekto nito sa SEO namin.