ehsan.blog
~/blog/how-to-prerender-a-react-vite-app-with-vite-react-ssg — zsh
cat how-to-prerender-a-react-vite-app-with-vite-react-ssg.md

How to improve SEO for a client-side React + Vite app with vite-react-ssg

·5 min read

If you open a standard React + Vite app and view the raw page source, you’ll see something like <div id="root"></div> and nothing else. All the real content is painted in by JavaScript after the page loads. That’s fine for a browser — but it’s a problem for SEO. Here’s how I fixed it for my portfolio by prerendering the whole page at build time with vite-react-ssg, without rewriting the app.

Why an empty <div id="root"> is bad for SEO

When a crawler or a link-preview bot fetches your page, it gets the raw HTML that the server sends before any JavaScript runs. For a plain client-side React app, that HTML is basically empty:

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

There is no heading, no copy, no links — nothing describing what the page is about. Search engines and social scrapers may not run your JavaScript (or may run it inconsistently), so they can end up indexing a blank page. For a portfolio whose entire job is to be found, that’s a dealbreaker.

What SSG / prerendering actually is

Prerendering (a form of Static Site Generation, or SSG) means: run your React app once at build time, in Node, and save the resulting HTML to a file. The browser then receives a fully-formed HTML page with all the content already in it. The crawler sees real text immediately.

vite-react-ssg does exactly this for a Vite + React app. It renders your React tree in Node during vite build and bakes the output into dist/index.html.

flowchart LR
  A[Build time in Node] --> B[Render React tree once]
  B --> C[Write full HTML into dist/index.html]
  C --> D[Browser gets real content instantly]
  D --> E[JS loads and hydrates the same tree]

The single-page entry: no createRoot().render()

Here’s the key change. A normal Vite React app boots like this:

tsx
// the usual client-only entry — replaced below
import { createRoot } from "react-dom/client"
createRoot(document.getElementById("root")!).render(<App />)

That calls document.getElementById at module load — which can’t work in Node, where there is no document. With vite-react-ssg you instead export a createRoot that the framework calls for you, in whichever environment it’s running (Node at build, browser at runtime). My actual entry:

tsx
// src/main.tsx
import { ViteReactSSG } from "vite-react-ssg/single-page"
import App from "./App.tsx"
import { ErrorBoundary } from "./components/shared/error-boundary.tsx"
import "@fontsource-variable/jetbrains-mono"
import "./index.css"

// Prerendered at build time and hydrated on the client by vite-react-ssg.
export const createRoot = ViteReactSSG(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>,
)

A few things to notice:

  • I import from vite-react-ssg/single-page because this is one page, not a multi-route site. (There’s a routed variant too, but a portfolio is a single page.)
  • ViteReactSSG(...) wraps my app and returns the createRoot function I export. I never call createRoot().render() myself — the framework owns that and decides whether to render to a string (build) or hydrate (client).
  • The entry must stay browser-free. There’s no document, no window here, because this module executes in Node during the build. This is a rule that runs through the whole codebase — see keeping React code SSG-safe by avoiding browser globals for the full pattern. (Dark mode, for example, is applied with class="dark" baked into index.html rather than set by JS, which also avoids a flash of the wrong theme.)

The build script

The magic is in one npm script:

json
// package.json
{
  "scripts": {
    "build": "tsc -b && vite-react-ssg build"
  }
}

Two steps run in order:

  1. tsc -b — type-check and build with TypeScript. On my project there’s no separate linter step in the pipeline, so tsc is the correctness gate; if types are wrong, the build stops here.
  2. vite-react-ssg build — this is the prerender step. It runs Vite’s build and renders the app in Node, writing the full HTML into dist/index.html.

The rest of the Vite config is completely ordinary — just the React and Tailwind plugins:

ts
// vite.config.ts
export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
})

There’s no special SSG plugin to register in vite.config.tsvite-react-ssg drives the build through its own CLI command instead.

Hydration: how the client takes over

So the browser now receives a fully-rendered HTML page. But it’s static — no click handlers, no state. Hydration is the step where React boots on the client, attaches to the existing server-rendered DOM, and wires up all the interactivity without throwing the HTML away and re-rendering from scratch.

vite-react-ssg handles this automatically using the createRoot you exported. The important rule: your first client render must produce the same markup the server produced, or React warns about a hydration mismatch. In practice that means any content depending on window, localStorage, or the current time must be handled carefully (deferred to effects), so the initial render is identical in both environments. This shapes several patterns on the site: making typing animations SSG-safe, keeping tabbed UI crawlable with sr-only mirrors, and even an SSR-safe error boundary all follow from this same rule.

What to remember

  • A plain React + Vite app ships an empty <div id="root"> — crawlers see nothing.
  • Prerendering runs your app in Node at build time and bakes real HTML into dist/index.html.
  • With vite-react-ssg/single-page you export createRoot = ViteReactSSG(<App />) instead of calling createRoot().render().
  • The build is just tsc -b && vite-react-ssg build — no extra Vite plugin needed.
  • Keep the entry browser-free, and make sure the first client render matches the server so hydration is clean.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.