DEV Community

Cover image for Every Page of My React App Had the Same Title (Here's the Fix)
Mitu Das
Mitu Das

Posted on

Every Page of My React App Had the Same Title (Here's the Fix)

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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_image gives 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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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"
/>
Enter fullscreen mode Exit fullscreen mode

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 }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode
<JsonLd
  data={{
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    datePublished: post.publishedAt,
    author: { '@type': 'Person', name: post.author },
    image: post.coverImage,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)