Prerendering a React + Vite app for SEO without Next.js
October 17, 2026 · 6 min read · react, vite, seo, bece-vault
I build most of my projects with React and Vite. It's quick to set up and it stays out of my way. But it has one weakness I didn't really understand until it bit me: to a search engine, every page of the site looks exactly the same.
I found out the hard way. Google Search Console listed several pages of this portfolio as "Crawled – currently not indexed". Google had visited them and decided they weren't worth adding. Here's why that happens, and how I fixed it on both this site and BECE Vault without moving to Next.js or running a server.
Why every page looks the same
When you run vite build, you get a single index.html, roughly like this:
<!doctype html>
<html>
<head>
<title>My Site</title>
<script type="module" src="/assets/index-a1b2c3.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
Your host serves that same file for every URL: /about, /projects, /blog/whatever. The browser downloads the JavaScript, React reads the URL and draws the right page. For a person clicking around, it's seamless.
For a crawler that reads the HTML first, though, every URL is identical. Same title. No description of its own. An empty <div id="root">. Google does run JavaScript eventually, but for a small new site, a pile of empty-looking pages that all share a title is easy to put at the bottom of the queue.
The fix: write the HTML at build time
I didn't need server-side rendering for this. Everything on a portfolio or a notes library is known when the site is built. So after vite build finishes, a script can:
- read the built
index.htmlas a template - for each route, fill in the right title, description, canonical URL and social-preview tags
- put readable content for that page inside
<div id="root"> - save it as its own file, like
dist/about.html
When someone opens the page, React mounts into #root and replaces that content with the real app, so visitors notice nothing. Crawlers, meanwhile, get real HTML with real text on every URL.
A small version of the script
I run it as a postbuild step, so it happens automatically after every build:
{
"scripts": {
"build": "vite build",
"postbuild": "node scripts/prerender-pages.mjs"
}
}
And a stripped-down version of the script:
// scripts/prerender-pages.mjs
import fs from 'node:fs';
import path from 'node:path';
const DIST = 'dist';
const SITE = 'https://www.example.com';
const template = fs.readFileSync(path.join(DIST, 'index.html'), 'utf8');
const pages = [
{ path: '/about', file: 'about.html', title: 'About | Me',
description: 'Who I am and what I build.', body: '<h1>About me</h1><p>…</p>' },
// …one entry per route, usually generated from the same data the app uses
];
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
for (const page of pages) {
let html = template
.replace(/<title>.*?<\/title>/, `<title>${esc(page.title)}</title>`)
.replace('</head>',
`<meta name="description" content="${esc(page.description)}" />\n` +
`<link rel="canonical" href="${SITE}${page.path}" />\n</head>`)
.replace('<div id="root"></div>', `<div id="root">${page.body}</div>`);
fs.writeFileSync(path.join(DIST, page.file), html);
}
The part that matters most is where the pages list comes from. Don't write it by hand. Build it from the same data files your app renders. On this site, the projects, certificates and blog posts all live in plain data or Markdown files, and the script imports them directly. That way the static HTML can't quietly drift away from what the live page shows. The blog posts even go through the same Markdown renderer the app uses.
Getting the host to serve those files
Writing about.html is half the job. The host still has to serve it when someone visits /about. On Vercel, that's a few lines in vercel.json:
{
"cleanUrls": true,
"trailingSlash": false,
"rewrites": [{ "source": "/(.*)", "destination": "/app.html" }]
}
cleanUrls makes Vercel serve about.html at /about. trailingSlash: false redirects /about/ to /about, so each page has exactly one address. The rewrite catches every URL that doesn't have its own file and serves app.html, which is just an untouched copy of the original template. React still handles those pages. They simply don't get prerendered content. Real files always win over rewrites, so the prerendered pages take priority automatically.
A few things that tripped me up
I used createRoot, not hydrateRoot. Hydration expects the HTML on the page to match what React would render, exactly. My static content is a simpler version of each page, so I let React replace it instead.
I stopped hiding the static content. My first idea was to hide it with CSS until React loaded, so it wouldn't flash on screen. Then I realised that text hidden from people but shown to crawlers is exactly what search engines are suspicious of. Now it stays visible, with a little styling, for the split second before the app takes over.
I marked the 404 page noindex. With a catch-all rewrite, a made-up URL returns the 404 page with a normal "200 OK" status, and Google can index those as junk. A <meta name="robots" content="noindex"> on that page sorts it out.
I generated the sitemap from the same list. The script that writes the pages also writes sitemap.xml, so every page is listed and nothing old is left behind.
Was it worth it?
For a site this size, yes. It's one script of a few hundred lines, no new dependencies, and no change to how I write the app. BECE Vault uses a Vite-plugin version of the same idea. It prerenders about 150 pages and writes a sitemap and an llms.txt file for AI crawlers too.
If your React app's pages aren't getting indexed, look at what your HTML says before any JavaScript runs. Open the page, pick View Page Source, and look inside <div id="root">. If it's empty, you've found your problem, and it's one you can fix in an afternoon.