Breadcrumb Navigation in React: JSON-LD, ARIA and Tailwind
Build accessible, SEO-ready breadcrumb navigation in React with JSON-LD structured data, proper ARIA roles, and clean Tailwind CSS styling.
Why Breadcrumbs Still Matter in 2026
Breadcrumbs are one of those UI patterns that quietly do a lot of heavy lifting. They help users orient themselves, they give Google a clear content hierarchy, and when you implement them right, they show up as those nice indented sitelinks in search results. That's real traffic.
Most teams get the visual part right — a row of links separated by slashes or chevrons. Fine. But they skip the structured data and accessibility markup, which means they're leaving both SEO and screen-reader users out in the cold.
In practice, a breadcrumb without aria-label="breadcrumb" on its <nav> is basically invisible to assistive tech. And a breadcrumb without JSON-LD is just decoration as far as Google's rich-result parser is concerned. This guide covers both, plus how to wire it all up cleanly with Tailwind.
The Core Component: ARIA-First Markup
Start with the HTML structure. The spec (WAI-ARIA 1.2) is explicit: you want a <nav> element with aria-label="Breadcrumb", an ordered list <ol>, and each item should be <li>. The current page — the last item — needs aria-current="page". Don't skip that last part; it's what tells screen readers where the user actually is.
Here's a clean, typed React component that handles all of it:
import { Fragment } from 'react';
type BreadcrumbItem = {
label: string;
href?: string;
};
type BreadcrumbProps = {
items: BreadcrumbItem[];
};
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="Breadcrumb">
<ol className="flex flex-wrap items-center gap-1 text-sm text-zinc-500">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<Fragment key={index}>
<li>
{isLast || !item.href ? (
<span
aria-current={isLast ? 'page' : undefined}
className={isLast ? 'text-zinc-900 font-medium' : undefined}
>
{item.label}
</span>
) : (
<a
href={item.href}
className="hover:text-zinc-900 transition-colors"
>
{item.label}
</a>
)}
</li>
{!isLast && (
<li aria-hidden="true" className="select-none">/</li>
)}
</Fragment>
);
})}
</ol>
</nav>
);
}A few things worth pointing out. The separator <li> has aria-hidden="true" so screen readers skip the slashes — you'd otherwise hear "Home slash Docs slash Getting Started" which is annoying. The select-none class on the separator prevents accidental copy-paste picking up all those slashes.
Worth noting: if your separator is an SVG chevron instead of a text character, you still want aria-hidden="true" on it. Decorative icons should never be read aloud.
Adding JSON-LD for Google Rich Results
The breadcrumb structured data schema is one of the simplest Google supports, but it has to be exact. You're injecting a <script type="application/ld+json"> tag into the document <head> with a BreadcrumbList type. Each step has a position, name, and item (the URL).
In Next.js App Router (13+), you drop this into your page component or a layout. Here's a companion component you can render alongside the visual breadcrumb:
type BreadcrumbJsonLdProps = {
items: { label: string; href: string }[];
baseUrl: string; // e.g. "https://empire-ui.com"
};
export function BreadcrumbJsonLd({ items, baseUrl }: BreadcrumbJsonLdProps) {
const schema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.label,
item: `${baseUrl}${item.href}`,
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}Plug both components into the same page and pass the same items array to both. Single source of truth, no drift between what users see and what Google parses.
Honestly, the most common mistake I see is people setting item to a relative path like /blog/react-hooks. Google's parser wants a fully qualified URL — https://yourdomain.com/blog/react-hooks. That's why the baseUrl prop matters.
Styling with Tailwind: Sizes, Colors, Responsive Behavior
The Tailwind classes in the component above give you a solid default, but let's talk about the details. text-sm puts you at 14px, which is about right — breadcrumbs shouldn't compete visually with the page heading. The gap-1 (4px) keeps items tight without cramping.
For dark mode, add dark:text-zinc-400 and dark:hover:text-zinc-100 to the link. If you're building on top of Empire UI's glassmorphism components, you'll want the breadcrumb to inherit the glass surface's text color rather than hardcoding zinc values — swap to text-inherit and let the parent handle it.
Responsive truncation is something most people ignore until a product manager complains. On narrow viewports, a five-level breadcrumb will wrap awkwardly. A clean pattern: hide middle items on sm: screens, show only the immediate parent and the current page. You can do this with a simple hidden sm:inline toggle on the middle <li> elements.
One more thing — don't go below 44px touch target height for breadcrumb links on mobile. The py-1 class gives you 4px top and bottom, which is fine on desktop but too tight on touch screens. Add py-2 in your mobile breakpoint or bump the parent's min-h to 44px.
Integrating with Next.js App Router and Dynamic Routes
In the App Router, you usually build breadcrumb data from the URL segments. usePathname() from next/navigation gives you the current path, and you split it into segments to build the items array. That said, raw segments are ugly — /blog/breadcrumb-navigation-react should display as "Breadcrumb Navigation in React", not as a slug.
You've got two options: a static label map, or pull the title from your page's metadata. The metadata approach is cleaner for large sites but requires a bit of wiring. For most projects, a label map keyed by segment slug is totally fine and zero runtime cost.
// hooks/useBreadcrumbs.ts
import { usePathname } from 'next/navigation';
const LABELS: Record<string, string> = {
blog: 'Blog',
tools: 'Tools',
templates: 'Templates',
glassmorphism: 'Glassmorphism',
// add more as needed
};
export function useBreadcrumbs() {
const pathname = usePathname();
const segments = pathname.split('/').filter(Boolean);
return [
{ label: 'Home', href: '/' },
...segments.map((seg, i) => ({
label: LABELS[seg] ?? seg.replace(/-/g, ' '),
href: '/' + segments.slice(0, i + 1).join('/'),
})),
];
}Quick aside: seg.replace(/-/g, ' ') is a fallback, not a solution. For production, you really want explicit labels for every segment that users will see. Slug-to-display transforms get weird fast — breadcrumb-navigation-react becomes "breadcrumb navigation react", which reads oddly without capitalisation logic on top.
Testing ARIA and Structured Data
Once you've built it, you need to verify it. For ARIA, the best tool is still the browser's accessibility tree. In Chrome DevTools, open the Elements panel, select your <nav>, and check the Accessibility tab. You should see role: navigation, name: Breadcrumb, and the current page item should have aria-current: page.
For JSON-LD, paste your page URL into Google's Rich Results Test. It'll parse the structured data and show you exactly what Google sees — including any errors. The validator is strict about the @context value, position ordering starting at 1 (not 0), and fully-qualified URLs.
You can also test structured data locally without deploying. Copy the raw HTML of your page and paste it into the "Code Snippet" tab of the Rich Results Test. Useful during development when you'd rather not push to prod just to check a schema.
If you're building out a full component system, browse the components to see how breadcrumbs fit into larger navigation patterns. Tools like the box shadow generator can help you style the active breadcrumb item if you want a subtle depth effect on the current-page label.
Common Mistakes and How to Avoid Them
The aria-label on <nav> is the one people miss most often. If you have multiple <nav> elements on a page — site nav, breadcrumb, pagination — they all need distinct labels. Screen reader users navigate by landmark, and "navigation" repeated three times tells them nothing.
Look, the other big one is duplicate JSON-LD. If you're injecting breadcrumb structured data in a layout and also in a page component, you'll end up with two BreadcrumbList blocks in the same document. Google doesn't hard-fail on this, but it's messy. Keep the JSON-LD close to the visual component and render it once.
Finally — structured data and visible content must match. If your JSON-LD says "Home > Blog > React" but your visible breadcrumb says "Home > Articles > React", Google can flag that as a mismatch. It's a surprisingly easy mistake when your label map gets out of sync with your JSON-LD name fields. Keep them both sourced from the same items array and you won't have this problem.
FAQ
Both serve different purposes — ARIA is for screen readers and accessibility tools, JSON-LD is for search engine rich results. Skipping either one means you're only doing half the job.
No. The current page shouldn't link to itself — it's redundant and confusing for keyboard users. Render it as a <span> with aria-current="page" instead.
Most commonly it's a relative URL in the item field — Google needs a fully-qualified URL like https://yourdomain.com/path. Also check that position starts at 1, not 0.
Yes — hidden in Tailwind sets display: none, which removes the element from the accessibility tree too, so screen readers won't hit it. Just make sure you're not hiding the entire nav on mobile without a replacement pattern.