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:
- Every route shared one
<head>./services,/services/ai-developmentand/blogall served the sameindex.html, with the homepage's title, description and a canonical pointing at/. To Google, every page announced itself as a duplicate of the homepage. - Every URL returned 200. The usual SPA rewrite (
/(.*) → /index.html) meant/totally-fakewas a perfectly healthy page as far as crawlers could tell. That's a soft 404, and an endless supply of them. - Link previews were wrong everywhere. LinkedIn, WhatsApp and Slack don't run JavaScript, so every shared link showed the homepage card.
Three ways out
- Move to an SSR framework like Next.js. The right answer for a large site, but a rewrite for a small one.
- 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.
- 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
- The body is still client-rendered. Google indexes it, but crawlers that don't run JavaScript only see the head. For blog posts I also inject the article HTML into the prerendered page, so the text is there without JavaScript.
- Routes must be known at build time. That's fine when routes come from data, as they do here. It's not fine for user-generated pages; at that point, use a real SSR framework.
- No new dependencies. The whole thing is one metadata module, a hook and a plugin of about forty lines.
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.