I shipped a React app, submitted it to Google Search Console, and watched it sit at zero impressions for six weeks. No crawl errors. No penalty. Just… nothing. Turns out, my entire app was rendering as a blank <div id="root"></div> to Googlebot. Classic. If you're building React SPAs and ignoring SEO infrastructure, this article will save you weeks of guesswork. We'll cover why React breaks SEO by default, how to audit it properly, how to fix the most common issues with real code, and how to track SEO health as part of your dev workflow.
Why React Kills Your SEO (And You Won't Notice Until It's Too Late)
Google's crawler is better at executing JavaScript than it used to be but it's still unreliable, slow, and inconsistent. The problem isn't just rendering. It's that React SPAs typically:
- Render a mostly empty HTML shell until JS loads
- Set
<title>and<meta>tags dynamically, after the initial parse - Generate no meaningful
<h1>or semantic structure until client-side hydration - Have zero
<link rel="canonical">tags by default
Google might eventually see your content. Or it might cache the blank shell. You won't know which until you're three months into a traffic investigation.
The fix isn't always SSR or Next.js (though those help). Often, it's just fixing the metadata layer and auditing what Googlebot actually receives.
Step 1: Audit What Google Actually Sees
Before fixing anything, you need to know what Googlebot is getting. The lazy way is Google Search Console's "URL Inspection" tool. The developer way is to fetch your page the same way a bot does.
curl -A "Googlebot/2.1 (+http://www.google.com/bot.html)" https://yoursite.com | grep -E "<title>|<meta|<h1"
If this returns nothing useful or just your index.html shell you have a rendering problem.
For a more structured audit, write a quick Node script that checks the critical SEO fields:
// audit-seo.mjs
import { JSDOM } from 'jsdom';
async function auditPage(url) {
const res = await fetch(url);
const html = await res.text();
const dom = new JSDOM(html);
const doc = dom.window.document;
const checks = {
title: doc.querySelector('title')?.textContent || '❌ MISSING',
description: doc.querySelector('meta[name="description"]')?.getAttribute('content') || '❌ MISSING',
canonical: doc.querySelector('link[rel="canonical"]')?.getAttribute('href') || '❌ MISSING',
h1: doc.querySelector('h1')?.textContent || '❌ MISSING',
ogTitle: doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || '❌ MISSING',
};
console.table(checks);
}
auditPage('https://yoursite.com');
npm install jsdom
node audit-seo.mjs
This gives you a fast, repeatable baseline. Run it against your staging URL before every deploy.
Step 2: Fix the Metadata Layer With React Helmet (or the Modern Alternative)
If you're on React 18+ with React Router, react-helmet-async is the standard approach for dynamic <head> management:
npm install react-helmet-async
Wrap your app:
// main.jsx
import { HelmetProvider } from 'react-helmet-async';
ReactDOM.createRoot(document.getElementById('root')).render(
<HelmetProvider>
<App />
</HelmetProvider>
);
Then in each route component:
// pages/BlogPost.jsx
import { Helmet } from 'react-helmet-async';
export default function BlogPost({ post }) {
return (
<>
<Helmet>
<title>{post.title} | My Blog</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={`https://yoursite.com/blog/${post.slug}`} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:type" content="article" />
</Helmet>
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
</>
);
}
Four lines of Helmet config was literally all it took to fix my blank-shell problem. Google saw a title, a description, a canonical URL and started indexing within two weeks.
Note: If you're on Next.js 13+ App Router, use the built-in metadata export instead of Helmet. Same concept, different API.
Step 3: Build a React SEO Checklist Into Your CI Pipeline
The real problem isn't fixing SEO once. It's that a junior dev ships a new page in two weeks and forgets the <title> tag. You need a React SEO checklist that runs automatically.
Here's a lightweight Playwright test you can add to your CI:
// tests/seo.spec.js
import { test, expect } from '@playwright/test';
const routes = ['/', '/about', '/blog', '/contact'];
for (const route of routes) {
test(`SEO basics: ${route}`, async ({ page }) => {
await page.goto(`https://yoursite.com${route}`);
// Title must exist and be meaningful
const title = await page.title();
expect(title.length).toBeGreaterThan(10);
expect(title.length).toBeLessThan(70);
// Meta description
const desc = await page.getAttribute('meta[name="description"]', 'content');
expect(desc).not.toBeNull();
expect(desc.length).toBeGreaterThan(50);
// Canonical
const canonical = await page.getAttribute('link[rel="canonical"]', 'href');
expect(canonical).not.toBeNull();
// One H1 per page
const h1s = await page.locator('h1').all();
expect(h1s.length).toBe(1);
});
}
This runs against your deployed URL and fails the build if any page is missing critical SEO tags. It's cheap, fast, and catches regressions before Google does.
Step 4: Track SEO Health as a Dev Metric (Not an Afterthought)
The above covers static checks. But SEO also means tracking what changes over time Core Web Vitals, crawl coverage, index status.
For this I've been using power-seo, a free React SEO tracking tool that hooks into your app and surfaces SEO issues at the component level during development. It's not a cloud service it runs locally and stays GDPR-compliant because nothing leaves your machine.
npm install power-seo
// Wrap your router in development
import { SEOProvider } from 'power-seo';
function App() {
return (
<SEOProvider debug={process.env.NODE_ENV === 'development'}>
<Router>
<Routes>
{/* your routes */}
</Routes>
</Router>
</SEOProvider>
);
}
In dev mode, it overlays a panel showing missing tags, duplicate titles, and viewport issues for the current route. In production, the provider strips itself out zero bundle impact.
It's one of those tools that earns its place not by doing something magical, but by making the checklist you'd otherwise do manually into something automatic.
What I Learned
- The blank shell problem is still real in 2025. Don't assume Google executes your JS. Audit with curl first, optimize second.
-
Dynamic metadata is table stakes. Every route needs its own
<title>,<meta description>, and<canonical>. react-helmet-async makes this straightforward. - Automate your React SEO checklist. Playwright SEO tests in CI catch regressions before they cost you rankings. Set them up once; forget about them.
-
Track SEO health in dev, not just in prod. Finding a missing
<h1>in a dev overlay is 100x cheaper than finding it three months later in Search Console.
If you want to try this approach, here's the repo: https://ccbd.dev/blog/free-react-seo-tracking-tool-thats-actually-gdpr-ready.
Your Turn
What's the most painful SEO bug you've shipped with a React app? Blank titles? Duplicate canonicals? A whole subdomain accidentally set to noindex? Drop it in the comments I'm genuinely curious whether the blank-shell issue is as common as I think, or if everyone else already knew to check for it and I was just late to the party.
Top comments (0)