Skip to content

SEO8 min read

React SEO: Why Your React SPA Isn't Ranking (and Fixes)

React SEO explained: why client-rendered React SPAs struggle in Google and how to fix it with SSR, SSG or prerendering, meta tags, sitemaps and JSON-LD.

Your React SPA probably is not ranking because the HTML your server sends is empty. A client-rendered React app returns a shell like <div id="root"></div>, and the title, text, links and meta tags only exist after JavaScript downloads and runs. Google can render JavaScript, but it does so in a separate, delayed step, and other crawlers and social preview bots often cannot. The fix is to ship real HTML: server-side rendering, static generation or build-time prerendering, plus unique meta tags, crawlable links, a sitemap and structured data.

I learned this on my own site. My previous portfolio was a client-rendered React app built with Vite, and its HTML was literally an empty <div id="root">. I rebuilt it to pre-render every page to static HTML, and the difference in what crawlers see is night and day. This post walks through why that happens and how to fix it in your own app.

Key takeaways

  • React is fine for SEO. Client-side rendering (CSR) is the problem, because the first HTML response has no content.
  • Google crawls, then renders JavaScript later in a queue, then indexes. CSR makes indexing slower and more fragile.
  • Many non-Google crawlers and link preview bots (Facebook, LinkedIn, Slack and others) read only the raw HTML.
  • Fix it with SSR, SSG or prerendering, so content and meta tags are in the initial HTML.
  • Also check the basics: real <a href> links, unique titles and descriptions, correct status codes, a sitemap and JSON-LD.

How Google processes JavaScript React apps

Google's JavaScript SEO basics describes three phases: crawling, rendering and indexing.

  1. Crawl. Googlebot fetches the URL and reads the raw HTML. In a CSR app that is an empty shell plus a script tag.
  2. Render. Pages that return 200 are queued for rendering. A headless Chromium later runs your JavaScript. Google says a page may stay in this queue for a few seconds, but it can take longer.
  3. Index. Google indexes the rendered HTML and extracts links to crawl next.

So yes, Google can see your React content. But every step adds risk: a JavaScript error, an API that times out, a blocked script, or content that only loads after a scroll or click means the rendered page is incomplete. And Google's own docs still say server-side or pre-rendering is a great idea because it is faster for users and crawlers, and not all bots can run JavaScript.

CSR vs SSR vs SSG vs prerendering for SEO

Rendering mode What the first HTML contains SEO Best for
CSR (client-side rendering) Empty <div id="root"> Weak: depends on JS rendering Dashboards and apps behind login
SSR (server-side rendering) Full page per request Strong Personalized or fast-changing pages
SSG (static generation) Full page built at deploy time Strong and fast Blogs, docs, marketing, portfolios
ISR / revalidation Static page refreshed in the background Strong Large catalogs, listings
Prerendering a SPA Static HTML per route generated at build Strong for listed routes Existing Vite SPAs with known routes
Dynamic rendering Bots get prerendered HTML, users get CSR Workaround only Legacy apps you cannot change yet

The rule of thumb: public pages that you want to rank should arrive as HTML. Logged-in screens can stay client-rendered, since search engines cannot see them anyway.

Fix 1: Render real HTML

Option A: a framework with SSR or SSG

Next.js is the most common choice. Pages in the App Router are Server Components by default, so the content is in the HTML without extra work. If you are migrating a Vite app, my comparison of Vite, Next.js and React Router helps you choose, and React Server Components explained covers the model behind it.

Option B: prerender your existing SPA

If a full migration is too much, React Router v7 in framework mode can prerender static routes at build time, even with SSR turned off:

TypeScript
// react-router.config.ts
import type { Config } from '@react-router/dev/config'

export default {
  ssr: false,
  async prerender() {
    const posts = await getAllPostSlugs()
    return ['/', '/about', '/blog', ...posts.map((slug) => `/blog/${slug}`)]
  },
} satisfies Config

Each listed path is rendered to its own index.html during the build, and your loader data is baked in. The app still hydrates and behaves like a SPA after load.

Option C: a custom build step

For small sites, you can render each route with renderToString from react-dom/server in a Node script and write the HTML files yourself. My own portfolio uses the same idea without React at all: a small Node script renders every page to static HTML at build time. It is more work than a framework, but it gives you full control and zero runtime server.

How to check what crawlers see

Do not trust what you see in the browser. Check the raw response:

Terminal
curl -s https://example.com/blog/my-post | grep -i -E "<title>|<h1|og:title"

If that returns nothing useful, crawlers that skip JavaScript see nothing useful either. For Google specifically, use the URL Inspection tool in Search Console and look at the rendered HTML and screenshot.

Fix 2: Unique meta tags in the initial HTML

Every indexable page needs its own <title>, <meta name="description">, canonical link and Open Graph tags. In a CSR app these are often set with a library after load, or not at all, so every route shares the title from index.html.

React 19 lets you render metadata tags anywhere in the tree, and React hoists them into <head>:

TSX
export function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <title>{`${post.title} | Sundar Gautam`}</title>
      <meta name="description" content={post.excerpt} />
      <link rel="canonical" href={`https://www.example.com/blog/${post.slug}/`} />
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  )
}

