Vai al contenuto

SSG for React with Vite

Pubblicato:

Single Brief Introduction (SBI)

Whenever I have to write a quick and dirty Single Page Application (SPA), I usually default to React and Vite as my go-to tools. To be honest, sometimes I turn to React even when a simple vanilla HTML+CSS+JS solution would be enough because “you never know when you might need to add a lot of interactivity later on to handle all the features that your enthusiastic users demand” (it has never happened). SPAs are convenient and battle tested, but I love the idea of avoiding unnecessary computations and giving both the browser and the search engine (if the project ever becomes relevant enough to require some SEO effort) all HTML they may need for the first render.
Normally, since react takes care of creating the DOM for you with the createRoot function, the HTML document served to the client is basically empty except for the root element and the script that will add all other elements once the JavaScript is loaded and executed.

<!-- index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Title</title>
  </head>
  <body>
    <!-- From this "root" div React will generate 
         all other elements to render the page. -->
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
// main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    {/* Usually this is an App component that contains your application */}
    <div>Hello, world!</div>
  </StrictMode>
);

A build tool like Vite will then bundle your code, transpiling it if necessary, correcting all the imports and preparing a folder with all the files that you can serve to your users. In all of this, the HTML file remains mostly unchanged.

Who renders what?

I’m far from the first person to think of a way to push React past the SPA paradigm while still using it as the main source of interactivity for the website. Glossing over some details and nuances, when it comes to SPA rendering there are three main approaches:

  • Client Side Rendering (CSR): this is what I described so far. The browser receives a mostly empty HTML document and React takes care of creating the DOM and rendering the page.
  • Server Side Rendering (SSR): the server receives a request for a page, runs React on the server to generate the HTML for that page and sends it back to the client. The browser receives a fully rendered HTML document and React takes care of adding interactivity to it.
  • Static Site Generation (SSG): the HTML for each page is generated at build time, and the resulting static files are served to the client. The browser receives a fully rendered HTML document and React takes care of adding interactivity to it.

As with most things in software engineering, there is no silver bullet, and the correct solution depends on the specific use case.

CSRSSRSSG
Client loadHighLowLow
Server loadLowHighLow
Can be served staticallyYesNoYes
Time to first renderHighLowLow
SEO friendlinessLowHighHigh
Extensive user personalization costHighLowHigh

Note

By “Can be served statically” I mean that you only need a static file server to serve the files, without any backend logic. Examples of static file servers are GitHub Pages, GitLab Pages, nginx, Apache, and S3 buckets. None of these are meant to work with SSR, but they can work with both CSR and SSG.

Note

By “Extensive user personalization cost” I am referring to complex web applications that need to tailor the content to each user. For example, a social media platform or an e-commerce website with a recommendation system. In these cases, you may need to run complex business logic on a large amount of user data to generate the appropriate response, not mentioning the security and privacy concerns that come with it.

Based on the table above, SSR serves a specific set of use cases despite the added complexity. For many applications, however, SSG offers most of the benefits of SSR while retaining the simplicity of static hosting. This raises an interesting question: if SSG is often better, why is it not the default?

A few lines to make hydration the default choice

The main reason why we still rely on IPv4 instead of IPv6 mostly comes from the attrition of changing what is the default configuration for most systems, even if the benefits are clear and all the major players in the field already support it fully. In other words, never underestimate the power of inertia. I would argue that a similar situation happens every time we create a new React project: the default configuration is to use CSR and, unless we somehow run into some limitations of this approach, we will keep using it without second thoughts.
But making the switch is actually very easy.

I am going to assume that you have already created a React project with Vite, and the dependencies in the package.json file look something like this:

// package.json
{
  "name": "my-spa",
  "version": "0.0.1",
  "type": "module",
  "scripts": {
    "dev": "vite --port=3000 --host=0.0.0.0",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@vitejs/plugin-react": "^5.2.0",
    "dotenv": "^17.4.2",
    "react": "^19.2.8",
    "react-dom": "^19.2.8",
    "vite": "^6.4.3",
    "typescript": "~5.8.3",
    "tsx": "^4.23.12"
  }
}

