Technical SEO
React SEO: Escaping the Client-Side Rendering Shell Trap
What is React SEO?
React SEO is the practice of making a React application’s content, metadata, and structured data present in the HTML the server sends — before any JavaScript runs — so search engines and AI crawlers can crawl, render, understand, and cite it. For the default client-rendered React SPA the work is architectural: switch the render strategy to server-side rendering, static prerendering, or a meta-framework, so the page is not a blank
<div id="root">to every bot that never executes JavaScript.
This is the React instalment of my SEO for engineers series, which walks a single arc — crawlable → fast → understandable → citable — and grounds it in one stack at a time. React is the interesting case in that series because its default posture is the worst of the three I cover: a plain single-page app ships almost nothing in the initial HTML. Astro starts crawlable; React starts blank. Everything below is about closing that gap with real diffs, not repositioning content you already have on the client.
The shell trap: what a default React SPA actually serves
Spin up a React app with Vite’s react template (or the long-deprecated Create React App) and look at the HTML the server returns. It is, essentially, this:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Every heading, paragraph, image, canonical tag, and JSON-LD block on that page is assembled after the browser downloads and runs main.jsx, which calls ReactDOM.createRoot(...).render(...) and builds the DOM client-side. The document above is the whole payload. Run the one check from the pillar — ask for the page as an AI crawler would — and you can see the problem in ten seconds:
# AI crawlers never run your JS. This raw HTML is the whole story
# for ChatGPT Search, Perplexity, and Claude.
curl -sA "GPTBot" https://your-react-app.com/ | grep -i "<h1\|your key sentence"
On a client-rendered SPA that command returns nothing — there is no <h1> and no body copy in the response, only <div id="root"></div>. GPTBot, OAI-SearchBot, ClaudeBot, and PerplexityBot fetch raw HTML and parse it as text; none of them execute JavaScript, so the page is genuinely empty to them. Googlebot is more forgiving — its rendering pipeline crawls the HTML first and defers JavaScript rendering to a later pass — but “more forgiving” is not “safe”: content that only appears after hydration can be indexed late, discounted, or missed, and it forfeits the entire class of AI-search surfaces outright.
That is the shell trap, and it is the default state of a React app, not an edge case. The rest of this guide is how to get out of it.
Escaping the shell: three paths, ranked by effort
I am deliberately not re-litigating the general SSR/SSG/ISR/CSR trade-offs here — I wrote a full treatment of rendering strategies for headless architecture, including the per-template decision. What matters for React specifically is how you move primary content into the server render path. There are three honest answers, and the right one depends on how much of your app actually needs to rank.
1. Adopt a meta-framework (the default recommendation). React’s own official documentation now steers new projects to a production framework rather than a bare SPA setup, and Create React App has been retired. A framework gives you server rendering, routing, and metadata handling as first-class features instead of things you wire up by hand. Next.js is the most common choice and gets its own instalment in this series — its App Router, server components, and generateMetadata are covered there, so I will not duplicate them. React Router v7 (which absorbed Remix) offers an equivalent framework mode with server rendering. If more than a couple of routes need organic or AI visibility, this is the path with the least long-term friction.
2. Server-render the existing app yourself. If you have a working SPA and don’t want to migrate, you can put React’s own server renderer in front of it. react-dom/server’s renderToPipeableStream produces the full HTML on the server; hydrateRoot attaches the existing client bundle to that markup in the browser:
// server.jsx — Node handler
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res); // full HTML — <h1>, body copy, schema — streams to the crawler
},
});
});
// client entry — hydrate the server HTML instead of rendering from scratch
import { hydrateRoot } from 'react-dom/client';
import App from './App';
hydrateRoot(document.getElementById('root'), <App url={window.location.pathname} />);
This is more moving parts than a framework (you own the server, the routing bridge, and the data-fetching-on-both-sides problem), which is exactly why frameworks exist — but it is a legitimate way to keep an app you already have while making it crawlable.
3. Prerender to static HTML at build time. For content that doesn’t change per request — marketing pages, docs, a blog — you don’t need a live server at all. Prerendering runs the app once per route at build time and writes real HTML files a CDN serves directly. The crawler gets fully assembled HTML with zero runtime rendering ambiguity, which is the strongest SEO posture available. Most React frameworks expose this as a static-export or prerender mode; if you’re rolling your own, it’s react-dom/server invoked over your route list at build.
The decision is per-template, not per-app: a dashboard behind a login never needs to escape the shell, while the marketing and content routes that carry your organic and AI-citation value always do. Getting that scoping right across a real codebase is the substance of a full-stack SEO engagement.
Per-route metadata that actually reaches the crawler
Server-rendering the body is half the job. The other half is the <head> — a title, description, canonical, and Open Graph tags that change per route. In a React SPA the naive approach mutates document.title in a useEffect, which runs only in the browser and leaves the server HTML with whatever static title sat in index.html. Every crawler that reads raw HTML sees that stale placeholder.
The long-standing library answer is react-helmet-async. The critical detail most tutorials skip: its output has to be extracted on the server and injected into the HTML template — not left to update the DOM after hydration. That means reading the helmet context after renderToString/renderToPipeableStream and stitching the tags into <head> yourself:
// server side — pull the resolved tags out and put them in the document head
import { HelmetProvider } from 'react-helmet-async';
const helmetContext = {};
const appHtml = renderToString(
<HelmetProvider context={helmetContext}>
<App url={req.url} />
</HelmetProvider>,
);
const { helmet } = helmetContext;
const html = `<!doctype html><html><head>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
</head><body><div id="root">${appHtml}</div></body></html>`;
If you skip the extraction step, react-helmet-async degrades to a client-only head manager — fine for a logged-in app, useless for SEO.
React 19 changes this in your favour. It natively supports rendering <title>, <meta>, and <link> tags anywhere in the component tree and hoists them into <head> automatically — and during SSR those tags are part of the streamed HTML, no context-extraction dance required:
// React 19 — metadata co-located with the route, hoisted to <head>,
// and present in the server-rendered HTML
function ArticlePage({ post }) {
return (
<article>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={`https://example.com/insights/${post.slug}/`} />
<h1>{post.title}</h1>
{/* …body… */}
</article>
);
}
The catch is the same catch as everything else in this guide: native metadata only reaches a crawler if the component that renders it is server-rendered. Co-locate it with the route however you like — but the render path has to run on the server.
Getting JSON-LD into the server response, not a useEffect
Structured data is the strongest, most controllable input to AI citation eligibility, and React is where I see it implemented wrong most often. The anti-pattern is injecting the <script type="application/ld+json"> block from a useEffect:
// ❌ invisible to every AI crawler — this runs only in the browser
useEffect(() => {
const el = document.createElement('script');
el.type = 'application/ld+json';
el.text = JSON.stringify(articleSchema);
document.head.appendChild(el);
}, []);
That schema never exists in the HTML the server sends. Googlebot may catch it on its deferred render pass; GPTBot, ClaudeBot, and PerplexityBot never will. The fix is to render the script tag as part of the tree so it lands in the server HTML. JSON-LD is valid anywhere in the document, so you can render it right in the component — the only requirement is that the component runs server-side:
// ✅ in the server-rendered output — crawlers and LLMs read it directly
function ArticleSchema({ post }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.date,
dateModified: post.updated ?? post.date,
author: { '@id': 'https://example.com/about/#person' },
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Note the author referenced by @id rather than duplicated inline — the Person entity is defined once (on your About page) and pointed at from everywhere, which is what lets an AI engine resolve the content to a consistent, verifiable author. The full priority stack and the sameAs mechanics are in the structured data for AI search guide. Whatever you build, validate it with Google’s Rich Results Test, which renders the page the way Googlebot does — if the schema doesn’t appear there, it’s still trapped on the client.
Hydration cost, INP, and the Core Web Vitals levers specific to React
Escaping the shell fixes crawlability. It can quietly hand you a performance problem, because React’s cost doesn’t disappear when you server-render — it moves to hydration. The server sends fast HTML (good for Largest Contentful Paint), but the browser then has to download the bundle and hydrate the entire tree before the page is interactive, and a full-page hydration is one long main-thread task. That is the classic React signature on Interaction to Next Paint: the content paints quickly, then the page feels frozen for a beat while React wires it up. Per web.dev, the targets are LCP under 2.5s, INP under 200ms, and CLS under 0.1, assessed at the 75th percentile of page loads, segmented by mobile and desktop — and INP is the one React apps miss.
Three levers move it, all things you control in the codebase:
Ship less JavaScript. The single biggest hydration win is not hydrating code the first screen doesn’t need. Code-split below-the-fold and interactive-only components with React.lazy and Suspense so they load and hydrate on demand rather than in the initial bundle:
const Reviews = React.lazy(() => import('./Reviews'));
// only pulled in when it renders, keeping the first-load bundle — and the
// hydration task — smaller
<Suspense fallback={<Spinner />}>
<Reviews productId={id} />
</Suspense>
Stream and hydrate selectively. renderToPipeableStream with <Suspense> boundaries lets React stream HTML as it’s ready and hydrate the interactive islands independently, so a slow widget no longer blocks the whole page from becoming responsive. This is the streaming-SSR posture that modern React frameworks build on.
Push work to server components where a framework allows it. React Server Components render on the server and ship zero client JavaScript for the non-interactive parts of the tree — the most direct way to cut hydration cost, since code that never hydrates can’t block the main thread. RSC is available today through a framework rather than in a bare SPA, which is one more reason the framework path in the previous section tends to win.
One CLS note that is not React-specific but bites React apps hard: always ship intrinsic width/height (or aspect-ratio) on images and reserve space for anything that mounts after hydration, so the layout doesn’t jump as client-rendered pieces settle in.
The payoff: from crawlable React to a sentence ChatGPT will quote
Everything above is the crawlable → fast → understandable stretch of the arc, done in React’s idioms. The reason it’s worth the effort is the last stage — citable. Once your content, metadata, and schema are all in the server response, a React page becomes eligible to be lifted and attributed by an AI engine, which is the bridge from this technical work to the site’s GEO practice. The eligibility is structural, and the numbers behind it are the same ones that anchor the rest of this series:
The uncomfortable truth for a React team is that a page can pass every design review, work flawlessly in the browser, and still score zero on both of those measures — because none of the signals are in the HTML a crawler reads. Fixing that is not a marketing task bolted on later; it’s a render-strategy decision that lives in your codebase, which is the entire premise of SEO for engineers.
Where React SEO fits the wider audit
The checks in this guide are the React-specific version of stages that recur on every site. The standing-maintenance version — the crawl, render, Core Web Vitals, and schema checks run on a cadence rather than once — is the technical SEO audit methodology, and the curl -A "GPTBot" render check belongs in it permanently once your app is server-rendered, because a refactor can silently push content back onto the client. If you’d rather have the render strategy, performance, and schema designed and shipped end-to-end than retrofit them route by route, that’s what full-stack development covers. Either way the sequence is the same one this series walks: get it crawlable, get it fast, get it understandable, and then get it citable.
FAQ
Is React bad for SEO?
React is not inherently bad for SEO, but its default configuration is. A plain single-page app built with Vite or Create React App renders all of its content client-side, so the HTML the server sends is an empty <div id="root"> plus a script bundle. Search engines that defer JavaScript rendering may index it late, and AI crawlers that never execute JavaScript see nothing at all. Server-side rendering, static prerendering, or a meta-framework moves the content into the initial HTML and removes the problem entirely.
Does Google index client-side rendered React apps?
Sometimes, and unreliably. Googlebot processes pages in phases — it crawls the raw HTML first and defers JavaScript rendering to a later pass once resources allow. A client-rendered React page can eventually be indexed on that second pass, but the content may be indexed late, discounted, or missed, especially on sites without a large crawl budget. And AI crawlers like GPTBot, ClaudeBot, and PerplexityBot never run the second pass at all, so client-only React content is invisible to ChatGPT Search and Perplexity regardless of how Google treats it.
Do I need Next.js for React SEO?
Not necessarily, though a meta-framework is the lowest-friction path. Next.js and React Router v7 (which absorbed Remix) give you server rendering, routing, and metadata handling as built-in features. But you can also put React’s own renderToPipeableStream server renderer in front of an existing SPA, or prerender content routes to static HTML at build time. The requirement is that primary content, metadata, and schema end up in the server-sent HTML — the framework is one way to achieve that, not the only one. The Next.js-specific patterns are covered in the Next.js SEO guide.
How do I add meta tags per route in a React app?
Use per-route metadata that resolves on the server. The library approach is react-helmet-async, but you must extract its output from the helmet context after server rendering and inject it into the <head> of the HTML template — left to run client-side it does nothing for SEO. React 19 simplifies this: rendering <title>, <meta>, and <link> tags anywhere in a component hoists them into <head> automatically, and during server rendering they’re part of the streamed HTML. Both approaches only work when the component that renders them runs on the server.
Where should JSON-LD structured data go in a React app?
In the server-rendered HTML, never in a useEffect. Injecting a <script type="application/ld+json"> tag from a useEffect runs only in the browser, so the schema is absent from the HTML the server sends and invisible to every AI crawler. Instead, render the script tag as part of the component tree so it appears in the server output — JSON-LD is valid anywhere in the document. Reference the author by @id to a single Person entity rather than duplicating it inline, and validate the result in Google’s Rich Results Test.
Does React hydration hurt Core Web Vitals?
It can hurt Interaction to Next Paint. Server-rendered React paints its content quickly, which helps Largest Contentful Paint, but the browser then has to hydrate the tree before the page is interactive — and hydrating the whole page on load is a long main-thread task that can push INP past the 200ms threshold. The levers are shipping less JavaScript (code-split with React.lazy and Suspense), streaming with renderToPipeableStream so islands hydrate independently, and using React Server Components through a framework to ship zero client JavaScript for non-interactive parts of the tree.