Guide · 12 min read

React SEO for Vite apps: prerender, SSR or a framework

For developers with a Vite + React single-page app that Google barely sees. How to check it in five minutes, prerender it in an afternoon, or choose a framework when that's the better call.

Updated · By the Serpkin team

In short

  • A default Vite + React SPA sends the same empty HTML and title for every URL. Google may render it later; many crawlers and link previews never do.
  • If your public pages are known at build time, add a prerender step: render each route to its own HTML file with its own title, description and canonical.
  • Scope the SPA rewrite to client-only routes so unknown URLs return a real 404 instead of a 200 app shell.
  • React 19 title and meta tags keep titles right during navigation, but they are added by JavaScript: put the same values in the prerendered HTML.
  • For content-heavy sites, move to a framework that renders on the server or at build time: React Router framework mode, TanStack Start, Next.js or Astro.

A React app built with Vite can show up on Google, but by default it sends crawlers the same near-empty index.html for every URL: one title, no H1, no text, just a script tag. Google may render the JavaScript later; many other crawlers, link previews and AI tools never will. Fix that first. For a site with a few dozen marketing pages, add a prerender step to your build so each route ships its own HTML. For a site that is mostly content, or that grows every week, move to a framework with server rendering or static generation. This guide shows how to check what you have, the code for both paths, and a table to choose.

Why a Vite SPA is hard for crawlers

npm create vite with the React template gives you a single-page app. The server (or CDN) returns one HTML file, and React builds the page in the browser. That has four SEO side effects:

What a default Vite + React SPA sends to a crawler
ProblemWhat the crawler getsWhy it matters
Empty body<div id="root"></div> and a scriptCrawlers that read raw HTML see no content, headings or links
One title for every URLWhatever is in index.html (often "Vite + React + TS")Every page looks the same in results and share cards
Soft 404sA 200 status and the app shell for any path, real or notGoogle may index junk URLs or flag them as soft 404s
Late discoveryLinks exist only after JavaScript runsNew pages are found later, if at all

Google's own JavaScript SEO guide says it queues pages for rendering and that server-side rendering or prerendering "is still a great idea" because not all bots can run JavaScript. So the fix is not a meta tag. It is getting real HTML out of the server for every page you want found. (For the general version of this problem across AI-built sites, see SEO for AI-built websites.)

The five-minute diagnosis

  1. Fetch the raw HTML

    Run this against production (not vite dev) for your home page and one deep page.

    Terminal
    URL=https://your-site.com/pricing
    curl -s $URL | grep -o "<title>[^<]*"
    curl -s $URL | grep -o "<h1[^>]*>[^<]*"
    curl -s $URL | sed 's/<[^>]*>//g' | wc -w     # rough word count of the server HTML
  2. Compare with good output
    Terminal
    $ curl -s https://spa.example.com/pricing | grep -o "<title>[^<]*"
    <title>Vite + React + TS
    $ curl -s https://spa.example.com/pricing | grep -o "<h1[^>]*>[^<]*"
    (no output)
    $ curl -s https://fixed.example.com/pricing | grep -o "<title>[^<]*"
    <title>Pricing for small teams | Acme
    $ curl -s https://fixed.example.com/pricing | grep -o "<h1[^>]*>[^<]*"
    <h1>Pricing for small teams
    
    Illustrative output. Top: a default Vite SPA (generic title, no H1, almost no words). Bottom: the same page after prerendering.
  3. Test a URL that doesn't exist
    Terminal
    curl -s -o /dev/null -w "%{http_code}\n" https://your-site.com/this-page-does-not-exist

    You want 404. A 200 means your host rewrites every path to index.html, which is how most SPA setups are configured.

  4. Check robots.txt and the sitemap

    Fetch /robots.txt and /sitemap.xml. A Vite app has neither unless you put them in public/. Watch out for a sitemap URL that returns your app shell with a 200: that is the SPA rewrite again. The robots.txt tester and sitemap validator check both for you, and the free SEO checker shows title, H1, canonical and server HTML for one URL.

Your options, compared

There are four honest paths. Pick by how much of the site is content and how often it changes.

Ways to get real HTML out of a React app
OptionWhat you getEffortBest for
Stay SPA + prerender at buildStatic HTML per known route; the app still hydratesAn afternoonFewer than ~100 marketing pages known at build time; app behind login
React Router framework modePrerender or SSR per route, meta exports, loadersA day or two if you already use React RouterApps already on React Router that need content pages to rank
TanStack StartSSR by default, optional static prerender with link crawlingDays: new router and file layoutFull-stack React apps that want type-safe routing and server functions
Next.js or AstroServer Components (Next.js) or HTML-first pages with React islands (Astro)Days to a week: a real migrationContent-heavy sites, blogs, docs, programmatic pages

Option 1: prerender your Vite SPA

Vite's SSR guide describes the idea: if the routes and their data are known ahead of time, render them to static HTML with the same code you would use for SSR. You need three small files and one build script. This example uses React Router in declarative mode (BrowserRouter); the same shape works with other routers.

