I spent five hours debugging why Google couldn't tell my React pages apart. The fix was four lines of code. Every route was serving the same <title> from index.html, and no amount of clever components was changing it.
If your Search Console shows "Crawled, not indexed" and your link previews look blank, this is probably why. Below: why client-rendered React breaks head tags, how to set per-page titles, canonicals, and Open Graph, how to add JSON-LD, and where an SEO npm package for React earns its keep.
Why your React app has one title for every page
A client-rendered React app mounts into one <div id="root">. Titles, descriptions, and Open Graph tags live in <head>, outside that tree, so every route inherits whatever is hard-coded in index.html.
Check yours in ten seconds: open View Source on three different routes. If the <title> is identical, you have the problem. Google can render JavaScript, but rendering happens after crawling and can lag. Many social preview bots don't run JS at all.
Here's the fastest fix. React 19 hoists <title>, <meta>, and <link> into the head natively:
// BlogPost.jsx (React 19+)
export default function BlogPost({ post }) {
const url = `https://example.com/blog/${post.slug}`;
return (
<article>
<title>{`${post.title} | My Site`}</title>
<meta name="description" content={post.excerpt} />
<link rel="canonical" href={url} />
<h1>{post.title}</h1>
{/* content */}
</article>
);
}
Result: the tab title and head tags now change per route in the rendered DOM. On React 18 or below, react-helmet-async does the same job with a <Helmet> wrapper and a <HelmetProvider> at your root.
One caveat: this fixes the rendered DOM. For bots that skip JavaScript, you still want SSR, SSG, or prerendering. Head management and rendering strategy are separate problems.
Canonical URLs, Open Graph, and Twitter Cards
Once titles work, three tags cause most of the remaining pain:
-
Canonical: SPAs often serve one page at
/page,/page/, and/page?ref=twitter. A canonical tells search engines which one counts. Google treats it as a strong hint, not a command. - Open Graph: controls link previews on Slack, LinkedIn, Facebook, and most messengers. Use a 1200×630 image.
-
Twitter Cards:
summary_large_imagegives you the big preview.
Here's a reusable component so you don't copy-paste tags into every page:
// Seo.jsx
export function Seo({ title, description, url, image }) {
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:image" content={image} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
</>
);
}
// Usage
<Seo
title="React SEO Guide | My Site"
description="Meta tags, canonicals, and JSON-LD for React apps."
url="https://example.com/blog/react-seo"
image="https://example.com/og/react-seo.png"
/>
Result: paste any URL into a link-preview debugger and you should see the right title, description, and image. Keep descriptions around 150–160 characters and titles under roughly 60, since search results truncate both.
JSON-LD structured data
Structured data tells search engines what a page is: an article, a product, a FAQ. Google recommends JSON-LD as the format. Valid markup makes you eligible for rich results, but it doesn't guarantee them.
The catch with hand-written JSON-LD is escaping. If your data contains </script>, you've broken the page. This component handles it:
// JsonLd.jsx
export function JsonLd({ data }) {
const json = JSON.stringify(data).replace(/</g, '\\u003c');
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: json }}
/>
);
}
<JsonLd
data={{
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
author: { '@type': 'Person', name: post.author },
image: post.coverImage,
}}
/>
Result: run the page through Google's Rich Results Test and the Article should parse without errors. Misspell a property and nothing complains, though. Nothing here is typed or validated.
When hand-rolled tags stop scaling
The pieces above work fine for a small site. At around 30 routes, I hit the annoying parts: forgetting a canonical on one page, typing a bad robots directive that nobody noticed, and repeating the same defaults everywhere.
That's when a dedicated SEO npm package for React makes sense. It only has to do three things well: site-wide defaults, typed props, and structured data helpers. I've been using @power-seo/react plus its schema package. It's MIT-licensed and works in Vite, Gatsby, and other non-Next setups. The whole setup looks like this:
npm install @power-seo/react @power-seo/schema
import { DefaultSEO, SEO, Robots } from '@power-seo/react';
import { ArticleJsonLd } from '@power-seo/schema/react';
function App() {
return (
<DefaultSEO
titleTemplate="%s | My Site"
defaultTitle="My Site"
openGraph={{ type: 'website', siteName: 'My Site' }}
twitter={{ site: '@mysite', cardType: 'summary_large_image' }}
>
<Router>
<Routes />
</Router>
</DefaultSEO>
);
}
function BlogPost({ post }) {
return (
<>
<SEO
title={post.title}
description={post.excerpt}
canonical={`https://example.com/blog/${post.slug}`}
openGraph={{
type: 'article',
images: [{ url: post.coverImage, width: 1200, height: 630 }],
}}
/>
<Robots index follow maxImagePreview="large" maxSnippet={160} />
<ArticleJsonLd
headline={post.title}
datePublished={post.publishedAt}
author={{ name: post.author, url: 'https://example.com/authors/jane' }}
/>
<article>{/* content */}</article>
</>
);
}
Result: defaults cascade, each page overrides only what it needs, and a typo like maxImagePreview="huge" fails at build time instead of shipping. That typed safety net is the main reason I switched. I also wrote a longer comparison of head managers vs. toolkits for the same problem.
Disclosure: power-seo is built by the team at CyberCraft Bangladesh (ccbd.dev), where I work with it. If you're on Next.js App Router, the built-in Metadata API may be all you need.
What I learned
-
View source first. Compare
<title>across several routes before touching any code. - Head tags and rendering are separate problems. Fix per-route metadata, then decide whether you need SSR or prerendering.
- Canonicals matter more than they look. Add one to every indexable page from day one.
- Type your SEO props. Silent typos in robots or schema are the bugs you find months later.
If you want to try this approach, here's the repo: https://github.com/CyberCraftBD/power-seo
Your turn
What's the worst React SEO bug you've shipped: blank previews, a stray noindex, or something stranger? And are you handling head tags with React 19 natively, Helmet, or a toolkit? Drop it in the comments. I'm collecting war stories.
Top comments (0)