DEV Community

Cover image for How to Fix SEO Issues in React Apps: The 4-Line Fix That Actually Worked
Mitu Das
Mitu Das

Posted on • Originally published at ccbd.dev

How to Fix SEO Issues in React Apps: The 4-Line Fix That Actually Worked

I spent way too long debugging why Google couldn't see my React app. The fix was 4 lines of code.

Turns out react-helmet was rendering meta tags after Googlebot had already taken its snapshot. My title, description, and Open Graph tags existed, just too late for anyone to notice.

If you've ever shipped a React app and watched it show up on Google as "Untitled Page," this one's for you. Below is a practical guide to how to fix SEO issues in React apps: the four recurring problems that actually tank rankings, and the working code that fixes each one. This is React SEO from the trenches, not the theory.

TL;DR: the 4 React SEO fixes covered here:

  1. Sync meta tags with the render cycle instead of a delayed library
  2. Pre-render or SSR your routes so crawlers see real HTML
  3. Generate a sitemap and add structured data (JSON-LD)
  4. Centralize SEO config once you outgrow doing it by hand

React SEO Issue #1: Meta Tags Load After the Crawler Leaves

React apps render on the client by default. That means your <title> and meta tags start as whatever's in your static index.html, usually nothing useful, and only get updated once JavaScript runs. Some crawlers wait for JS execution. Many don't wait long enough, or at all (think Slack previews, Twitter cards, some SEO auditing tools).

The fix isn't fancy. You need your meta tags set as early and reliably as possible, ideally synced with route changes:

// SEOHead.jsx
import { useEffect } from 'react';

function useSEO({ title, description, image }) {
  useEffect(() => {
    document.title = title;

    const setMeta = (name, content, isProperty = false) => {
      const attr = isProperty ? 'property' : 'name';
      let tag = document.querySelector(`meta[${attr}="${name}"]`);
      if (!tag) {
        tag = document.createElement('meta');
        tag.setAttribute(attr, name);
        document.head.appendChild(tag);
      }
      tag.setAttribute('content', content);
    };

    setMeta('description', description);
    setMeta('og:title', title, true);
    setMeta('og:description', description, true);
    if (image) setMeta('og:image', image, true);
  }, [title, description, image]);
}

export default useSEO;
Enter fullscreen mode Exit fullscreen mode

Usage in a page component:

function ProductPage({ product }) {
  useSEO({
    title: `${product.name} | MyStore`,
    description: product.shortDescription,
    image: product.imageUrl,
  });

  return <div>{/* page content */}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Result: Tags update synchronously with the render cycle instead of waiting on a third-party library's internal scheduling. It's not a silver bullet, crawlers that don't execute JS at all still won't see this, but it closes the timing gap that was actually costing me indexing.

React SEO Issue #2: Client-Side Rendering Leaves Crawlers an Empty Shell

This is the root cause behind most SEO issues in React apps. View source on a typical CRA or Vite React app and you'll see a <div id="root"></div> and nothing else. Googlebot can execute JavaScript, but plenty of other bots, link preview generators, and even Google's own indexing pipeline under load can fall back to the raw HTML.

The real fix is server-side rendering (SSR) or static generation, actually shipping HTML with content in it. If a full framework migration to Next.js isn't realistic right now, a lightweight middle ground is pre-rendering your critical routes at build time:

// prerender.js, run after your build step
import puppeteer from 'puppeteer';
import fs from 'fs';

const routes = ['/', '/pricing', '/about'];

async function prerender() {
  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();
    const outPath = route === '/' ? 'dist/index.html' : `dist${route}/index.html`;
    fs.mkdirSync(outPath.replace(/\/[^/]+$/, ''), { recursive: true });
    fs.writeFileSync(outPath, html);
  }

  await browser.close();
}

prerender();
Enter fullscreen mode Exit fullscreen mode

Result: Each route now has fully-rendered HTML sitting on disk, served instantly to any bot, no JS execution required. It's a workaround, not a permanent architecture, but it buys you real indexing improvements without a rewrite.

React SEO Issue #3: No Sitemap, No Structured Data, No Discovery Path

Even with perfect on-page React SEO, Google needs a map. A missing sitemap.xml means crawlers rely purely on internal links to find your pages, slow and unreliable for anything with dynamic routes (products, blog posts, user profiles).

Generate one from your route data instead of hand-writing it:

// generate-sitemap.js
import fs from 'fs';

const baseUrl = 'https://example.com';
const staticRoutes = ['/', '/pricing', '/about', '/contact'];
const dynamicRoutes = getAllProductSlugs().map(slug => `/products/${slug}`);

const urls = [...staticRoutes, ...dynamicRoutes];

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

fs.writeFileSync('public/sitemap.xml', xml);
Enter fullscreen mode Exit fullscreen mode

Add structured data (JSON-LD) alongside your meta tags for rich results:

function ProductSchema({ product }) {
  const schema = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    description: product.shortDescription,
    offers: {
      "@type": "Offer",
      price: product.price,
      priceCurrency: "USD",
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Result: Crawlers get an explicit list of every URL that matters, and eligible pages become candidates for rich snippets (star ratings, prices) in search results.

React SEO Issue #4: Manual Per-Page SEO Doesn't Scale

The patterns above work, but wiring up meta tags, JSON-LD, and sitemap entries by hand for every route in a growing app gets tedious fast, and it's easy to forget a page or ship inconsistent data.

This is the kind of repetitive wiring that a small config-driven package handles well. I tried @power-seo on a project with ~40 dynamic routes, and it basically does what useSEO and the JSON-LD component above do, but reads from a single route config instead of scattering logic across components:

import { PowerSEO } from '@power-seo';

<PowerSEO
  title={product.name}
  description={product.shortDescription}
  jsonLd={{ "@type": "Product", ...product }}
/>
Enter fullscreen mode Exit fullscreen mode

It's not required to fix your SEO, the raw code above works fine on its own, but if you've got dozens of routes, it saves you from re-writing the same boilerplate repeatedly.

Key Takeaways: How to Fix SEO Issues in React Apps

  • Meta tags need to exist before the crawler looks, not after JS finishes running. Timing matters more than the tags themselves.
  • Client-side rendering is the root cause of most React SEO problems. Pre-rendering or SSR fixes it at the source; everything else is a patch.
  • Sitemaps and structured data are cheap wins developers skip. They take an afternoon and meaningfully improve discoverability.
  • Manual per-page SEO wiring doesn't scale past a handful of routes. Centralize it in config once your route count grows.

Fix these four, in this order, and you've covered the vast majority of SEO issues in React apps, no framework rewrite required.

If you want to try this approach, here's the repo: https://github.com/CyberCraftBD/power-seo

Let's Talk React SEO

What's the worst React SEO bug you've run into, and how long did it take you to find it? Drop it in the comments, I'm collecting horror stories for a follow-up post.

Top comments (0)