SEO for a Vite + React SPA without SSR: prerender the head, not the app

· 5 min read

My portfolio is a Vite + React Router single-page app on Vercel. When I audited it for SEO, the problems were the classic ones for a client-rendered app:

Three ways out

  1. Move to an SSR framework like Next.js. The right answer for a large site, but a rewrite for a small one.
  2. Prerender the whole app with a headless browser at build time. Works, but it's slow, flaky in CI and adds a browser to your build.
  3. Prerender only the <head>. Google renders JavaScript fine, so the body can stay client-rendered. What crawlers and link previews need first is the title, description, canonical, robots and social tags, and those are just strings.

I went with the third.

One source of truth for metadata

Every route's metadata lives in one module. It has no import.meta.env and uses relative imports only, so both the React app and the Vite config can import it:

export type PageMeta = {
  /** Canonical path. Omit for pages that should not declare one (e.g. 404). */
  path?: string;
  title: string;
  description: string;
  noindex?: boolean;
};

export const staticPages = (): RoutedPageMeta[] => [
  homeMeta,
  servicesMeta,
  ...services.map(serviceMeta),
  ...caseStudies.map(caseStudyMeta),
  blogMeta,
  ...blogPosts.map(blogPostMeta),
];

Routes come from the same data that renders the pages, so a new service or case study gets its own prerendered page and sitemap entry without anyone remembering to add them.

A Vite plugin that writes one HTML file per route

index.html marks the page-specific block:

<!-- seo:start -->
<title>…</title>
<meta name="description" content="…" />
<link rel="canonical" href="…" />
<!-- seo:end -->

After the build, a small plugin swaps that block for each route and writes the files:

closeBundle() {
  const template = fs.readFileSync(path.join(outDir, "index.html"), "utf8");
  const write = (file: string, meta: PageMeta) =>
    fs.writeFileSync(path.join(outDir, file), template.replace(SEO_BLOCK, renderHead(meta)));

  for (const page of staticPages()) {
    write(page.path === "/" ? "index.html" : `${page.path.slice(1)}.html`, page);
  }
  write("404.html", notFoundMeta);
  fs.writeFileSync(path.join(outDir, "sitemap.xml"), sitemapXml());
}

renderHead turns a PageMeta into the title, description, robots, canonical, Open Graph and Twitter tags, plus per-route JSON-LD. Every value is HTML-escaped, and < is escaped inside the JSON so it can never close the script tag early.

Real 404s on Vercel

With a file per route, the catch-all rewrite can go:

{ "cleanUrls": true, "trailingSlash": false }

cleanUrls serves services.html at /services. Anything without a file falls through to 404.html, which Vercel serves with a real 404 status. That page still loads the app, so visitors see a styled not-found page, while crawlers see the status code and a noindex.

Keeping the head right during client-side navigation

The prerendered head covers the first load. After that, React Router navigates without reloading, so a small hook updates the same tags from the same metadata:

export const usePageMeta = (meta: PageMeta) => {
  useEffect(() => {
    document.title = meta.title;
    for (const tag of headTags(meta)) {
      // upsert <meta>/<link> by name, property or rel; remove when undefined
    }
  }, [meta.path, meta.title, meta.description, meta.noindex]);
};

Because the hook and the build plugin share headTags(), they can't drift apart.

Guarding the CSP

The site has one inline script, which applies the saved light or dark theme before first paint to avoid a flash of the wrong theme. A strict Content-Security-Policy only allows it by hash, and a hash goes stale as soon as someone edits the script. So the same plugin hashes every inline script and fails the build if that hash isn't in the CSP in vercel.json. A broken theme toggle becomes a build error instead of a production bug.

Checking it

Every route now answers with its own status and title:

URL Status Title
/services 200 Services | Paritosh Khubchandani
/services/ai-development 200 AI Development | …
/totally-fake 404 Page not found | …
/services/ 308 → /services

LinkedIn's Post Inspector picks up the right title and the new 1200×630 preview image.

Trade-offs

For a portfolio, marketing site or docs site built as a SPA, prerendering just the head gets you most of what SSR gives you for SEO, without changing frameworks.