I spent way too long debugging SEO for React apps before I found the actual issue. I checked robots.txt, sitemap, canonical tags, everything except the one thing that mattered: my content didn't exist until JavaScript ran, and Googlebot wasn't always sticking around for that.
The fix wasn't a rewrite. It was a handful of small, boring changes. Here's what actually worked for React SEO, with code you can paste in today.
Why Your React App Is (Probably) Invisible to Search Engines
React apps render in the browser. Search engine crawlers fetch your HTML first, and a lot of them (especially non-Google crawlers like Bing, or social media link previews from Slack/Twitter/LinkedIn) don't execute JavaScript at all. Even Googlebot, which does execute JS, does it in a second pass that can be delayed by days.
Run this to see what a crawler sees before JS runs:
curl -s https://your-app.com | grep -A 5 "<body"
If you get <div id="root"></div> and nothing else, that's your problem. No title, no description, no content, just an empty shell.
Fixing Dynamic Meta Tags (Step 1 of React SEO)
The most common mistake: a single static <title> and <meta description> in index.html, shared across every route. Every page on your site looks identical to search engines.
Fix it with react-helmet-async, which lets each route own its own head tags:
npm install react-helmet-async
// App.jsx
import { HelmetProvider } from 'react-helmet-async';
function App() {
return (
<HelmetProvider>
<Routes>
<Route path="/product/:id" element={<ProductPage />} />
</Routes>
</HelmetProvider>
);
}
// ProductPage.jsx
import { Helmet } from 'react-helmet-async';
function ProductPage({ product }) {
return (
<>
<Helmet>
<title>{product.name} | My Store</title>
<meta name="description" content={product.shortDescription} />
<link rel="canonical" href={`https://mystore.com/product/${product.id}`} />
</Helmet>
<h1>{product.name}</h1>
</>
);
}
Result: every route now ships a unique, relevant title and description. Test it with curl again after deploying: you'll still see the empty shell if you're purely client-side rendered, which brings us to the bigger fix.
Pre-Rendering: Giving Crawlers Real HTML (Step 2 of React SEO)
Dynamic <Helmet> tags only get injected after JavaScript runs. If a crawler doesn't execute JS, none of it matters. You need actual HTML content in the initial response.
You have three realistic options:
1. Full SSR (Next.js, Remix): best long-term, but often means a framework migration.
2. Static pre-rendering at build time: if your content doesn't change per-request, generate static HTML for each route during your build:
npm install vite-plugin-prerender-spa --save-dev
// vite.config.js
import prerender from 'vite-plugin-prerender-spa';
export default {
plugins: [
prerender({
routes: ['/', '/about', '/product/1', '/product/2'],
}),
],
};
This runs a headless browser against your built app for each route and saves the fully rendered HTML to disk. Crawlers now get real content on the first request, no JS execution required.
3. Automated meta + prerender tooling: for teams that don't want to hand-maintain a route list or wire up a headless browser themselves, packages like @power-seo bundle the Helmet-style meta injection and static prerendering into one config step:
npm install @power-seo
// power-seo.config.js
module.exports = {
routes: 'auto', // scans your router for routes automatically
sitemap: true,
structuredData: true,
};
It's not doing anything you couldn't wire up yourself with Helmet + a prerender plugin. It's just fewer moving parts if you'd rather not maintain three separate tools.
Structured Data: The Most Overlooked Part of SEO for React Apps
Meta tags help you get indexed. Structured data (JSON-LD) helps you get rich results: star ratings, prices, breadcrumbs in the actual search listing.
function ProductPage({ product }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
"name": product.name,
"description": product.shortDescription,
"offers": {
"@type": "Offer",
"price": product.price,
"priceCurrency": "USD",
},
};
return (
<>
<Helmet>
<script type="application/ld+json">
{JSON.stringify(jsonLd)}
</script>
</Helmet>
<h1>{product.name}</h1>
</>
);
}
Validate it with Google's Rich Results Test before you ship. Malformed JSON-LD gets silently ignored, not flagged.
What I Learned
-
Check what crawlers actually see, not what your browser shows you.
curlis your friend here, always test the raw response before assuming JS execution will save you. - Meta tags fix discoverability; pre-rendering fixes visibility. You usually need both, not one or the other.
- Structured data is the highest ROI, lowest effort win. It doesn't affect ranking directly but it dramatically improves click-through rate from the results page.
-
Don't over-tool this. Whether you hand-roll it with Helmet + a prerender plugin, or use something like
@power-seoto bundle the steps, the underlying fix is always the same: real HTML, unique per-page metadata, valid structured data.
If you want to try this approach end-to-end, here's the full guide and repo for improving SEO in React apps.
Let's Talk
What's your current SEO setup for a React SPA: full SSR migration, static prerendering, or are you still shipping an empty <div id="root"> and hoping for the best? Drop your stack in the comments, I'm curious how many of us are quietly ignoring this until traffic forces the issue.
Top comments (0)