background blurbackground mobile blur

1/1/1970

How I Implemented SSG in 2 Days

Howdy! A year ago, I thought this was impossible. But I just finished implementing Static Site Generation (SSG) for Foony in 2 days, and I'm pretty excited about it. This isn't my first time trying to solve SSG for Foony. either. I've looked at NextJS, Vike, Astro, Gatsby, and a few other solutions in the past. I even had a false start with NextJS, but ran into difficulties with the complexity of Foony's SPA and thousands of files. The migration would've been a nightmare and would've taken months. It also would've added additional complexity for everyone else working on the site because they'd have to learn NextJS and its quirks.

I wanted something light and easy to implement. Something that would let us continue writing code the same way we've been writing it without having to think about SSG (with the exception of useMediaQuery--no real way around that one). Below I'll break down why I went with a bespoke solution, the specific challenges I ran into (especially with React's Suspense boundaries), and how I solved them.

Why Not Standard Solutions?

When I first looked at adding SSG to Foony, I naturally considered NextJS (industry standard), Vike, and Astro.

NextJS: Too Much Migration

NextJS is powerful, but it would've required a massive migration of Foony's existing React SPA. We have thousands of files, complex routing logic, and a lot of custom infrastructure. Migrating to NextJS would've meant:

  • Rewriting our entire routing system
  • Restructuring how we load games and components
  • Months of work just to get back to feature parity
  • Potential breaking changes for users
  • Changing the way we handle images
  • Significantly slower build times (potentially 5-30 minutes. I don't have concrete numbers to back this up other than this 5-year-old discussion on GitHub)
  • The entire team learning something new (NextJS), and slower developer velocity forever
  • Migrating the code every time NextJS decides to make breaking changes.

I even tried a false start with NextJS, but quickly realized the migration cost was too high. The complexity wasn't worth it.

Vike: Similar Complexity

Vike (formerly vite-plugin-ssr) had similar issues. While it's more flexible than NextJS, it still would've required significant restructuring of our codebase. The learning curve and migration effort didn't justify the benefits.

Astro: Wrong Architecture

Astro is great for content-heavy sites, but Foony is a complex multiplayer game platform. We need real-time updates, WebSocket connections, and dynamic React components. Astro's architecture just doesn't fit what we're building.

The Solution: Bespoke SSG

Emboldened by my "fake SSG" approach I implemented a few days ago after i18n, I settled on a small, lightweight, bespoke solution for Foony's SSG.

My "fake SSG" approach involved pulling the blog post content from pages with blog posts (/posts routes and game pages), and positioning them exactly where the client would render them, specifically for search engines and LLMs to help understand Foony. It also applied ld+json schema and some small SEO stuff.

The approach is simple:

  1. Build on top of existing React SPA: No migration needed, just add SSG generation at build time.
  2. Use renderToReadableStream: React 18's streaming SSR API handles Suspense natively.
  3. Generate static HTML files: Pre-render routes at build time and serve them as static files, using our SitemapGenerator to get a list of routes.
  4. Minimal changes to existing codebase: Most components work as-is.

The core implementation lives in client/src/generators/GenerateShellSsgFromSitemap.ts. It reads a sitemap, renders each route using React's renderToReadableStream, and writes the HTML to static files. Simple, just the way I like it!

This ended up being pretty fast, too. About 2,800 routes rendered in 10 seconds. Nice. That's significantly faster than NextJS, Gatsby, and Astro. <img alt="SSG console log showing time taken" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

I could go on and on about simplicity. Even if it won't get you a promotion at large companies due to "lack of complexity", simple code is beautiful, maintainable, and is in general much better for developer velocity. This is something I really admire about Zen principles.

The Suspense Boundary Problem

So now I had SSG, and the content showed up in HTML... but my pages were blank! How?! <img alt="SSG blank page" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

It turns out that renderToReadableStream still has Suspense boundaries, even if you await stream.allReady. My guess is that this is because it's a "stream", and designed to be passed to clients as bytes are received.

What React Outputs

When you use renderToReadableStream with Suspense, React outputs HTML like this:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>

The <template id="B:0"> is a placeholder where content should go. The <div hidden id="S:0"> contains the actual rendered content. The B:0 matches S:0 by number (0-based index).

Without JavaScript, search engines (looking at you, Bing) and LLMs would see a nearly-blank page with just the template placeholder. That defeats the entire purpose of SSG!

I didn't see any clean way to remove these Suspense boundaries, so my solution was to write some tests and a resolveSuspenseBoundaries function to swap these out. This was faster than parsing the HTML and executing the script with something like JSDOM. And, more importantly, it was a requirement for what I had planned: a nice, readable site for search engines / LLMs without JavaScript, but with support for Suspense boundaries and hydration on the client.

Testing the Transformation

I started by writing tests for the transformation by grabbing some examples in the DOM from what I had (JavaScript disabled), and what I wanted (JavaScript enabled). I fed these into an LLM and had it handle the test generation, something it's pretty good at. These tests live in client/src/generators/ssr/renderRoute.test.ts and ensure the transformation works correctly. The tests cover:

  • Simple boundary replacement (blog listing)
  • Complex boundaries with content between template and closing comment
  • Multiple boundaries
  • Boundaries without comment markers
  • Edge cases

This type of "TDD" is actually quite useful for this use case where you have expected inputs and outputs.

This is not to be confused with "TDD everything because Robert C. Martin said so" (which will slow your team's development velocity). You should NOT be using TDD for UI or areas of your code that are constantly changing!

The Solution: resolveSuspenseBoundaries

Now that the tests were in place, I had the LLM write the function for resolveSuspenseBoundaries. I went with cheerio for this to avoid the brittleness of RegEx, even though using RegEx here would cut SSG time by about 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}; }

This ensures that instead of seeing a nearly-blank page, search engines and LLMs see a fully-rendered page.

Now we've got SSG working well without JavaScript! <img alt="No 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" }} />

Long-term, it's possible that React will change their Suspense format. I might remove the Suspense resolution code once I have a better solution for the pages that are lazy-loaded (and thus require Suspense boundaries).

Hydration Strategy (Update: This Took 3 Days + 1 Extra Day)

Hydration is challenging. I knew that. But, after a bit of work, I managed to get it working!

Total time taken for hydration: 3 days, plus 1 extra day to replace the dehydration approach.

The trickiest part was just getting that first minimal, working hydrate. Once I managed to render a "Hello World" with the navbar, I gained the confidence that, yes, this might not take a whole month!

<img alt="Foony's Hello World hydrating successfully with 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" }} />

