React is the most popular frontend framework, but its client-side rendering model creates challenges for search engines. This guide covers how to build React applications that are fully crawlable by Google, Bing, and AI search platforms.
Why React needs special SEO attention
React apps render in the browser by default. When a search engine crawler requests a page, it receives:
<div id="root"></div>
<script src="/bundle.js"></script>
The actual content — headings, text, images — only appears after JavaScript executes. While Googlebot can execute JavaScript, the process is delayed, unreliable for complex apps, and completely unsupported by AI crawlers.
The solution: render HTML on the server so crawlers receive complete content without executing JavaScript.
Rendering strategies for React SEO
Option 1: Next.js (recommended)
Next.js provides server-side rendering, static generation, and incremental static regeneration out of the box:
// Static generation (best for content pages)
export async function generateStaticParams() {
return posts.map(post => ({ slug: post.slug }))
}
export default async function Post({ params }) {
const post = await getPost(params.slug)
return <article>{post.content}</article>
}
Best for: Most React projects. Next.js handles SSR, routing, image optimization, and metadata natively.
Option 2: Remix
Remix renders server-first with nested routing:
// Remix loader runs on the server
export async function loader({ params }) {
const post = await getPost(params.slug)
return json({ post })
}
export default function Post() {
const { post } = useLoaderData()
return <article>{post.content}</article>
}
Best for: Data-heavy applications where every route needs fresh server data.
Option 3: Astro with React islands
Astro renders static HTML and hydrates only interactive React components:
---
// Static HTML shell
const post = await getPost(Astro.params.slug)
---
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<!-- Only this component needs JavaScript -->
<CommentSection client:visible postId={post.id} />
</article>
Best for: Content sites where most of the page is static and only specific widgets need interactivity.
Option 4: Create React App + pre-rendering
For existing CRA projects, add pre-rendering with react-snap or prerender.io:
// package.json
{
"scripts": {
"postbuild": "react-snap"
},
"reactSnap": {
"source": "build"
}
}
Best for: Legacy CRA projects where migrating to Next.js isn't feasible immediately.
Managing meta tags in React
react-helmet-async (for non-Next.js projects)
import { Helmet } from "react-helmet-async"
function BlogPost({ post }) {
return (
<>
<Helmet>
<title>{post.title}</title>
<meta name="description" content={post.metaDescription} />
<link rel="canonical" href={`https://yoursite.com/blog/${post.slug}`} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.metaDescription} />
<meta property="og:type" content="article" />
</Helmet>
<article>{/* content */}</article>
</>
)
}
Important: react-helmet-async requires SSR to work for crawlers. Client-only Helmet updates happen after JavaScript executes, which crawlers may not see.
Next.js Metadata API
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.metaDescription,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: {
title: post.title,
description: post.metaDescription,
type: "article"
}
}
}
This renders meta tags in the initial HTML response — no client-side JavaScript needed.
React-specific SEO pitfalls
Pitfall 1: Client-side-only routing
// Bad: search engines can't discover linked pages
<div onClick={() => navigate("/about")}>About</div>
// Good: renders as crawlable <a> tag
<Link to="/about">About</Link>
Pitfall 2: Conditional rendering hiding content
// Bad: content hidden behind state that crawlers don't trigger
const [showMore, setShowMore] = useState(false)
return (
<div>
<p>Summary</p>
{showMore && <div>{/* detailed content */}</div>}
<button onClick={() => setShowMore(true)}>Show more</button>
</div>
)
// Better: render all content, use CSS to control visibility
return (
<div>
<p>Summary</p>
<div className={showMore ? "" : "visually-hidden"}>
{/* Content is in HTML, visible to crawlers */}
</div>
</div>
)
Pitfall 3: useEffect for data fetching on content pages
// Bad: data fetches after component mounts (client-side only)
function Post({ slug }) {
const [post, setPost] = useState(null)
useEffect(() => {
fetchPost(slug).then(setPost)
}, [slug])
if (!post) return <Loading />
return <article>{post.content}</article>
}
// Good: fetch data on the server
export async function getServerSideProps({ params }) {
const post = await fetchPost(params.slug)
return { props: { post } }
}
function Post({ post }) {
return <article>{post.content}</article>
}
Pitfall 4: Loading spinners as initial content
Crawlers that see only a spinner may index the page as having no content. Ensure SSR provides meaningful HTML before JavaScript loads.
Structured data in React
JSON-LD component pattern
function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
)
}
// Usage
;<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
datePublished: post.publishedAt,
author: { "@type": "Person", name: post.author }
}}
/>
Place the <JsonLd> component in the page's <Head> or at the top of the page component. For SSR frameworks, this ensures the schema appears in the initial HTML.
React performance for SEO
Code splitting
React.lazy with Suspense splits your bundle by component:
const HeavyChart = lazy(() => import("./HeavyChart"))
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)
}
Smaller initial bundles mean faster LCP and lower TBT.
Image handling
Use next/image (Next.js) or manual optimization:
// Always include width, height, and lazy loading
<img
src={optimizedUrl}
alt="Description"
width={800}
height={600}
loading="lazy"
decoding="async"
/>
Avoid hydration layout shifts
React hydration can cause CLS if the server-rendered HTML doesn't exactly match the client render. Common causes:
- Date/time formatting differences between server and client
- Browser-only APIs used during render (window.innerWidth)
- Random/non-deterministic content
Use useEffect for browser-only logic, not during the initial render.
Testing React SEO
View HTML source
The most important test. Right-click → View Page Source shows what crawlers receive. If your content appears here, crawlers can see it.
Google URL Inspection
In Search Console, inspect individual URLs to see how Google renders them. Compare the rendered HTML to what you expect.
curl test
# Check what crawlers receive
curl -s https://yoursite.com/blog/my-post | head -50
# Check for specific content
curl -s https://yoursite.com/blog/my-post | grep -c '<h1>'
Automated CI checks
# Verify SSR output
npm run build
npm start &
sleep 5
curl -s http://localhost:3000/blog/test | grep -q "Expected Title" || exit 1
Frequently Asked Questions
Does React hurt SEO?
React itself doesn't hurt SEO. Client-side-only rendering hurts SEO. Use SSR or SSG (via Next.js, Remix, or Astro) to serve complete HTML to crawlers. With proper server rendering, React sites achieve the same SEO capabilities as any other technology.
Should I use Next.js or Gatsby for SEO?
Next.js. It supports SSR, SSG, ISR, and streaming — giving you flexibility for any content type. Gatsby is SSG-only and has slower build times for large sites. The React ecosystem has largely converged on Next.js for production applications.
Can React Server Components help with SEO?
Yes. Server Components render on the server and send HTML to the client without JavaScript. They're ideal for content pages because the content is in the initial HTML response and requires zero client-side JavaScript to display.
How do I handle dynamic routes for SEO in React?
Use generateStaticParams (Next.js App Router) or getStaticPaths (Pages Router) to pre-render known routes. For truly dynamic routes (search results, filtered pages), use SSR to render on each request.
Related Resources
- JavaScript SEO — Core JavaScript SEO concepts
- SPA SEO Optimization — SEO for single-page applications
- Next.js SEO Setup Template — Ready-to-use Next.js SEO config
- JavaScript SEO Best Practices — Technical checklist