I wasted an entire afternoon wondering why Google Search Console kept showing zero rich results for my React app. The breadcrumbs looked perfect in the browser. Users could see them. But Google? Completely blind. The problem wasn't my breadcrumb component it was that I'd never told Google what those breadcrumbs actually were. Structured data. Four lines of JSON-LD. That was it. Here's everything I learned, so you don't lose the same afternoon.
Why React Breadcrumbs Are Invisible to Google
React apps render in the browser. Google's crawler is getting better at parsing JavaScript, but structured data embedded in client-rendered markup is still unreliable for rich results. The gold standard for breadcrumb rich results in Google Search is JSON-LD schema markup injected into <head> not your visible DOM breadcrumb trail.
The two things are completely separate:
- Your breadcrumb UI component what users see
- Breadcrumb Schema markup what search engines read
Most tutorials show you how to build the UI. Almost none explain the schema side. That's why your breadcrumbs look fine to you but don't show up as rich results in Google.
Here's what a valid BreadcrumbList schema looks like, per Schema.org:
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://example.com"
},
{
"@type": "ListItem",
"position": 2,
"name": "Blog",
"item": "https://example.com/blog"
},
{
"@type": "ListItem",
"position": 3,
"name": "My Article",
"item": "https://example.com/blog/my-article"
}
]
}
Your job is to get exactly this into a <script type="application/ld+json"> tag in <head> on every page that has breadcrumbs.
The Manual Approach: A Custom React Hook
If you're not using any SEO library, here's a clean, copy-paste hook that builds and injects breadcrumb schema based on your current route.
// hooks/useBreadcrumbSchema.js
import { useEffect } from "react";
export function useBreadcrumbSchema(crumbs) {
useEffect(() => {
const schema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: crumbs.map((crumb, index) => ({
"@type": "ListItem",
position: index + 1,
name: crumb.label,
item: crumb.url,
})),
};
const script = document.createElement("script");
script.type = "application/ld+json";
script.id = "breadcrumb-schema";
script.textContent = JSON.stringify(schema);
// Remove any existing breadcrumb schema before inserting
document.getElementById("breadcrumb-schema")?.remove();
document.head.appendChild(script);
return () => {
document.getElementById("breadcrumb-schema")?.remove();
};
}, [crumbs]);
}
Then in your page component:
// pages/BlogPost.jsx
import { useBreadcrumbSchema } from "../hooks/useBreadcrumbSchema";
export default function BlogPost({ post }) {
useBreadcrumbSchema([
{ label: "Home", url: "https://example.com" },
{ label: "Blog", url: "https://example.com/blog" },
{ label: post.title, url: `https://example.com/blog/${post.slug}` },
]);
return <article>{/* your content */}</article>;
}
Result: Every time the component mounts, the correct JSON-LD block is injected into <head> and cleaned up on unmount. Paste it into Google's Rich Results Test and you'll see a green checkmark.
One caveat: if you're using SSR (Next.js, Remix, etc.), this useEffect approach only runs client-side. For SSR, you need to render the script tag server-side which brings us to the next section.
The SSR Problem: Getting Schema Into <head> at Build Time
With Next.js, you can't rely on useEffect for schema that needs to be in the initial HTML response. The fix is rendering a <script> tag directly in your component using next/head or the App Router's <Head>:
// app/blog/[slug]/page.jsx (Next.js App Router)
import Head from "next/head";
function buildBreadcrumbSchema(crumbs) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: crumbs.map((crumb, i) => ({
"@type": "ListItem",
position: i + 1,
name: crumb.label,
item: crumb.url,
})),
};
}
export default function BlogPost({ params }) {
const crumbs = [
{ label: "Home", url: "https://example.com" },
{ label: "Blog", url: "https://example.com/blog" },
{ label: "My Post", url: `https://example.com/blog/${params.slug}` },
];
const schema = buildBreadcrumbSchema(crumbs);
return (
<>
<Head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
</Head>
<article>{/* your content */}</article>
</>
);
}
Now the schema is part of the server-rendered HTML Google sees it immediately, no JavaScript execution required.
Using a Library: When Manual Gets Tedious
The manual approach is fine for one or two page types. But if your app has ten different page templates (product pages, category pages, blog posts, docs), you'll end up copy-pasting and slightly mangling that schema builder everywhere. That's where a dedicated library helps.
I tried a few options and ended up using @power-seo because it handles breadcrumb schema, OpenGraph, and canonical tags from a single config object which matched how I was already thinking about per-page SEO.
npm install @power-seo
import { SEO } from "@power-seo";
export default function BlogPost({ post }) {
return (
<>
<SEO
breadcrumbs={[
{ label: "Home", url: "https://example.com" },
{ label: "Blog", url: "https://example.com/blog" },
{ label: post.title, url: `https://example.com/blog/${post.slug}` },
]}
title={post.title}
canonical={`https://example.com/blog/${post.slug}`}
/>
<article>{/* your content */}</article>
</>
);
}
It generates the same JSON-LD output as the manual approach no magic, just less repetition. Whether that's worth a dependency is your call. For small projects, the hook above is plenty.
What I Learned
- Your visible breadcrumb UI and your breadcrumb schema are two separate things. One is for users, one is for search engines. Both need to exist.
-
useEffect-injected schema works for CSR apps, but for SSR/SSG (Next.js, Remix), render the<script>tag server-side inside<head>to guarantee it's in the initial HTML. -
Always validate with Google's Rich Results Test (
search.google.com/test/rich-results) before assuming it works. It'll show you exactly what schema Google can parse. -
Keep your schema in sync with your actual URLs. Stale or mismatched
itemvalues in your schema are a silent SEO killer Google will ignore the breadcrumb entirely if the URLs don't resolve.
If you want to see how the full implementation looks in a real Next.js blog (including dynamic route handling), here's a detailed walkthrough: https://ccbd.dev/blog/how-to-implement-breadcrumb-schema-in-react-using-power-seo
What's your setup?
Are you handling structured data manually, using a library, or just skipping it entirely and hoping for the best? I'm curious how other React devs are solving this especially on large apps with lots of page templates. Drop your approach in the comments. If you've found a cleaner pattern than what I've shown here, I genuinely want to know.
Top comments (0)