background blurbackground mobile blur

1/1/1970

Bagaimana Aku Mengimplementasikan SSG dalam 2 Hari

Halo! Setahun lalu, aku pikir ini nggak mungkin. Tapi aku baru saja selesai mengimplementasikan Static Site Generation (SSG) untuk Foony dalam 2 hari, dan aku cukup excited soal ini. Ini juga bukan pertama kalinya aku coba nyelesain SSG buat Foony. Sebelumnya aku sudah lihat NextJS, Vike, Astro, Gatsby, dan beberapa solusi lain. Aku bahkan sempat mulai pakai NextJS, tapi ketemu banyak kesulitan karena kompleksitas SPA Foony dan ribuan file yang kami punya. Migrasinya bakal jadi mimpi buruk dan makan waktu berbulan-bulan. Belum lagi bakal nambah kompleksitas buat semua orang yang kerja di situs ini, karena mereka harus belajar NextJS dan segala keunikannya.

Aku pengin sesuatu yang ringan dan gampang diimplementasikan. Sesuatu yang bikin kami bisa terus nulis kode seperti biasa tanpa harus mikir soal SSG (kecuali useMediaQuery—yang ini memang nggak ada jalan pintasnya). Di bawah ini aku jelasin kenapa akhirnya aku pilih solusi kustom, tantangan spesifik yang aku temui (terutama soal Suspense boundary di React), dan gimana aku ngatasinnya.

Kenapa Bukan Solusi Standar?

Waktu pertama kali mikirin buat nambahin SSG ke Foony, yang kepikiran tentu NextJS (standar industri), Vike, dan Astro.

NextJS: Terlalu Banyak Migrasi

NextJS itu kuat, tapi bakal butuh migrasi besar-besaran dari React SPA Foony yang sudah ada. Kita punya ribuan file, logika routing yang kompleks, dan banyak infrastruktur kustom. Migrasi ke NextJS bakal berarti:

  • Nulis ulang seluruh sistem routing
  • Ngerombak cara kami me-load game dan komponen
  • Kerja berbulan-bulan cuma buat balik lagi ke jumlah fitur yang sama
  • Potensi perubahan besar yang bisa ngerusak pengalaman pengguna
  • Mengubah cara kami menangani gambar
  • Waktu build yang jauh lebih lambat (potensial 5-30 menit. Aku nggak punya angka pasti selain diskusi di GitHub 5 tahun lalu ini)
  • Satu tim harus belajar hal baru (NextJS), dan kecepatan develop turun selamanya
  • Harus migrasi kode tiap kali NextJS mutusin buat bikin breaking change.

Aku bahkan sempat coba mulai pakai NextJS, tapi cepat sadar kalau biaya migrasinya terlalu tinggi. Kompleksitasnya nggak sebanding dengan hasilnya.

Vike: Kompleksitas Mirip

Vike (dulu vite-plugin-ssr) punya masalah mirip. Walaupun lebih fleksibel dari NextJS, tetap saja butuh restrukturisasi besar di codebase kami. Kurva belajarnya dan effort migrasinya nggak sebanding dengan manfaat yang kami dapat.

Astro: Arsitektur yang Kurang Pas

Astro keren buat situs yang isinya banyak konten, tapi Foony itu platform game multiplayer yang kompleks. Kami butuh update real-time, koneksi WebSocket, dan komponen React yang dinamis. Arsitektur Astro kurang cocok dengan apa yang lagi kami bangun.

Solusinya: SSG Buatan Sendiri

Terinspirasi dari pendekatan "fake SSG" yang aku bikin beberapa hari lalu setelah i18n, aku akhirnya milih solusi SSG kecil, ringan, dan buatan sendiri khusus untuk Foony.

Pendekatan "fake SSG" yang aku pakai waktu itu adalah menarik konten blog post dari halaman yang punya blog (/posts dan halaman game), lalu naruh konten itu tepat di posisi tempat client bakal nampilin kontennya. Ini khusus buat mesin pencari dan LLM supaya lebih gampang ngerti Foony. Di situ aku juga nambahin schema ld+json dan beberapa hal kecil untuk SEO.