Then, your main.tsx and index.html files should be similar to the ones I showed in the previous section.

What we need to do is pre-render the HTML for the page at build time, and then let React hydrate the page on the client side to add interactivity. We can do this by creating a simple build script.

// build.tsx
import { renderToString } from "react-dom/server";
import App from "../src/App";

class LocalStorageMock {
  private store: Map<string, string> = new Map();
  getItem(key: string) {
    return this.store.get(key) ?? null;
  }
  setItem(key: string, value: string) {
    this.store.set(key, value.toString());
  }
  removeItem(key: string) {
    this.store.delete(key);
  }
  clear() {
    this.store.clear();
  }
}

export default function renderApp() {
  console.log("Starting server-side rendering...");
  // Mock localStorage for server-side rendering
  (global as any).localStorage = new LocalStorageMock();
  // Pre-render the app to string for initial HTML
  return renderToString(<App />);
}

Here, we are using the renderToString function from the react-dom/server package to render the App component to a string. The output is then inserted into the index.html file, inside the root div. We just need to make the following changes to the index.html file:

<!-- ... -->
<body>
  <div id="root"><!-- ReactApp --></div>
  <script type="module" src="/src/main.tsx"></script>
</body>
<!-- ... -->

Then, we create a Vite plugin on the fly to ensure that the <!-- ReactApp --> placeholder is replaced with the pre-rendered HTML string during the build process. Since we only want to pre-render the HTML in production mode, we check the NODE_ENV environment variable.

// vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import renderApp from "./build";

export default defineConfig(() => {
  return {
    plugins: [
      react(),
      {
        name: "ssg-rendering",
        transformIndexHtml: {
          handler(html: string) {
            // We only want to pre-render the HTML in production,
            // so we check the NODE_ENV environment variable.
            return process.env.NODE_ENV === "production"
              ? html.replace("<!-- ReactApp -->", renderApp())
              : html;
          },
        },
      },
    ],
  };
});

Finally, we need to update the main.tsx file to use the hydrateRoot function instead of createRoot when the NODE_ENV environment variable is set to production. This way, React will attach event listeners to the existing HTML elements instead of creating new ones.

// src/main.tsx
import { createRoot, hydrateRoot } from "react-dom/client";
import App from "./App.tsx";

if (process.env.NODE_ENV === "production") {
  hydrateRoot(document.getElementById("root")!, <App />);
} else {
  createRoot(document.getElementById("root")!).render(<App />);
}

Important

The pre-rendered HTML and the virtual DOM generated by React must match exactly, otherwise React will throw a warning and re-render the entire page. There are multiple pitfalls that can cause issues, like

  • Extra whitespace (like newlines) around the React-generated HTML inside the root node.
  • Using checks like typeof window !== ‘undefined’ in your rendering logic.
  • Using browser-only APIs like window.matchMedia in your rendering logic.
  • Rendering different data on the server and the client.

If you rely on any of these, you may need to refactor your code to avoid them or stick to CSR.

Tip

Make sure you write

<div id="root"><!-- ReactApp --></div>

in the index.html file, and not

<div id="root">
  <!-- ReactApp -->
</div>

since React is convinced those are completely different DOM structures.

Examples

I have used this approach for the Emilib project. Feel free to use it as a reference for creating your own SSG solution with React and Vite.

Conclusion

In my experience, there are relatively few cases where CSR is preferable to SSG. Applications whose initial content depends heavily on authentication state, user preferences, or other client-specific data can still benefit from CSR, but for many projects SSG provides a compelling default: it reduces the amount of work needed during the initial render, improves SEO, supports static hosting, and retains the React development experience. If I’ve overlooked anything, feel free to reach out. I’d be happy to update this article based on feedback and alternative approaches.