React is a UI library. It renders interfaces, but it makes no decisions about where or when that rendering happens. That distinction matters enormously for SEO, because the default choice most developers land on, a client-side rendered (CSR) single-page app, produces an HTML file that is nearly empty. Crawlers see a <div id="root"></div> and nothing else. The fix isn’t a plugin or a meta tag. It’s picking the right rendering architecture from the start.
This guide covers what crawlers actually see in a React app, how to choose a rendering approach, how to manage the document head per route, and what you can do today if you’re already running a CSR app.
Why default React apps fail SEO
A standard Create-React-App or Vite SPA sends one bare HTML shell to every request. All content, headings, and links are injected by JavaScript after the page loads. Googlebot can render JavaScript, but it does so in a second wave, with a delay that can push dynamic content out of the crawl queue for days or weeks. Less capable crawlers (Bing’s bot, social previews, and most AI crawlers) don’t run JavaScript at all.
The practical result: your pages look blank to anything that doesn’t execute your bundle. No content means no rankings. For a deeper look at why this is a structural problem across JavaScript frameworks, see our JavaScript SEO guide.
Choosing a rendering strategy
The choice between CSR, SSR, SSG, and prerendering is the highest-leverage SEO decision you’ll make for a React project. Pick the wrong one and no amount of metadata optimisation will compensate.
| Strategy | How it works | Best for | SEO impact |
|---|---|---|---|
| CSR (default SPA) | Browser runs JS, injects content | Web apps behind login | Poor for public pages |
| SSR (server-side render) | Server renders HTML per request | Dynamic public content | Excellent |
| SSG (static site gen) | HTML built at deploy time | Blogs, docs, marketing sites | Excellent |
| ISR (incremental static regen) | SSG + periodic background rebuild | Frequently updated content | Excellent |
| Prerendering | Headless browser pre-builds HTML | Legacy CSR apps as a workaround | Acceptable |
For most public-facing React sites, SSG or SSR via a meta-framework is the right path. CSR is fine for pages behind authentication where crawlers have no reason to visit.
Meta-frameworks that solve this for you
React doesn’t include a server or a build pipeline for static output. That’s what meta-frameworks are for.
Next.js is the most widely deployed React SSR/SSG framework. It offers SSG with getStaticProps, SSR with getServerSideProps, ISR, and the newer App Router with React Server Components. Most teams shipping public React sites should be on Next.js. See our Next.js SEO guide for the full setup.
Remix is built around the web platform, using standard fetch and progressive enhancement. Every route is server-rendered by default, which makes it SEO-safe out of the box. It handles metadata via the meta export on each route module.
Astro with React islands lets you write mostly static HTML and opt into React components only where interactivity is needed. Everything outside those islands is rendered to static HTML at build time. It’s the best choice when you want React for components but don’t need client-side routing everywhere.
Static export (no server required)
Next.js supports output: 'export' in next.config.js, which produces a fully static site you can host on any CDN. This is the right move for sites that don’t need per-request dynamic data. The output is plain HTML and is fully crawlable.
Vite doesn’t include a static site generator, but libraries like ViteSSG can render React routes to HTML at build time.
Prerendering as a retrofit
If you’re running an existing CSR app and can’t migrate to a meta-framework yet, prerendering (using a tool like Prerender.io or a custom Lambda that serves headless-rendered HTML to crawlers) is a workable stopgap. It’s more moving pieces than SSG and can go stale, but it’s better than leaving crawlers with an empty shell. This is covered in more depth in our single-page application SEO guide.
Managing the document head per route
Even if you get rendering right, you still need to control <title> and <meta> tags per route. A React SPA that uses a single static <title> in index.html will have every page indexed under the same title and description. That’s a significant ranking problem.
The useEffect trap
A common pattern in CSR React is to set the title in a useEffect:
useEffect(() => {
document.title = "Product Page | My Site";
}, []);
This works visually, but it doesn’t help crawlers. By the time useEffect runs, the page has already been serialised to HTML. If you’re doing SSR or SSG, the title needs to be part of the server-rendered output, not set on the client after hydration.
react-helmet-async
For apps using SSR where you control the rendering pipeline, react-helmet-async is the standard library for injecting head elements server-side. It works by collecting all <Helmet> declarations during a server render and injecting them into the HTML before it’s sent to the client.
import { Helmet } from "react-helmet-async";
function ProductPage({ product }) {
return (
<>
<Helmet>
<title>{product.name} | My Store</title>
<meta
name="description"
content={`Buy ${product.name}. ${product.summary}`}
/>
<link rel="canonical" href={`https://example.com/products/${product.slug}/`} />
</Helmet>
<main>
<h1>{product.name}</h1>
{/* page content */}
</main>
</>
);
}
On the server, you wrap your render with HelmetProvider and collect the helmet data:
import { renderToString } from "react-dom/server";
import { HelmetProvider } from "react-helmet-async";
const helmetContext = {};
const html = renderToString(
<HelmetProvider context={helmetContext}>
<App />
</HelmetProvider>
);
const { helmet } = helmetContext;
// Inject helmet.title.toString(), helmet.meta.toString() into your HTML template
Without the server-side collection step, react-helmet-async only updates the DOM on the client. It does nothing for crawlers.
React 19 built-in metadata
React 19 introduced native support for <title>, <meta>, and <link> tags directly inside components, without any third-party library. When these are rendered server-side (via a framework like Next.js with App Router or Remix), they’re automatically hoisted into <head>.
function BlogPost({ post }) {
return (
<article>
<title>{post.title} | My Blog</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={`https://example.com/blog/${post.slug}/`} />
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
If you’re on React 19 with a framework that supports it, this is the cleanest approach. No wrapper components, no library to maintain.
If you’re on Next.js App Router, use the built-in Metadata API instead. The Next.js SEO guide covers that in detail.
Real anchor links and client-side navigation
React Router and similar libraries use JavaScript to swap page content without a full browser navigation. That’s fine for users, but the navigation only works if the correct HTML is served for each URL at the server level.
Two things need to be true for crawlable client-side routing:
- Every URL must return a meaningful server-rendered or statically generated HTML response when requested directly. A CDN or server that returns the same
index.htmlfor all routes will give crawlers the same empty shell regardless of which URL they request. - Links must be real
<a href="...">anchor tags, not<div onClick>or<button onClick>handlers. Crawlers followhrefattributes. They don’t execute click handlers.
React Router renders <Link> components as proper <a> tags, which is correct. The problem is the server side: if your deployment serves a single index.html for all routes without pre-rendering those routes to HTML, crawlers hitting /products/shoes/ get the same empty shell as hitting /.
Sitemaps
React doesn’t generate a sitemap. You need to produce one separately.
For SSG builds (Next.js with static export, Astro, Gatsby), a library like next-sitemap can generate sitemap.xml at build time from your route list. For SSR apps, you can write a /sitemap.xml route that generates XML dynamically from your CMS or database.
The sitemap should list every URL that should be indexed, use absolute URLs, and include <lastmod> where you have reliable data. Submit it in Google Search Console once it’s live.
React SEO and AI citations
Getting into AI-generated answers (ChatGPT, Perplexity, Google AI Overviews) depends on the same foundation as Google rankings, but the bar is higher in one way: AI crawlers are more likely to skip JavaScript execution entirely. If your page is CSR-only, an AI crawler may never see your content at all.
SSR and SSG are more important than ever because AI systems that power AI search visibility pull from their training data and from real-time web crawls. A page that renders clean HTML immediately, has a clear heading structure, and contains well-organised factual content is far more likely to be cited than a page that requires JavaScript to show any text.
Schema markup also plays a role. A React component that renders a blog post, product, or FAQ should include the corresponding JSON-LD in the server-rendered output. That structured data gives AI systems a machine-readable summary of the page’s content without needing to parse prose.
Tools like Fokal track how often your pages are cited across ChatGPT, Perplexity, and Google AI Overviews over time, which makes it possible to see whether rendering or content changes are actually moving the needle.
What to do next
If you’re starting a new React project, choose Next.js or Remix and don’t look back. If you have an existing CSR app with public pages that need to rank, assess whether a migration to Next.js is feasible. For smaller apps, a static export from Next.js often works without changing your component code much. For large apps, a phased migration starting with the highest-traffic routes is usually the right call.
For the full platform SEO picture across JavaScript frameworks, the core principle is the same: crawlers need HTML, and that HTML needs to be the right HTML for the URL being requested.
React SEO checklist
- Use Next.js, Remix, or Astro instead of a plain CRA/Vite SPA for public pages
- Set a unique
<title>and<meta name="description">for every route, server-side - Use
react-helmet-asyncwith server-side collection, or React 19 native metadata tags - Never rely on
document.titleinuseEffectas your only metadata mechanism - Use
<a href>links, not click handlers, for navigation between pages - Ensure every route returns meaningful HTML when fetched directly (not just
index.html) - Generate and submit a sitemap
- Add JSON-LD schema to content pages rendered server-side
- Test with Google’s URL Inspection tool in Search Console to verify rendered output