Pendekatannya simpel:

  1. Dibangun di atas React SPA yang sudah ada: Nggak perlu migrasi, cuma nambah proses generate SSG waktu build.
  2. Pakai renderToReadableStream: API streaming SSR React 18 ini sudah nangani Suspense secara native.
  3. Generate file HTML statis: Pre-render route saat build dan layani sebagai file statis, pakai SitemapGenerator untuk dapetin daftar route.
  4. Perubahan minimal di codebase yang ada: Hampir semua komponen bisa jalan apa adanya.

Implementasi utamanya ada di client/src/generators/GenerateShellSsgFromSitemap.ts. File ini baca sitemap, render tiap route pakai renderToReadableStream dari React, dan nulis HTML-nya ke file statis. Simpel, persis seperti yang aku suka!

Ternyata ini juga lumayan cepat. Sekitar 2.800 route kebentuk dalam 10 detik. Mantap. Itu jauh lebih cepat dibanding NextJS, Gatsby, dan Astro. <img alt="Log konsol SSG yang menunjukkan waktu yang dibutuhkan" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Aku bisa ceramah panjang soal kesederhanaan. Walaupun mungkin nggak bikin kamu naik jabatan di perusahaan besar karena dianggap "kurang kompleks", kode yang simpel itu indah, mudah dirawat, dan umumnya jauh lebih baik buat kecepatan pengembangan. Ini salah satu hal yang aku suka banget dari prinsip Zen.

Masalah Suspense Boundary

Jadi sekarang aku sudah punya SSG, kontennya muncul di HTML... tapi halamanku kosong! Kok bisa?! <img alt="Halaman SSG kosong" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Ternyata renderToReadableStream masih punya Suspense boundary, bahkan kalau kamu sudah pakai await stream.allReady. Tebakanku, karena ini bentuknya "stream" dan memang didesain buat dikirim ke client sambil jalan seiring byte diterima.

Apa yang Dihasilkan React

Kalau kamu pakai renderToReadableStream bareng Suspense, React bakal ngeluarin HTML seperti ini:

<!--$?-->
<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"> itu placeholder tempat seharusnya konten berada. <div hidden id="S:0"> berisi konten sebenarnya yang sudah dirender. B:0 dan S:0 dicocokkan lewat angkanya (indeks dari 0).

Tanpa JavaScript, mesin pencari (hai, Bing) dan LLM bakal lihat halaman yang hampir kosong, cuma ada placeholder template. Ini jelas ngancurin tujuan SSG dari awal!

Aku nggak nemu cara bersih buat ngilangin Suspense boundary ini, jadi solusinya aku tulis beberapa test dan bikin fungsi resolveSuspenseBoundaries untuk menukar struktur itu. Ini ternyata lebih cepat daripada parsing HTML dan jalanin script pakai sesuatu seperti JSDOM. Dan yang lebih penting, ini penting banget buat rencana utamaku: situs yang rapi dan enak dibaca untuk mesin pencari / LLM tanpa JavaScript, tapi tetap dukung Suspense boundary dan hydration di client.

Ngetes Transformasinya

Aku mulai dengan nulis test buat transformasinya dengan cara ngambil contoh DOM dari kondisi yang ada sekarang (JavaScript dimatikan), dan kondisi yang aku pengin (JavaScript nyala). Lalu aku lempar contoh ini ke LLM dan minta dia bikinin test-nya, sesuatu yang memang dia cukup jago.

Test ini ada di client/src/generators/ssr/renderRoute.test.ts dan ngejamin transformasinya jalan dengan benar. Test-nya mencakup:

  • Penggantian boundary yang simpel (listing blog)
  • Boundary yang kompleks dengan konten di antara template dan komentar penutup
  • Banyak boundary sekaligus
  • Boundary tanpa penanda komentar
  • Berbagai edge case

Tipe "TDD" kayak gini lumayan berguna buat kasus di mana kamu punya input dan output yang jelas.

Ini beda dengan "semua harus TDD karena Robert C. Martin bilang begitu" (yang justru bakal ngelambatin pengembangan tim kamu). Kamu TIDAK perlu pakai TDD buat UI atau area kode yang terus berubah!

Solusinya: resolveSuspenseBoundaries

