URL: https://spot-the-difference.peterbe.com/about

Reminder;
SSG - Static Site Generation
SPA - Single Page App

I have a lovingly simple web app that is an SPA. It's built with Vite, React, and React Router. In essence, it looks like this:


// src/main.tsx

import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import AppRoutes from "./AppRoutes";

const root = document.getElementById("root");

createRoot(root).render(
  <BrowserRouter>
    <AppRoutes />
  </BrowserRouter>
);

(full code GitHub)

and:


// src/AppRoutes.tsx

import { Route, Routes } from "react-router";
import { About } from "./About";
import { App } from "./App";
import { Layout } from "./Layout";

export default function AppRoutes() {
  return (
    <Routes>
      <Route element={<Layout />}>
        <Route path="/" element={<App />} />
        <Route path="/about" element={<About />} />
      </Route>
    </Routes>
  );
}

(full code on GitHub)

When you run bun run build, Vite will transpile all the TSX and create the JS bundles. It will also take the src/index.html and inject the script and link tags like this:


<!doctype html>
<html lang="en">
  <head>
    <!-- Other HTML header stuff here like favicon and title -->
    <script type="module" crossorigin src="/assets/index-BFdX_h7v.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-CubzMkJz.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

Now, you can upload that dist/index.html (along with all static assets in dist/) to your CDN (Firebase in my case). If someone requests https://spot-the-difference.peterbe.com/about, Firebase is configured to serve the dist/index.html file (by default), and that loads the React and react-router JS assets, which ultimately take care of rendering the <About> component on that page.

Pretty standard stuff.

SSG for faster initial load

The "problem" with this, and any SPA alike, is that when you first load this page, it starts off empty (the body only has a <div id="root"></div> after all) until the CSS and JS files have been downloaded, parsed and executed. Why not have the HTML rendered, as part of the build, as the DOM is going to become after the JS has executed?

Before we go into the details how you can accomplish the SSG, let's look at the perceived web performance. To demonstrate this I made an extra route in React Router called /about-csr. It's using the same About.tsx component but it starts from the global dist/index.html.
You can compare for yourself by visiting:

  1. https://spot-the-difference.peterbe.com/about-csr
  2. https://spot-the-difference.peterbe.com/about

I put these two pages into WebPageTest.org:

WebPageTest Visual Comparison

And the Visual Progress:

Visual Progress

The script looks like this:


// src/scripts/generate-static-pages.ts

import { ABOUT, ROOT, STATS } from "../titles";
import { preRenderApp } from "./pre-render";

const PAGES = [
  ["/about", "dist/about.html", ABOUT],
];

async function main() {
  const templateHtml = await Bun.file("dist/index.html").text();

  for (const [path, dest, title] of PAGES) {
    const pageHtml = await preRenderApp(templateHtml, path, title);
    await Bun.file(dest).write(pageHtml);
  }
}

and:


// src/scripts/pre-render.tsx

import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router";
import { createServer } from "vite";

const vite = await createServer({
  server: { middlewareMode: true },
  appType: "custom",
});

const getAppRoutes = async () => {
  const { default: AppRoutes } = await vite.ssrLoadModule("/src/AppRoutes");
  return AppRoutes;
};

export const preRenderApp = async (
  html: string,
  path: string,
  title: string,
) => {
  const AppRoutes = await getAppRoutes();

  const reactHtml = renderToString(
    <StaticRouter location={path}>
      <AppRoutes />
    </StaticRouter>,
  );

  return html
    .replace("<!--ssg-outlet-->", reactHtml)
    .replace(/<title>.*<\/title>/, `<title>${title}</title>`);
};

It's executed from bun run build in package.json like this:


"scripts": {
  "dev": "bunx --bun vite",
  "build": "tsc -b && bunx --bun vite build && bun run ssg",
  "ssg": "bun run src/scripts/generate-static-pages.ts",
  // ...more scripts...

I've omitted a bunch of details for the sake of the blog post. To read the full code see:

Important details

I'm certainly no Vite expert, so I'm not 100% sure this is the perfect technique but it works. The most important thing is that you use await vite.ssrLoadModule("/src/AppRoutes") and import { renderToString } from "react-dom/server";

If you use react-router to make a SPA, the important thing is that you have to have 2 different routers:

  1. import { StaticRouter } from "react-router"; for the SSG
  2. import { BrowserRouter } from "react-router"; for the client-side rendering

The other thing that is important is that you give vite.ssrLoadModule a URL-looking path. You say "/src/AppRoutes" and not "../src/AppRoutes.tsx".
And that src/AppRoutes.tsx needs to use a default export:


export default function AppRoutes() {
  ...
}

Hydration

In this simple setup, I'm not deliberately hydrating the first render. That means that once the React JS code renders, it will rewrite the DOM, which isn't ideal. That's what React hydration is supposed to solve. But it's not something I've tackled in this particular app.

Why not SSR?

Easy: it's more complex. A powerful pattern is to have a Node/Bun server-side render the HTML and accompany Cache-Control headers to be picked up by the CDN.

Firebase hosting

Firebase is known for its amazing real-time document database Firestore, but you don't have to use that. Firebase also have a hosting offering which competes with Netlify and similar services that gives you a CDN with the ability to configure custom headers.

When you set it up, it will generate a firebase.json file where you configure the hosting. Initially, it looks like this:


{
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ],
    "headers": [
      {
        "source": "/assets/**/*.*",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "public,max-age=315360000"
          }
        ]
      }
    ]
  }
}

That means it will automatically fall back to the dist/index.html file for any URL that isn't mapped to a file. The problem is that it will serve the dist/index.html file for the URL /about. But you can insert a config specifically for for /about -> ./dist/about.html using this:


  "rewrites": [
+   {
+     "source": "/about",
+     "destination": "/about.html"
+   },
    {
      "source": "**",
      "destination": "/index.html"
    }
  ],

(See full firebase.json here)

Conclusion

This solution gives you a solid CDN-backed hosting of your SPA, which means you don't need to worry about any servers. At the same time, you can achieve that additional initial load performance as demonstrated by the visual comparison above.

In this blog post and in this particular web app, I have not needed to worry about hydration or preloading the statically generated HTML with data. For local development, all you need is Vite's dev server with (near) instant hot module reloading. Then, with a small effort you can have some of the pages respond with HTML on first load and let the react router client-side navigation take over.

Comments

Your email will never ever be published.

Related posts