How to improve SEO for a client-side React + Vite app with vite-react-ssg
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:
<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:
// 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:
// 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-pagebecause 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 thecreateRootfunction I export. I never callcreateRoot().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, nowindowhere, 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 withclass="dark"baked intoindex.htmlrather than set by JS, which also avoids a flash of the wrong theme.)
The build script
The magic is in one npm script:
// package.json
{
"scripts": {
"build": "tsc -b && vite-react-ssg build"
}
}Two steps run in order:
tsc -b— type-check and build with TypeScript. On my project there’s no separate linter step in the pipeline, sotscis the correctness gate; if types are wrong, the build stops here.vite-react-ssg build— this is the prerender step. It runs Vite’s build and renders the app in Node, writing the full HTML intodist/index.html.
The rest of the Vite config is completely ordinary — just the React and Tailwind plugins:
// 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.ts — vite-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-pageyou exportcreateRoot = ViteReactSSG(<App />)instead of callingcreateRoot().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.