Setelah test siap, aku minta LLM nulis fungsi resolveSuspenseBoundaries. Aku pakai cheerio di sini supaya nggak rapuh seperti kalau pakai RegEx, walaupun kalau pakai RegEx mungkin waktu SSG bisa kepotong sekitar 40%.

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}; }

Dengan ini, alih-alih lihat halaman yang hampir kosong, mesin pencari dan LLM bakal lihat halaman yang sudah dirender penuh.

Sekarang SSG kami sudah jalan dengan baik tanpa JavaScript! <img alt="SSG tanpa JavaScript untuk blog 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" }} />

Dalam jangka panjang, bisa jadi React bakal mengubah format Suspense mereka. Mungkin nanti aku bakal ngehapus kode resolusi Suspense ini kalau sudah punya solusi yang lebih baik buat halaman-halaman yang lazy-loaded (dan karenanya butuh Suspense boundary).

Strategi Hydration (Update: Ini Makan Waktu 3 Hari + 1 Hari Tambahan)

Hydration itu menantang. Aku sudah tahu. Tapi setelah sedikit ngoprek, akhirnya bisa jalan juga!

Total waktu yang kepakai buat hydration: 3 hari, plus 1 hari ekstra buat mengganti pendekatan dehydration.

Bagian paling susah adalah bikin versi paling minimal yang benar-benar bisa hydrate dulu. Begitu aku berhasil ngerender "Hello World" bareng navbar, aku mulai yakin, oke, ini kayaknya nggak bakal makan waktu sebulan penuh!

<img alt="Hello World Foony berhasil di-hydrate dengan 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" }} />

Untuk versi minimal pertama yang berhasil hydrate ini, aku punya tantangan unik: aku pengin hydration, tapi juga pengin SEO yang bagus buat mesin pencari dan LLM, tanpa bikin developer harus mikir soal Suspense boundary.

Tantangannya

Hydration di React itu super literal: kalau DOM-nya nggak persis sama dengan yang React harapkan di render pertama, kamu bakal dapet pesan error yang kelihatannya keren tapi hampir nggak berguna di console, dan React bakal buang semuanya lalu render ulang dari nol. Nggak ada diff yang kasih tahu apa yang salah!

Di kasus kami, SSG bikin ini makin parah dalam beberapa cara:

  1. Kami memproses ulang HTML setelahnya untuk menghapus/menyelesaikan artefak Suspense streaming React 18 (yang mana bagus buat bot).
  2. Client nggak selalu punya data yang sama persis di waktu (t = 0) seperti yang dipakai server saat render (data SSG, metadata blog, dan sebagainya).
  3. i18n kami itu "lazy" secara default, yang artinya terjemahan bisa saja belum siap di render pertama kecuali kamu catat terjemahan mana yang dipakai saat SSG dan suntikkan dulu sebelum React render.

Yang Berhasil (Pendekatan Awal: Dehydration)

Awalnya aku coba sesuatu yang keliatan pintar dan lucu: aku pakai pola command buat nyatat perintah yang dipakai buat menyelesaikan Suspense boundary di HTML, lalu ngembaliin perintah kebalikannya supaya aku bisa balikin HTML ke bentuk yang React butuh buat hydration. Harapanku adalah aku bisa ngirim jauh lebih sedikit byte di index.html dengan metode command ini. Tapi, seperti kebanyakan solusi yang "terlalu pintar", ini gagal karena browser suka mengubah HTML dengan hal-hal kecil, misalnya nambah atau ngilangin ; atau /, yang bikin indeks penggantian jadi berantakan. Secara teori kamu mungkin bisa ngakomodasi perubahan halus dari browser ini, tapi aku nggak mau kirim sesuatu yang serapuh itu.

Alih-alih coba "mengembalikan" transformasi Suspense boundary ke markup streaming React, aku pilih cara super simpel:

Bungkus HTML asli yang belum di-resolve ke dalam <script type="text">.

Pendekatan "dehydration" ini jalan, tapi aku habiskan satu hari ekstra buat ganti dengan solusi yang lebih baik.

Pendekatan yang Lebih Baik: Critical Path Suspense Boundary Replacement

