DEV Community

pk2502
pk2502

Posted on Originally published at paritosh-dev.vercel.app

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

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-development and /blog all served the same index.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-fake was 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

  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),
];
Enter fullscreen mode Exit fullscreen mode

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 -->
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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]);
};
Enter fullscreen mode Exit fullscreen mode

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.


Originally published on my site. I'm Paritosh, a lead full-stack engineer; more of my work is at paritosh-dev.vercel.app.

Top comments (0)