A Vite or Create React App build ships an index.html whose body is one empty
element:
<div id="root"></div>
Everything a visitor reads is drawn by JavaScript after that. A browser runs it. A crawler, on its first pass, does not — and what it stores is the empty div.
Google does render JavaScript, eventually. Eventually is the problem: rendering is a second queue with its own budget, and a new domain with no authority waits in it. Bing renders far less. The crawlers behind AI answers — GPTBot, ClaudeBot, PerplexityBot — mostly do not render at all. For them your site has no content, and never will.
Check your own site in one command
Ask for your homepage the way Googlebot does, strip the tags, and count what is left in the body:
curl -sS -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://example.com/ \
| python3 -c "
import sys, re
h = sys.stdin.read()
b = re.search(r'<body[^>]*>(.*)</body>', h, re.S).group(1)
b = re.sub(r'<script.*?</script>|<style.*?</style>', '', b, flags=re.S)
print(len(re.sub(r'<[^>]+>', ' ', b).split()))"
Run it against every address in your sitemap, not just the homepage. On two sites in production, measured before changing anything, that number was zero on every one of them: 62 addresses on this site, 8 on a document-services site we also run.
The <head> on both was excellent — titles, descriptions, Open Graph, JSON-LD,
all carefully written. All of it describing a page that, to the crawler, had no
content.
What the fix is not
The usual advice is to move to Next.js. That means rebuilding routing, data loading and deployment to solve a problem that lives entirely in the build step. If the application already works, the migration is a large risk for a small cause.
What follows is about forty lines that run after vite build: render each route
once with a headless browser, over the build you are about to ship, and write
the result to disk.
The build step
import { createServer } from 'node:http';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';
const execFileP = promisify(execFile);
const DIST = path.resolve('dist');
const ROUTES = ['/', '/pricing', '/about', '/contact'];
// A static server over dist, with the same SPA fallback nginx will use.
function serveDist(port) {
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
for (const candidate of [
path.join(DIST, url.pathname),
path.join(DIST, url.pathname, 'index.html'),
path.join(DIST, 'index.html'),
]) {
try {
res.writeHead(200);
return res.end(await readFile(candidate));
} catch { /* try the next candidate */ }
}
});
return new Promise((ok) => server.listen(port, '127.0.0.1', () => ok(server)));
}
const server = await serveDist(4318);
const origin = 'http://127.0.0.1:4318';
for (const route of ROUTES) {
const { stdout: html } = await execFileP(CHROME, [
'--headless', '--disable-gpu', '--no-sandbox',
'--virtual-time-budget=8000',
'--run-all-compositor-stages-before-draw',
'--dump-dom', origin + route,
], { maxBuffer: 64 * 1024 * 1024 });
const dir = route === '/' ? DIST : path.join(DIST, route);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'index.html'), html.split(origin).join(SITE));
}
server.close();
Three details are load-bearing.
Render over the built output, not the dev server. The dev server hands out
untranspiled modules and a different index.html. What it renders is not what
you deploy.
--virtual-time-budget, not a fixed sleep. It advances the page's clock as
fast as work completes, so the render finishes when the app settles rather than
after an arbitrary wait — and the budget is a ceiling, not a delay.
Replace the render origin in the output. Every absolute URL the app emitted
points at http://127.0.0.1:4318. Ship that and each page declares localhost as
its canonical address, which is the most efficient way to not be indexed at all.
Then make an empty route fail the build:
const words = html.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
if (words < 80) {
console.error(` ${route} rendered ${words} words`);
process.exit(1);
}
The failure this guards against is silent. A route that renders nothing still writes a file, and the file still deploys.
Two failures that never show up in a build
Both of these passed type checking, linting and tests. Both were visible only in the output.
Language set from an effect arrives too late
The application read its language from localStorage and switched it on mount:
useEffect(() => {
if (i18n.language !== lang) i18n.changeLanguage(lang);
document.documentElement.lang = lang;
}, [lang, i18n]);
In a browser this is invisible. The effect runs, React re-renders, the visitor sees the right language one frame later.
The static render takes one snapshot. The dump caught the markup after
document.documentElement.lang had been set and before the re-render that
carried the translated strings. Every page under /ru/ shipped with Russian in
the lang attribute and Romanian in the body — the exact contradiction a search
engine reads as a quality signal against you.
The tell was in the word counts, not in the pages. The Russian routes had identical counts to the Romanian ones: 457, 110, 248, 406. Two languages do not produce the same number four times.
The fix is to resolve the language before React renders at all:
const initialLang =
/^\/ru(\/|$)/.test(window.location.pathname) ? 'ru' : 'ro';
i18n.init({ lng: initialLang /* … */ });
Which also removes a flash of the wrong language for real visitors, so it was worth doing regardless.
One <head> for every page
index.html has one head, and it is served for every address. So the
<link rel="canonical"> in it says the same thing everywhere — usually the
homepage.
This is not a missing optimisation. A canonical pointing somewhere else is a page telling Google it is a duplicate and should not be indexed separately. Eight pages, eight declarations that they are copies of the root.
Titles are the same problem more slowly: four pages sharing one title compete with each other for the same query, and none of them wins.
The prerender step is the right place to fix it, because it already has the file open:
const META = {
'/': ['Home — Example', 'What the company does, in one sentence.'],
'/pricing': ['Pricing — Example', 'What it costs and what changes the number.'],
};
function rewriteHead(html, route) {
const [title, description] = META[route];
const url = SITE + route;
return html
.replace(/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`)
.replace(/<meta name="description" content="[^"]*"/,
`<meta name="description" content="${description}"`)
.replace(/<link rel="canonical" href="[^"]*"/,
`<link rel="canonical" href="${url}"`);
}
If the site has more than a handful of pages, generate that table from whatever already holds the route list rather than typing it twice.
Serving the files
With nginx, the usual SPA configuration is:
location / {
try_files $uri $uri/ /index.html;
}
Once every real route is a file on disk, that last fallback has no legitimate
user left. Only wrong addresses reach it — and they get 200 with the full
homepage. Every typo, every dead link, every scanner probing for
/.env.production receives a complete page and a success status. Google calls
these soft 404s and treats them as an unbounded set of duplicates.
error_page 404 /404.html;
location / {
try_files $uri $uri/ =404;
}
One caveat: $uri/ makes nginx redirect /pricing to /pricing/ when the
directory exists. Either write your canonical URLs with the trailing slash, or
emit flat pricing.html files and add $uri.html to try_files. What does not
work is a canonical pointing at an address that redirects somewhere else.
What happened afterwards
Both sites went from zero to real content: 1201 words on this homepage, 2334 across the 8 addresses of the other. The measurable change was in the server logs, counted by user agent:
| crawler | before | after |
|---|---|---|
| Googlebot | 1–5 requests a day | 167, then 451 |
| GPTBot | 1–3 a day | 208 in one day |
| bingbot | 0–5 a day | 24, 17, 11, 32 |
| YandexBot | 0 | 84 |
Four crawlers that had never appeared started arriving within a week: PerplexityBot, Applebot, DuckDuckBot, Amazonbot. Over twelve days Googlebot fetched 1008 times and reached 62 distinct pages; GPTBot reached 87.
ChatGPT-User — the agent that fetches a page while someone is waiting for an
answer in ChatGPT — went from never to 33 requests in a day.
What this does not fix
Indexing. Two weeks after that surge, site: on the domain still returned
nothing, on two different search indexes.
That is not a contradiction, and it is worth stating plainly because most articles on this stop one section earlier. Prerendering removes the technical reason a crawler cannot read you. It does not give a search engine a reason to spend index space on you. That reason is authority — links from pages that are themselves indexed — and it is a separate problem with a slower fix.
The right way to read the numbers above is: the door is now open. Whether anyone walks through it is decided elsewhere.