1. One list of public routes and their metadata

src/seo.ts
export const SITE = "https://your-site.com"; // production origin, never a preview URL

export const pages = [
  { path: "/", title: "Acme: project planning for small teams", description: "Plan sprints, track work and ship on time. Free for teams of up to five." },
  { path: "/pricing", title: "Pricing for small teams | Acme", description: "Acme is free for up to five people. Paid plans add unlimited projects, SSO and priority support." },
  { path: "/blog/launch", title: "Why we built Acme | Acme", description: "The story behind Acme and what we learned shipping it in six weeks." },
];

2. A server entry and a hydrating client entry

src/entry-server.tsx
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router";
import { App } from "./App"; // App holds <Routes>, no router of its own
export { pages, SITE } from "./seo";

export function render(url: string) {
  return renderToString(
    <StaticRouter location={url}>
      <App />
    </StaticRouter>,
  );
}
src/main.tsx
import { createRoot, hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";

const root = document.getElementById("root")!;
const app = (
  <BrowserRouter>
    <App />
  </BrowserRouter>
);
// Prerendered pages already have markup: hydrate it. Client-only routes start empty.
root.hasChildNodes() ? hydrateRoot(root, app) : createRoot(root).render(app);

3. The prerender script

scripts/prerender.mjs
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

const dist = path.resolve("dist");
const template = fs.readFileSync(path.join(dist, "index.html"), "utf8");
const { render, pages, SITE } = await import(pathToFileURL(path.resolve("dist-ssr/entry-server.js")).href);
const esc = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
// Drop head tags the components render themselves, so each page has exactly one of each.
const strip = (html) => html.replace(/<title>.*?<\/title>|<meta name="description"[^>]*>|<link rel="canonical"[^>]*>/gs, "");

function page(url, { title, description, noindex }) {
  const head = [
    `<title>${esc(title)}</title>`,
    `<meta name="description" content="${esc(description)}">`,
    noindex ? '<meta name="robots" content="noindex">' : `<link rel="canonical" href="${SITE}${url}">`,
  ].join("\n");
  return template
    .replace(/<title>.*?<\/title>/s, "")
    .replace("</head>", `${head}\n</head>`)
    .replace('<div id="root"></div>', `<div id="root">${strip(render(url))}</div>`);
}

// Client-only shell for the logged-in app (the original, empty index.html).
fs.mkdirSync(path.join(dist, "app"), { recursive: true });
fs.writeFileSync(path.join(dist, "app/index.html"), template);

for (const p of pages) {
  const file = p.path === "/" ? "index.html" : `${p.path.slice(1)}/index.html`;
  fs.mkdirSync(path.dirname(path.join(dist, file)), { recursive: true });
  fs.writeFileSync(path.join(dist, file), page(p.path, p));
}
// Your catch-all route (<Route path="*">) renders the not-found page.
fs.writeFileSync(path.join(dist, "404.html"), page("/404", { title: "Page not found", description: "", noindex: true }));

const urls = pages.map((p) => `  <url><loc>${SITE}${p.path}</loc></url>`).join("\n");
fs.writeFileSync(path.join(dist, "sitemap.xml"),
  `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`);
console.log(`Prerendered ${pages.length} pages + 404.html + sitemap.xml`);
package.json
{
  "scripts": {
    "build": "vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs"
  }
}

Components that touch window or localStorage during render will crash the prerender. Move that code into useEffect. Data that comes from an API at runtime won't be in the HTML unless you fetch it in the script and pass it in.

4. Stop the host from turning every 404 into a 200

Now that each public route has its own file, you no longer need a rewrite of every path to index.html. Scope it to the part of the app that is client-only, and let everything else fall through to 404.html. The script above wrote an empty shell to app/index.html for that. On Netlify, a root 404.html is picked up automatically for paths that don't resolve to a file; on other hosts, the not-found curl tells you whether yours is served with a 404 status:

public/_redirects
# Netlify: only the logged-in app is a client-side SPA
/app/*  /app/index.html  200
vercel.json
{
  "rewrites": [{ "source": "/app/(.*)", "destination": "/app/index.html" }]
}

Re-run the not-found curl from the diagnosis. If you must keep a catch-all rewrite, follow Google's SPA advice: on unknown routes, add <meta name="robots" content="noindex"> or navigate to a URL that returns a real 404.

Per-page titles and meta tags in React

React 19 lets any component render <title>, <meta> and <link>, and places them in the document head. Two rules from the React docs: a title takes a single string (use a template literal), and only one component should render a title at a time.

src/components/Seo.tsx
import { SITE } from "../seo";

export function Seo({ title, description, path }: { title: string; description: string; path: string }) {
  return (
    <>
      <title>{title}</title>
      <meta name="description" content={description} />
      <link rel="canonical" href={`${SITE}${path}`} />
      <meta property="og:title" content={title} />
      <meta property="og:description" content={description} />
    </>
  );
}

On their own these tags are still added by JavaScript, so crawlers that read raw HTML won't see them. That is why the prerender script writes the same values from src/seo.ts into each file's head. On React 18, use react-helmet-async instead. Its 3.x release supports React 19 by rendering native tags, but on React 19 its server context is no longer filled, so don't build new prerender code on it.

Options 2 to 4: move to a framework

React Router framework mode

If you already use React Router, framework mode is the shortest step. Routes export meta and loader, and react-router.config.ts decides what is prerendered. With ssr: false you still deploy static files: listed paths get HTML, the rest falls back to the SPA.

react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false, // static hosting; set true to server-render unlisted routes
  async prerender({ getStaticPaths }) {
    const slugs = await getPostSlugs();
    return [...getStaticPaths(), ...slugs.map((s) => `/blog/${s}`)];
  },
} satisfies Config;

Each prerendered path becomes build/client/<path>/index.html. Note the SPA fallback that React Router tells you to configure (/* /index.html 200) brings back soft 404s, so scope it as above.

TanStack Start

TanStack Start renders on the server by default and can also prerender: set prerender.enabled in the tanstackStart() Vite plugin, and with crawlLinks (on by default) it follows links from prerendered pages to find more routes. This is the stack Lovable moved new projects to in 2026 (see Lovable SEO).

Next.js or Astro

For blogs, docs and anything with many pages, a content-first framework saves you from maintaining a prerender script. Next.js renders Server Components on the server and has a Metadata API, sitemap.ts and robots.ts (see the Next.js SEO checklist). Astro ships HTML by default and lets you keep React components as islands (see the Astro SEO checklist).

  1. 1Mostly app, few public pagesStay on Vite, prerender the public routes
  2. 2Already on React RouterFramework mode with prerender, SSR later if needed
  3. 3Full-stack app, needs SSRTanStack Start or React Router with ssr: true
  4. 4Mostly contentNext.js or Astro
How to choose: start from how much of the site is content, then how often it changes.

A migration brief that works well with a coding agent:

Prompt
Goal: every public route returns its content, title, description and canonical in the
server HTML, and unknown URLs return 404.

Context: Vite + React SPA, routes in src/App.tsx. Public routes: /, /pricing, /about, /blog/*.
The /app/* routes are behind login and can stay client-only.

Do: add a build-time prerender (src/seo.ts route list, src/entry-server.tsx, scripts/prerender.mjs),
write sitemap.xml and a 404.html with noindex, and scope the SPA rewrite to /app/*.

Done when: after a production build and preview, curl of each public route shows its <h1> and
<title>, curl of /nope returns 404, and /sitemap.xml lists only the public routes.

Check it before you ship

  • Every public route's H1 and main copy are in curl output.
  • Each route has its own title, description and an absolute canonical on your production domain.
  • Unknown paths return a 404 status, not a 200 app shell.
  • /robots.txt exists, doesn't block your assets, and points at /sitemap.xml.
  • Links are real <a href> elements (React Router's <Link> is), with no #/ URLs.
  • Open Graph tags are in the HTML, so link previews work (check with the SERP preview for titles).

If you'd like a second pair of eyes, npx serpkin connects Claude Code, Codex or Cursor, registers your site and runs a first scan. The scan flags pages whose text isn't in the server HTML, and small metadata gaps ship together as one "Fix the basics" mission. Route-to-file mapping covers Next.js fully and React Router framework mode as best effort; on a plain Vite SPA, give your agent the route file yourself. Before you merge a prerender change, npx serpkin check <preview-url> compares the preview with production and fails on regressions such as a lost title or canonical.

Check a page nowFree: see what a crawler finds on any URL, with a prompt for your agent.

Questions

Is React bad for SEO?

No. React only becomes a problem when the page is built entirely in the browser, as in a default Vite SPA. Render the same components on the server or at build time and crawlers get normal HTML.

Can Google index a Vite React app?

Often, yes, because Google renders JavaScript. But rendering is queued, not every page renders fully, and many other crawlers read only raw HTML. Google itself recommends server-side rendering or prerendering.

Do I need react-helmet in React 19?

Not for setting tags in the browser: React 19 renders <title>, <meta> and <link> from any component and places them in the head. You still need those values in the server or prerendered HTML for crawlers that don't run JavaScript.

Why does my React app return 200 for pages that don't exist?

The host rewrites every path to index.html so client-side routing works. Scope that rewrite to client-only routes, ship a 404.html, or add a noindex robots tag on your not-found route.

Should I switch from Vite to Next.js for SEO?

Only if most of the site is content or it grows often. For a handful of marketing pages in front of an app, prerendering those routes is less work and gets the same HTML.

Sources

Let your agent do the SEO work.

Serpkin finds the next fix from real search data, hands it to Claude Code, Codex or Cursor, and checks it after you ship.

npx serpkin
Start free