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:
| Problem | What the crawler gets | Why it matters |
|---|---|---|
| Empty body | <div id="root"></div> and a script | Crawlers that read raw HTML see no content, headings or links |
| One title for every URL | Whatever is in index.html (often "Vite + React + TS") | Every page looks the same in results and share cards |
| Soft 404s | A 200 status and the app shell for any path, real or not | Google may index junk URLs or flag them as soft 404s |
| Late discovery | Links exist only after JavaScript runs | New 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
- 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 - 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. - 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-existYou want
404. A200means your host rewrites every path toindex.html, which is how most SPA setups are configured. - Check robots.txt and the sitemap
Fetch
/robots.txtand/sitemap.xml. A Vite app has neither unless you put them inpublic/. 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.
| Option | What you get | Effort | Best for |
|---|---|---|---|
| Stay SPA + prerender at build | Static HTML per known route; the app still hydrates | An afternoon | Fewer than ~100 marketing pages known at build time; app behind login |
| React Router framework mode | Prerender or SSR per route, meta exports, loaders | A day or two if you already use React Router | Apps already on React Router that need content pages to rank |
| TanStack Start | SSR by default, optional static prerender with link crawling | Days: new router and file layout | Full-stack React apps that want type-safe routing and server functions |
| Next.js or Astro | Server Components (Next.js) or HTML-first pages with React islands (Astro) | Days to a week: a real migration | Content-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
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
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>,
);
}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
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, "&").replace(/"/g, """).replace(/</g, "<");
// 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`);{
"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:
# Netlify: only the logged-in app is a client-side SPA
/app/* /app/index.html 200{
"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.
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.
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).
- 1Mostly app, few public pagesStay on Vite, prerender the public routes
- 2Already on React RouterFramework mode with prerender, SSR later if needed
- 3Full-stack app, needs SSRTanStack Start or React Router with ssr: true
- 4Mostly contentNext.js or Astro
A migration brief that works well with a coding agent:
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
curloutput. - 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.txtexists, 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.
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
- Google: JavaScript SEO basics (render queue, SSR recommendation, SPA soft 404s) (checked October 29, 2026)
- Google: dynamic rendering as a workaround (checked October 29, 2026)
- Vite docs: server-side rendering and pre-rendering (checked October 29, 2026)
- React docs: <title> (checked October 29, 2026)
- React docs: <meta> (checked October 29, 2026)
- React Router docs: pre-rendering (checked October 29, 2026)
- TanStack Start docs: static prerendering (checked October 29, 2026)
- react-helmet-async 3.0 (React 19 support) (checked October 29, 2026)
- Netlify docs: custom 404 page (checked October 29, 2026)
- Netlify docs: Prerender extension (checked October 29, 2026)
- Vercel docs: Vite on Vercel (SPA rewrites) (checked October 29, 2026)