React SPA SEO: fixing the empty shell for AI crawlers
React SPA SEO for AI search: why Vite and Create React App sites send crawlers an empty root div, four fixes ranked by effort, and how to verify each one.
By Crawlito · 8 min read
In short
- A client-rendered React app built with Vite or Create React App usually returns the same HTML for every URL: an empty root div and a script tag, with no page text.
- In Vercel and MERJ's December 2024 analysis, none of the major AI crawlers executed JavaScript, so they read that empty shell, while Google Search renders the full page.
- The four fixes are a framework with server rendering or static generation (the most complete), build-time prerendering, server rendering from an existing Node server, and key text in index.html as a stopgap.
- Next.js's official Vite migration guide starts by keeping the app client-only, so the shell stays empty until routes actually render on the server.
- Verify any fix with a plain HTTP request per route: each URL should return its own title, main text and links without running JavaScript.
On this page
A React single-page app (SPA) built with Vite or Create React App sends every URL the same nearly empty HTML file: a <div id="root"></div> and a script tag. Browsers run the script and draw the page. Most AI crawlers do not run JavaScript, so they read the empty file and find no headings, no body text and no links. Fixing React SPA SEO for AI search means getting real HTML into that first response, either by rendering on the server, by prerendering at build time, or, as a stopgap, by writing key text into index.html.
This guide is for teams who already have a working React SPA and need to choose a fix. It covers how to confirm the problem, four options with their migration effort, and how to verify the result.
Why a Vite or Create React App site looks empty to AI crawlers
Both tools produce a client-side rendered app by default. The build output is one index.html, a JavaScript bundle and a CSS file. With the usual SPA rewrite rule, the host serves that same index.html at /, /pricing and /blog/some-post, and the client-side router decides what to draw after the bundle runs.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Acme</title>
<script type="module" crossorigin src="/assets/index-4f2a9c.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>Google Search can cope with this, because it renders JavaScript with an evergreen Chromium after queuing the page. Most AI crawlers cannot. In Vercel and MERJ's The rise of the AI crawler (December 2024), none of the major AI crawlers rendered JavaScript, including OpenAI's crawlers, ClaudeBot and PerplexityBot. ChatGPT's and Claude's crawlers downloaded JavaScript files but did not execute them. That is a snapshot from one network at one time; Do AI crawlers run JavaScript? covers what is known by crawler.
There is a second reason to act if you are on Create React App. The React team deprecated Create React App for new apps in February 2025, citing no active maintainers, and recommended migrating to a framework such as Next.js or React Router, or to a build tool such as Vite. A rendering fix and a CRA exit can be the same project.
How to confirm your React app ships an empty shell
Fetch a few different routes without a browser and compare them. If each one returns the same title and an empty root div, you have the problem on every page.
for route in / /pricing /blog/first-post; do
echo "== $route"
curl -s "https://example.com$route" | grep -o '<title>[^<]*</title>'
curl -s "https://example.com$route" | grep -c 'id="root"></div>'
doneA 1 from the second command means the root is empty in the raw HTML. Then search for a phrase a visitor can see on the page, such as a price or a heading:
curl -s https://example.com/pricing | grep -c "Starter plan"Zero matches means a non-rendering crawler cannot read that phrase. How to see your page as an AI crawler, with curl adds a word-count script and crawler user agents. Sending a crawler's user agent shows what rules based on the user agent serve, but it cannot reproduce firewall rules that check the crawler's IP address.
Four ways to fix a React SPA, compared
| Fix | Effort | What crawlers get | Good fit |
|---|---|---|---|
| Framework with SSR or static generation | High | Full HTML per route, including dynamic routes | Sites you keep building |
| Build-time prerendering with your current stack | Medium | Full HTML for listed routes | Known routes that change on deploy |
| Server rendering from an existing Express server | Medium to high | Full HTML per request | Apps that already run on Node |
Key text in index.html | Low | One shared summary for every route | A stopgap this week |
Serving prerendered HTML only to bots, called dynamic rendering, is a fifth path. Google calls it a workaround, not a recommended solution, and it only helps crawlers on its bot list. Prerendering and dynamic rendering covers when it is still worth it.
Option 1: Move to a framework
This is the complete fix. Pick by what you already use.
Next.js. The official Vite to Next.js guide is incremental, and its first stage keeps your app client-only: it sets output: 'export', adds an optional catch-all [[...slug]] route, and loads your App with dynamic(() => import('../../App'), { ssr: false }). After that stage the root div in the raw HTML is still empty. The gain for crawlers arrives in the later step the guide lists, moving from React Router to the App Router so pages render on the server. Next.js and AI search covers streaming and metadata once you are there.
React Router framework mode. If the app already uses React Router, framework mode keeps your route components and server-renders by default. The pre-rendering guide shows how to render routes to static files at build time, even for a static host:
// react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: false, // static host, no runtime server
prerender: ["/", "/pricing", "/features", "/blog/first-post"],
} satisfies Config;Each listed path is written as an HTML file in build/client. Because / is in the list, other URLs fall back to build/client/__spa-fallback.html, which your host should serve for unmatched paths. With ssr: false, a loader is only allowed on routes matched by a prerender path, so list every content route that loads data.
Astro. For marketing sites and docs with a few interactive widgets, Astro renders pages to HTML and lets you keep React components through the React integration (npx astro add react). Per the directives reference, a component with no client:* directive is rendered to HTML without JavaScript, while client:only skips server rendering entirely. Avoid client:only for anything you want crawlers to read.
Option 2: Prerender routes at build time
If a migration is months away, you can prerender with Vite itself. Vite's SSR guide describes a server entry, a placeholder in index.html and an SSR build, and notes that the same setup can pre-render known routes to static HTML. React's prerenderToNodeStream fits this job because, unlike renderToString, it waits for Suspense data to load before resolving. It does not wait for data fetched in effects.
First, put a placeholder inside the root in index.html: <div id="root"><!--ssr-outlet--></div>. Then add a server entry. This assumes the router lives in main.jsx, not inside App, and that package.json has "type": "module", as Vite's templates do:
// src/entry-server.jsx
import { prerenderToNodeStream } from "react-dom/static";
import { StaticRouter } from "react-router";
import App from "./App";
export async function render(url) {
const { prelude } = await prerenderToNodeStream(
<StaticRouter location={url}><App /></StaticRouter>
);
const chunks = [];
for await (const chunk of prelude) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}A short script writes one file per route, with its own title, and keeps an empty copy of the template as the fallback for other URLs. The replacements use functions so that a $ in your page text is not read as a replacement pattern:
// prerender.mjs
import fs from "node:fs";
import path from "node:path";
import { render } from "./dist/server/entry-server.js";
const routes = [
{ url: "/", title: "Acme: invoicing for small agencies" },
{ url: "/pricing", title: "Pricing | Acme" },
{ url: "/features", title: "Features | Acme" },
];
const template = fs.readFileSync("dist/client/index.html", "utf8");
fs.writeFileSync("dist/client/spa-fallback.html", template);
for (const { url, title } of routes) {
const appHtml = await render(url);
const html = template
.replace(/<title>[^<]*<\/title>/, () => `<title>${title}</title>`)
.replace("<!--ssr-outlet-->", () => appHtml);
const file = url === "/" ? "dist/client/index.html" : `dist/client${url}/index.html`;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, html);
}npx vite build --outDir dist/client
npx vite build --outDir dist/server --ssr src/entry-server.jsx
node prerender.mjsFinally, update main.jsx so React attaches to prerendered HTML with hydrateRoot instead of replacing it, but still renders normally on the empty fallback page:
// src/main.jsx
import { createRoot, hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
const container = document.getElementById("root");
const app = <BrowserRouter><App /></BrowserRouter>;
if (container.firstElementChild) hydrateRoot(container, app);
else createRoot(container).render(app);Point your host's catch-all rewrite at spa-fallback.html, and add the description and canonical tag per route the same way as the title.
Option 3: Server-render from an existing Express server
If the app already sits behind a Node server, you can render each request there instead of at build time. This covers routes you cannot list in advance, such as thousands of product pages. The Vite SSR guide's production setup reads dist/client/index.html, imports the built server entry, and replaces the placeholder per request:
// server.mjs
import express from "express";
import fs from "node:fs";
import { render } from "./dist/server/entry-server.js";
const template = fs.readFileSync("dist/client/index.html", "utf8");
const app = express();
app.use(express.static("dist/client", { index: false }));
app.use(async (req, res, next) => {
try {
const appHtml = await render(req.originalUrl);
// Sends 200 for every URL: add a route match check that returns 404.
res.status(200).type("html").send(template.replace("<!--ssr-outlet-->", () => appHtml));
} catch (err) {
next(err);
}
});
app.listen(3000);Two details matter for crawlers. Return a real 404 status for URLs that match no route, rather than 200 with a "not found" component. And cache rendered HTML for pages that rarely change, since rendering every request costs CPU. For long pages you can switch to React's streaming APIs, such as renderToPipeableStream, later.
Option 4: Put critical content in index.html
If none of the above can ship soon, write a real H1, a one-paragraph summary and plain links to your main sections inside the root div of index.html. React replaces that content when createRoot(...).render() runs. The limit is that one file serves every route, so /pricing still shows no prices to a crawler. Client-side rendering and the empty HTML problem has a template and the caveats.
How much work each migration takes
The effort rarely depends on the number of components. It depends on how much code assumes it runs in a browser. Check these before you estimate:
| Pattern in your code | Why it matters | Typical change |
|---|---|---|
Data fetched in useEffect | Effects do not run on the server, so that content is missing from server HTML | Move fetching to route loaders, server components or Suspense-aware data reads |
window, document or localStorage read during render | Crashes or differs on the server | Read them in effects or event handlers |
| Different output on server and client | React says to treat hydration mismatches as bugs | Render the same data in both places |
| Per-route titles set after load | They never reach raw HTML in an SPA | Set them in the framework's metadata API or the prerender step |
| Static-only hosting | No server to render requests | Prerender at build time, or add a Node or edge runtime |
| Logged-in app screens | No public content to render | Leave them client-side and render public pages first |
A practical order is to render the public pages first (home, product or service pages, pricing, docs, blog) and leave the authenticated app as it is. Those are the pages a crawler can reach without logging in, so they are where readable HTML makes a difference.
How to verify the fix
- Per-route raw HTML. Rerun the curl loop from earlier. Each route should return its own
<title>, and the root should contain the page's text. - Key facts.
grepfor a heading, a price and a sentence from the middle of each important page. - Links. Confirm navigation uses real
<a href="...">elements in the raw HTML, which React Router'sLinkand Next.js'sLinkboth render. - Status codes. Check a real deep link returns 200 and a made-up URL returns 404:
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/no-such-page. - Hydration. Open the browser console on each page type and fix any hydration warnings React reports in development.
- Logs. Over the following weeks, check your server logs for AI crawlers requesting HTML pages beyond the homepage, not only script bundles.
Frequently asked questions
Is a React SPA bad for Google SEO?
Less than for AI search, because Google renders JavaScript. Rendering is queued and can be delayed, and Google's own guide still says server-side rendering or pre-rendering is a great idea.
Do I have to rewrite my app to fix this?
No. Prerendering with Vite or React Router's prerender option keeps your components, and you can limit the work to public pages. A full framework migration is the most complete fix but can be done route by route.
Does react-helmet or a title tag in a component fix the title problem?
Not in an SPA. Those set the title after the JavaScript runs, so non-rendering crawlers still see the title in index.html. They work once the page is server-rendered or prerendered.
Will server rendering get my React site cited in AI answers?
No one can promise that. Server rendering removes a technical barrier so AI crawlers can read your pages; whether an assistant cites them depends on many things outside your site.
Next step
Run the curl loop on your three most important routes. To compare raw and rendered HTML for one page without setting anything up, run the free Crawlito check; for every public page, an audit covers up to 500 pages with evidence and prioritized fixes.
Filed under Platform guides. Topics: react, javascript, rendering, ssr, ai-crawlers, nextjs