This is handy, but notice the catch: in a pure CSR app these tags still only exist after JavaScript runs. Combined with SSR or prerendering, they land in the initial HTML, which is what Facebook, LinkedIn and Slack previews need. In Next.js, use the Metadata API instead; I cover it in the Next.js SEO guide.

Google discovers pages by following <a> elements with an href. Things that look like links to users but are not links to crawlers:

TSX
// Bad: no href, crawlers cannot follow it
<div onClick={() => navigate('/pricing')}>Pricing</div>

// Bad: hash-based routes are treated as the same URL
<a href="#/pricing">Pricing</a>

// Good: a real link (React Router's Link renders an <a href>)
<Link to="/pricing">Pricing</Link>

Use the History API (which React Router's BrowserRouter and every modern framework use) instead of HashRouter. Google's docs explicitly say not to use fragments to load different page content.

Fix 4: Status codes and soft 404s

A SPA usually returns 200 OK for every path, including /this-does-not-exist, then shows a "not found" component. Google calls that a soft 404, and it wastes crawl budget on junk URLs.

Fixes, in order of preference:

  • With SSR or a static host, return a real 404 for unknown routes (most static hosts serve a 404.html with the correct status).
  • In a pure SPA, add <meta name="robots" content="noindex"> on the not-found view, or redirect with JavaScript to a URL that the server answers with a 404.

One warning from Google's docs: if your initial HTML contains noindex, Google may skip rendering entirely, so removing noindex later with JavaScript may not work. Never ship noindex in the base index.html and hope to remove it on the client.

Fix 5: Sitemap and robots.txt

A sitemap lists the URLs you want indexed, and it matters more for SPAs because link discovery is weaker. Generate it at build time from the same route list you prerender:

JavaScript
// scripts/sitemap.mjs
import { writeFileSync } from 'node:fs'

const base = 'https://www.example.com'
const routes = ['/', '/about/', '/blog/', '/blog/react-spa-seo/']

const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes.map((r) => `  <url><loc>${base}${r}</loc></url>`).join('\n')}
</urlset>`

writeFileSync('dist/sitemap.xml', xml)

Then point to it from robots.txt and submit it in Search Console:

Terminal
User-agent: *
Allow: /
Sitemap: https://www.example.com/sitemap.xml

Make sure robots.txt does not block your JavaScript or CSS bundles. If Google cannot fetch them, it cannot render the page.

Fix 6: Structured data with JSON-LD

Structured data helps Google understand what a page is: an article, a person, a product, a breadcrumb trail. Add it as JSON-LD in the HTML:

TSX
export function ArticleSchema({ post }: { post: Post }) {
  const data = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    datePublished: post.date,
    author: { '@type': 'Person', name: 'Sundar Gautam' },
  }

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
    />
  )
}

The .replace escapes < so a stray </script> in your content cannot break out of the tag. Validate the output with Google's Rich Results Test. Structured data does not guarantee rich results, but it removes ambiguity.

Fix 7: Performance is part of SEO

Page experience signals use Core Web Vitals from real users, and CSR apps tend to have slow LCP because nothing meaningful renders until JavaScript finishes. Prerendering usually improves LCP at the same time as indexing. I go deeper in Core Web Vitals for React apps.

Common React SEO mistakes

  • Same title and description on every route, inherited from index.html.
  • Content loaded in useEffect after mount, so even prerendered HTML is empty. Move data loading into loaders or Server Components.
  • Navigation built from buttons and onClick instead of links.
  • Using HashRouter for public pages.
  • Returning 200 for every URL.
  • Shipping noindex from staging to production. Check the live HTML after every deploy.
  • Relying on dynamic rendering long term. Google calls it a workaround, not a recommended solution.

FAQ

Is React bad for SEO?

React itself is not bad for SEO. The problem is client-side rendering, where the server sends an empty HTML shell and content only appears after JavaScript runs. React rendered on the server or prerendered at build time produces normal HTML that search engines index easily.

Can Google index a client-side rendered React app?

Usually yes. Googlebot queues pages for rendering in a headless Chromium and runs your JavaScript, but rendering can be delayed, can fail on errors or blocked resources, and many other crawlers and link preview bots do not run JavaScript at all.

Do I need Next.js for React SEO?

No. Any approach that puts real content and meta tags in the initial HTML works: Next.js, React Router framework mode with SSR or prerendering, Astro, or a build step that renders routes to static HTML. Next.js is just the most common option.

No. Google describes dynamic rendering (serving bots a different prerendered version) as a workaround, not a long-term solution, and recommends server-side rendering, static rendering or hydration instead.

Wrapping up

If your React app is not ranking, run curl on a page you care about and read the HTML. If the content is not there, fix rendering first, then titles, links, status codes, the sitemap and structured data. Most of the work is making sure the first response tells the full story, which is exactly what I had to do when I moved my own portfolio off a client-rendered SPA.

  • #seo
  • #react
  • #spa
  • #prerendering
  • #nextjs

Performance8 min read

Core Web Vitals for React: Fix INP, LCP and CLS

Core Web Vitals for React apps: how to measure and fix INP, LCP and CLS with real code, current thresholds, and React 19 and Next.js patterns that work.

Read article