coresmith.dev
← All articles
Blog

Your Vite SPA serves an empty page to Google

A single-page app renders in the browser, so the first thing a crawler stores is an empty div. Here is the build step that fixes it without a framework migration, and the two failures that never show up in a build.

Published 6 min read

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.

Start with the scope conversation. It costs nothing and it is usually one call.