For that first minimal, working hydrate, I had a unique challenge: I wanted hydration, but I also wanted good SEO for for search engines and LLMs without developers needing to think about Suspense boundaries.

The Challenge

React hydration is extremely literal: if the DOM doesn’t look like what React expects for that first render, you get this nice, near-useless error message in your console, and React throws everything out and re-renders from scratch. Not even a diff to let you know what went wrong!

In our case, SSG made this worse in a couple of ways:

  1. We post-processed the HTML to remove/resolve React 18 streaming Suspense artifacts (which is great for bots).
  2. The client didn’t always have the exact same data available at time (t = 0) as the server render did (SSG data, blog metadata, etc).
  3. Our i18n is “lazy” by default, which means translations can be missing for the first render unless you record which translations were used for SSG and inject them before React renders.

What Worked (Initial Approach: Dehydration)

At first, I tried something clever and cute: I used a command pattern to record the commands used to resolve the HTML's Suspense boundaries, and returned the reverse transformation commands so I could restore the HTML to what React needs for hydration. My hope was that I could ship way less bytes in index.html with this command method. But, as with most clever solutions, this failed because browsers modify the HTML in subtle ways, such as removing or adding a ; or /, which threw off the replacement indices. Technically you could probably account for these subtle browser changes, but I wasn't about to ship something so brittle. Instead of trying to "reverse" the Suspense-boundary transformation back into React's streaming markup, I did something super simple:

Bundle the original, unresolved HTML in a <script type="text">.

This "dehydration" approach worked, but I spent an extra day replacing it with a better solution.

The Better Approach: Critical Path Suspense Boundary Replacement

After the initial implementation, I was still running into some issues with Suspense boundaries. That's when I realized there was a cleaner, better, simpler solution. I replaced the dehydration approach with critical path Suspense boundary replacement, which:

  • Loads the critical path before hydration: Components that were preloaded during SSR are identified and preloaded on the client before hydrateRoot is called
  • Is simpler to maintain: No React internals or AST parsing required (the dehydration approach needed to parse and restore HTML)
  • Ships less bytes: We no longer bundle the original SSR response from React in a script tag
  • Prevents a potential flash: No need to dehydrate/rehydrate HTML, eliminating a potential visual flash

The implementation tracks which lazy components were preloaded during SSR (via SSRLazyComponentTracker), includes their import paths in the hydration data, and preloads them synchronously before hydration. Critical path components render directly without Suspense boundaries, matching the SSR output exactly.

For everything else, we make the first client render act as SSR/SSG. That means using the same inputs, and making those inputs available synchronously before hydrateRoot. This is done by bundling via our "ssg-data".