Setelah implementasi awal, aku masih ketemu beberapa masalah dengan Suspense boundary. Di situ aku sadar ada solusi yang lebih bersih, lebih bagus, dan lebih simpel. Aku ganti pendekatan dehydration dengan critical path Suspense boundary replacement, yang:

  • Meload critical path sebelum hydration: Komponen yang sudah di-preload saat SSR dicatat dan di-preload lagi di client sebelum hydrateRoot dipanggil
  • Lebih gampang dirawat: Nggak perlu ngoprek internal React atau parsing AST (pendekatan dehydration perlu parsing dan balikin HTML)
  • Ngirim lebih sedikit byte: Kami nggak lagi membundel respons SSR asli dari React dalam script tag
  • Mencegah kemungkinan flash: Nggak perlu dehydrate/rehydrate HTML, jadi potensi flash visual hilang

Implementasinya melacak komponen lazy mana yang di-preload saat SSR (via SSRLazyComponentTracker), menyertakan path import-nya di data hydration, dan me-load komponen itu secara sinkron sebelum hydration. Komponen di critical path dirender langsung tanpa Suspense boundary, sehingga hasilnya persis sama dengan output SSR.

Untuk bagian lain, kami bikin render pertama di client bertindak seperti SSR/SSG. Artinya pakai input yang sama, dan pastikan input itu tersedia secara sinkron sebelum hydrateRoot. Ini dilakukan dengan membundel data melalui "ssg-data" kami.

Secara konkret, penyesuaiannya:

  1. Membundel input SSR ke dalam satu script text

    • Waktu SSG, kami nyuntik <script type="text/foony-ssg" id="foony-ssg-data">...</script> tepat sebelum entrypoint modul Vite.
    • Script ini berisi:
      • html: HTML yang sudah di-resolve yang benar-benar kami kirim di file statis
      • ssgData: SSGData yang sudah diserialisasi dan dipakai wrapper SSR. Rencananya bakal aku ubah ke Proxy atau semacamnya supaya cuma data yang diakses yang ikut dikirim.
      • translationData: kumpulan key-value terjemahan yang dipakai saat SSR
  2. Menyuntikkan input itu tepat sebelum hydration

    • Di main.tsx, kami secara sinkron:
      • set #root.innerHTML ke HTML resolved yang diserialisasi (jadi DOM-nya persis sama dengan yang dilihat React saat hydration)
      • membungkus app dengan SSGDataProvider supaya komponen punya SSGData yang sama di render pertama
  3. Bikin i18n instan dengan menyuntik nilai terjemahan

    • Kami catat objek terjemahan mana saja yang dipakai saat SSR dan kirim sebagai bagian dari script SSG.
    • Di client, kami suntik data itu langsung ke cache LocaleQueryer lewat method khusus LocaleQueryer.inject(), jadi terjemahan langsung tersedia.

Dan dengan begitu, render pertama punya data yang sama dengan SSR!

Hook useIsSSRMode() sudah diimplementasikan di 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;
}

Hook ini bakal balikin true saat SSR dan di render pertama di client (hydration), lalu ganti ke false setelah komponen mount. Komponen seperti UserBanner, Navbar, dan Dialog sudah pakai hook ini buat ngehindarin mismatch waktu hydration.

  1. Patch React supaya diff lebih enak dilihat

Awalnya aku berharap bisa langsung pakai hydration-overlay. Tapi proyek ini nggak lagi aktif, cuma dukung sampai React 18, dan belum siap buat produksi. Jadi aku minta LLM clone repo itu buat inspirasi, lalu dia bikin overlay hydration minimal dalam beberapa menit. Aku nggak butuh sesuatu yang canggih, cuma yang bisa muncul waktu development supaya aku tahu bagian mana yang bermasalah.

Overlay baru ini super basic, jadi diff-nya belum sempurna banget. React ngilangin komentar, nambah ; setelah atribut style, mengubah whitespace, dan beberapa hal kecil lain yang belum diakomodasi overlay kami (untuk sekarang). Overlay kami juga masih menyertakan komentar HTML yang diabaikan React saat hydration.

<img alt="Overlay hydration baru kami" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />

Tapi ini sudah cukup buat nunjukin mana yang perlu dibenerin.

