Google couldn't see my React app, and I had no idea why. I checked robots.txt, resubmitted my sitemap, blamed my hosting provider. The actual bug: my <title> tag said "React App" on every page, and my meta descriptions didn't exist at all.
If you've searched "SEO npm package for React," you're probably staring at the same problem, or trying to pick between the half-dozen open source SEO tools that all claim to fix it. I tested four of them on the same codebase so you don't have to guess. Here's what actually works, with code you can copy right now, and where each tool falls short.
Why React Needs a Dedicated SEO npm Package in the First Place
Client-side rendered React apps ship an almost-empty index.html:
<!DOCTYPE html>
<html>
<head>
<title>React App</title>
</head>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>
Your title, headings, and content only appear after JavaScript executes. Per Google's own documentation on JavaScript SEO, Googlebot does render JS, but rendering happens as a second wave, isn't guaranteed on every crawl, and other bots (Bing, LinkedIn, Slack unfurls) frequently skip it entirely. That's why a plain React SPA can rank inconsistently even with great content.
Check your own app right now:
curl -s https://your-react-app.com | grep -i "<title>"
If every route prints the same generic title, you've confirmed the bug. This is the exact problem every npm package in this article is trying to solve, just with different tradeoffs.
Option 1: react-helmet-async for Dynamic Meta Tags Per Route
This is the most widely adopted open source SEO tool in the React ecosystem, with over 1.5M weekly downloads on npm. It lets each route own its <head>.
npm install react-helmet-async
// App.jsx
import { HelmetProvider } from 'react-helmet-async';
function App() {
return (
<HelmetProvider>
<YourRoutes />
</HelmetProvider>
);
}
// BlogPost.jsx
import { Helmet } from 'react-helmet-async';
function BlogPost({ post }) {
return (
<>
<Helmet>
<title>{post.title} | My Blog</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.coverImage} />
</Helmet>
<article>{/* content */}</article>
</>
);
}
Tested result: inspecting <head> in dev tools confirms tags update correctly per route. But running the earlier curl test still returns the default title, because Helmet only updates the DOM after hydration. For crawlers that skip JS execution, this fix is invisible. That's the gap the next two tools address.
Option 2: Prerendering or SSR to Make the Fix Visible to Every Crawler
If you're on Next.js, Remix, or Astro, this comes free: the server sends complete HTML on first request. On plain Vite or CRA, prerender your static routes at build time instead:
// scripts/prerender.js
import puppeteer from 'puppeteer';
import fs from 'fs';
const routes = ['/', '/about', '/blog/my-first-post'];
const browser = await puppeteer.launch();
const page = await browser.newPage();
for (const route of routes) {
await page.goto(`http://localhost:3000${route}`, { waitUntil: 'networkidle0' });
const html = await page.content();
fs.mkdirSync(`dist${route}`, { recursive: true });
fs.writeFileSync(`dist${route}/index.html`, html);
}
await browser.close();
Run this as a post-build step. Tested result: re-running the curl command against the prerendered output now returns the correct, route-specific title and meta tags, with no JS execution required. This is the step that actually fixes crawler visibility, not just the DOM.
Option 3: Structured Data, Comparing next-seo, react-schemaorg, and @power-seo
Titles and descriptions get you indexed. JSON-LD structured data gets you rich results: star ratings, breadcrumbs, FAQ dropdowns. Hand-writing schema.org JSON for every content type gets tedious, so most React SEO npm packages bundle a helper for it.
| Package | Handles meta tags | Generates JSON-LD | Sitemap CLI | Weekly downloads |
|---|---|---|---|---|
next-seo |
Yes | Yes (manual shape) | No | ~500k |
react-schemaorg |
No | Yes (typed) | No | ~15k |
@power-seo |
Yes | Yes (generated from data) | Yes | Newer, growing |
I tested @power-seo on a product page because it combines meta tags and schema generation in one call, which cut down the boilerplate next-seo + react-schemaorg together would've required:
npm install @power-seo
import { SEOHead, generateSchema } from '@power-seo';
function ProductPage({ product }) {
const schema = generateSchema('Product', {
name: product.name,
price: product.price,
rating: product.rating,
reviewCount: product.reviewCount,
});
return (
<SEOHead
title={`${product.name} | Store`}
description={product.shortDescription}
jsonLd={schema}
/>
);
}
Tested result: pasting the rendered HTML into Google's Rich Results Testvalidated the Product schema with no errors. It also ships a CLI to generate sitemap.xml from your route config, which pairs naturally with the prerender step above. next-seo and react-schemaorg cover overlapping ground and are equally valid choices. Pick based on whether you want sitemap generation bundled in or prefer to keep concerns separate.
What I Learned Testing These Tools
-
Diagnose before picking a package. Run the
curltest first. "No dynamic tags" and "no crawlable HTML at all" are different problems with different fixes. -
No SEO npm package replaces SSR or prerendering.
react-helmet-async,next-seo, and@[power-seoall update](https://www.npmjs.com/org/power-seo) the DOM, but none of them fix a blank shell for non-JS crawlers on their own. - Structured data is the most skipped, highest-ROI step. It's usually one JSON object away from rich search results, and validating it takes two minutes with Google's Rich Results Test.
- "Best" depends on your stack, not hype. A meta-framework app needs different tooling than a plain Vite SPA. Match the package to the gap you actually have.
If you want to try this approach, here's the repo: https://ccbd.dev/blog/seo-npm-package-for-react-complete-guide
Let's Talk
Which open source SEO tool are you running in production? react-helmet-async, next-seo, prerendering, full SSR, or something else? What broke or surprised you when you tested it against real crawlers? Drop your setup in the comments.
Top comments (1)
The
curlcheck is a great practical tip. I’ve seen teams add meta tags and assume SEO is fixed, while the actual HTML response still contains the same generic title on every route.