Concretely, the adjustments were:

  1. Bundle SSR inputs into a single text script

    • During SSG, we inject a <script type="text/foony-ssg" id="foony-ssg-data">...</script> right before the Vite module entrypoint.
    • That script contains:
      • html: the resolved HTML we actually shipped in the static file
      • ssgData: the serialized SSGData used by the SSR wrapper. I plan on updating this to a Proxy or something so only accessed data is included.
      • translationData: the translation key-value blobs we touched during SSR
  2. Inject those inputs just before hydration

    • In main.tsx, we synchronously:
      • set #root.innerHTML to the serialized resolved HTML (so the DOM is exactly what hydration sees)
      • wrap the app in SSGDataProvider so components have the same SSGData on first render
  3. Make i18n instant by injecting translation values

    • We record the actual translation objects accessed during SSR and ship them in the SSG script.
    • On the client, we inject them straight into LocaleQueryer’s cache via a dedicated LocaleQueryer.inject() method, so translations are available immediately.

And with that, the first render has the same data that SSR had!

The useIsSSRMode() hook is already implemented 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;
}

This hook returns true during SSR and on the first client render (hydration), then switches to false after mount. Components like UserBanner, Navbar, and Dialog already use this to prevent hydration mismatches.

  1. Patch React for better diffs

I was hoping I could just use hydration-overlay. But it's not actively maintained, only supported up to React 18, and wasn't production-ready. So I had an LLM clone the repo for inspiration, and then it created a minimal hydration overlay in a few minutes. I didn't need anything fancy--just something that'd show up during development so I could figure out where things went wrong.

This new overlay is super basic, so the diffs aren't quite perfect. React strips comments, adds ;s after style attributes, modifies whitespace, and a few other small things which our overlay doesn't account for (yet). Our overlay also includes HTML comments which React ignores for its hydration.

<img alt="Our new 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" }} />

But it's good enough to figure out what needs fixing.

<img alt="diff of our SSG vs client first-page render for 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" }} />

By the Numbers

To give you a sense of what this implementation involved:

  • 2 days of work (from start to working SSG). This was just over 24 hours while on holiday.
  • 4 days of work to get hydration behaving nicely without async translation races or useMediaQuery messing things up.
  • 1 extra day to replace the dehydration approach with critical path Suspense boundary replacement (simpler, less bytes, no potential flash).
  • ~200 lines of core SSG generation code (GenerateShellSsgFromSitemap.ts)
  • ~120 lines of Suspense boundary resolution (resolveSuspenseBoundaries in renderRoute.tsx) - Note: This was later replaced by the critical path approach
  • ~50 lines of SSR utilities (isSSRMode.ts)
  • ~100 lines of tests (renderRoute.test.ts)
  • ~150 lines of polyfills for SSR (setupSSREnvironment)
  • Minimal changes to existing components (mostly adding useIsSSRMode() checks)

The solution is lightweight and maintainable. It doesn't require a framework migration, and it works with our existing React SPA.

Key Takeaways

Sometimes a Bespoke Solution is Better

Not every problem needs a framework. For Foony, a small, bespoke SSG solution was the right choice. It's:

  • Lightweight: No heavy dependencies or framework overhead
  • Maintainable: Simple code that we understand
  • Flexible: Easy to modify and extend as needed
  • Compatible: Works with our existing React SPA without migration

React's Streaming SSR Has Quirks

React's renderToReadableStream is nice for dealing with Suspense, but it has quirks. Even with await stream.allReady, you still get Suspense boundaries in the output. This isn't a bug—it's by design for streaming. But for SSG, we need fully-resolved HTML. It feels like a failure by the React team to not handle this scenario in a clean way.

My solution was to post-process the HTML and resolve boundaries. It's not pretty, but it's fast and flexible enough for my use case.

TDD Can Be Useful For LLMs

HTML transformation is error-prone. One small bug and you could break the entire SSG output and break the end-user experience. I had an LLM write comprehensive tests (with my input) to ensure the transformation works correctly.

Conclusion

SSG is now working for Foony. Pages are fully rendered for search engines and LLMs, and the solution is maintainable and lightweight. Hydration for the SSG routes took longer than I expected (3 days), and I spent an extra day replacing the initial dehydration approach with critical path Suspense boundary replacement. The new approach is simpler to maintain, ships fewer bytes, and prevents potential visual flashes from dehydrating/rehydrating HTML.

I'm still shocked that it only took 2 days to implement a bespoke solution for SSG. But sometimes the right solution is the simplest one.

Future work includes completing the hydration matching and potentially patching React for better debugging. But for now, Foony has working SSG. I'll be keeping an eye on Google Search Console and Bing Webmaster Tools over the coming weeks to see what effect this has on our SEO.

8 Ball Pool online multiplayer billiards icon