<img alt="diff antara SSG kami dan render pertama di client untuk hydration React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

Dalam Angka

Biar kebayang seberapa besar implementasi ini:

  • 2 hari kerja (dari mulai sampai SSG berfungsi). Ini sedikit lebih dari 24 jam total, dan aku lagi liburan waktu itu.
  • 4 hari kerja buat bikin hydration di route SSG berjalan mulus tanpa race async di terjemahan atau useMediaQuery bikin masalah.
  • 1 hari ekstra buat mengganti pendekatan dehydration dengan critical path Suspense boundary replacement (lebih simpel, lebih sedikit byte, tanpa kemungkinan flash).
  • ~200 baris kode inti untuk generate SSG (GenerateShellSsgFromSitemap.ts)
  • ~120 baris untuk resolusi Suspense boundary (resolveSuspenseBoundaries di renderRoute.tsx) - Catatan: Ini kemudian diganti dengan pendekatan critical path
  • ~50 baris utility SSR (isSSRMode.ts)
  • ~100 baris test (renderRoute.test.ts)
  • ~150 baris polyfill untuk SSR (setupSSREnvironment)
  • Perubahan minimal di komponen yang sudah ada (kebanyakan cuma nambah pengecekan useIsSSRMode())

Solusinya ringan dan gampang dirawat. Nggak butuh migrasi ke framework lain, dan bisa jalan dengan React SPA kami yang sudah ada.

Hal Penting yang Bisa Diambil

Kadang Solusi Kustom Itu Lebih Baik

Nggak semua masalah butuh framework. Buat Foony, solusi SSG kecil buatan sendiri ternyata pilihan yang paling pas. Solusi ini:

  • Ringan: Nggak ada dependency berat atau overhead framework
  • Gampang dirawat: Kode simpel yang benar-benar kami ngerti
  • Fleksibel: Gampang diubah dan dikembangkan sesuai kebutuhan
  • Kompatibel: Jalan bareng React SPA kami yang sudah ada tanpa migrasi

Streaming SSR React Punya Keanehan

renderToReadableStream di React enak buat ngadepin Suspense, tapi ada beberapa keanehan. Bahkan dengan await stream.allReady, kamu tetap dapat Suspense boundary di output. Ini bukan bug; memang desainnya begitu untuk streaming. Tapi buat SSG, kami butuh HTML yang sudah benar-benar di-resolve. Rasanya seperti React kurang menyediakan cara yang rapi buat skenario ini.

Solusi yang aku pakai adalah memproses ulang HTML setelahnya dan menyelesaikan boundary tersebut. Bukan solusi yang super elegan, tapi cepat dan cukup fleksibel buat kebutuhanku.

TDD Bisa Berguna Buat LLM

Transformasi HTML itu gampang bikin bug. Satu kesalahan kecil bisa bikin seluruh output SSG rusak dan ngancurin pengalaman pengguna. Di sini aku pakai LLM buat nulis test yang cukup lengkap (dengan direction dariku) supaya transformasinya benar-benar terjamin.

Kesimpulan

Sekarang SSG sudah jalan untuk Foony. Halaman-halaman dirender penuh untuk mesin pencari dan LLM, dan solusinya tetap ringan serta gampang dirawat. Hydration untuk route SSG memang makan waktu lebih lama dari yang aku kira (3 hari), dan aku habiskan satu hari ekstra buat ganti pendekatan dehydration awal dengan critical path Suspense boundary replacement. Pendekatan baru ini lebih simpel dirawat, ngirim lebih sedikit byte, dan mencegah kemungkinan flash visual akibat dehydrate/rehydrate HTML.

Aku sendiri masih kaget karena ternyata cuma butuh 2 hari buat bikin solusi SSG kustom. Tapi kadang solusi yang paling tepat memang yang paling simpel.

Ke depannya, masih ada pekerjaan buat menyempurnakan kecocokan hydration dan mungkin nge-patch React supaya debugging lebih enak. Tapi untuk sekarang, Foony sudah punya SSG yang jalan. Aku bakal mantau Google Search Console dan Bing Webmaster Tools beberapa minggu ke depan buat lihat efeknya ke SEO kami.

8 Ball Pool online multiplayer billiards icon