Build Accessible Breadcrumbs with Claude Code
Implement breadcrumbs with Claude Code, React, Astro, JSON-LD, aria-current, mobile CSS, and tests.
Breadcrumbs look like a tiny piece of UI above the article title. In production, they touch site architecture, internal links, accessibility, structured data, and mobile layout. A weak implementation can still look fine while screen readers miss the current page, Google receives invalid BreadcrumbList markup, or long titles push the first paragraph off a phone screen.
When I tried this pattern on ClaudeCodeLab templates, the first Claude Code draft drew Home > Blog > Title, but it missed aria-current, used relative URLs in JSON-LD, wrapped badly on mobile, and displayed raw slugs for dynamic routes. Claude Code can fix those issues, but the prompt must describe the contract before implementation starts.
This guide shows how to ask Claude Code to scaffold and review accessible breadcrumbs for React, Next.js-like, and Astro sites. Pair it with SEO optimization, accessibility implementation, Astro development, and React development.
Design Contract
A breadcrumb is not a replacement for the browser back button. It communicates hierarchy, gives users a fast path to parent pages, and helps search engines understand page relationships. The WAI-ARIA APG Breadcrumb Pattern describes breadcrumbs as a navigation landmark with a label, and it uses aria-current="page" for the current page link.
Structured data should follow schema.org BreadcrumbList. The list represents a chain of web pages, and each ListItem needs a position so the order is unambiguous. If you want Google Search to use the breadcrumb trail, keep the official Google Search Central breadcrumb structured data page open while reviewing.
| Decision | What to define | Common failure |
|---|---|---|
| Labels | Human names from titles, categories, or a dictionary | Raw slugs like claude-code-breadcrumb-navigation appear |
| URLs | Relative links for HTML, absolute URLs for JSON-LD | Structured data contains relative paths |
| Current page | Link or text for the final item | Current state is shown only by color |
| Mobile | Full trail or collapsed middle items | Breadcrumb wraps into three lines |
| Locales | Route prefix and translated labels | Japanese labels leak into English pages |
Prompt Claude Code
Ask for the data builder, UI, structured data, responsive CSS, and tests together. A vague “make breadcrumbs” prompt usually produces a decorative component rather than a reliable site primitive.
Implement breadcrumbs for a React/Next.js-like or Astro site.
Requirements:
- Accept items as { label: string; href: string }[].
- Add aria-current="page" to the final item.
- Put the trail in nav aria-label="Breadcrumb".
- Mark separators aria-hidden="true".
- Generate JSON-LD BreadcrumbList from the same items array.
- Convert JSON-LD item URLs to absolute URLs using siteUrl.
- Add a helper that builds items from URL pathname.
- Format slugs for humans and allow a label dictionary override.
- Collapse middle items on mobile while keeping the current page readable.
- Add Vitest coverage for root, nested paths, localized labels, and query strings.
- After implementation, list accessibility and structured-data checks.
This turns Claude Code into a scaffold-and-review assistant instead of a snippet generator. For larger changes, run a second review pass using the Claude Code review workflow checklist.
React Component
Create components/Breadcrumb.tsx. Pass siteUrl without a trailing slash, such as https://example.com. The same items array powers both visible links and JSON-LD, which avoids drift during category or slug changes.
import type { ReactNode } from "react";
export type BreadcrumbItem = {
label: string;
href: string;
};
type BreadcrumbProps = {
items: BreadcrumbItem[];
siteUrl: string;
ariaLabel?: string;
};
function toAbsoluteUrl(siteUrl: string, href: string) {
return new URL(href, siteUrl).toString();
}
function Separator(): ReactNode {
return (
<span className="breadcrumb__separator" aria-hidden="true">
/
</span>
);
}
export function Breadcrumb({
items,
siteUrl,
ariaLabel = "Breadcrumb",
}: BreadcrumbProps) {
if (items.length <= 1) return null;
const jsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@id": toAbsoluteUrl(siteUrl, item.href),
name: item.label,
},
})),
};
return (
<>
<nav className="breadcrumb" aria-label={ariaLabel}>
<ol className="breadcrumb__list">
{items.map((item, index) => {
const isCurrent = index === items.length - 1;
return (
<li className="breadcrumb__item" key={item.href}>
{index > 0 ? <Separator /> : null}
{isCurrent ? (
<span className="breadcrumb__current" aria-current="page">
{item.label}
</span>
) : (
<a className="breadcrumb__link" href={item.href}>
{item.label}
</a>
)}
</li>
);
})}
</ol>
</nav>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</>
);
}
The final item is rendered as text, so aria-current is technically optional in the APG pattern. I still add it because it makes the current state explicit and easy to test. Separators are decorative, so they are hidden from assistive technology.
Build Items from Routes
The helper below handles query strings, trailing slashes, encoded segments, and label overrides. It is intentionally small so Claude Code can adapt it to a CMS, file-based routes, or a content collection.
import type { BreadcrumbItem } from "@/components/Breadcrumb";
export type BreadcrumbLabels = Record<string, string>;
function titleize(segment: string) {
return decodeURIComponent(segment)
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
export function buildBreadcrumbs(
pathname: string,
labels: BreadcrumbLabels = {},
): BreadcrumbItem[] {
const cleanPath = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
const segments = cleanPath.split("/").filter(Boolean);
const items: BreadcrumbItem[] = [
{ label: labels["/"] ?? "Home", href: "/" },
];
let href = "";
for (const segment of segments) {
href += `/${segment}`;
items.push({
label: labels[href] ?? labels[segment] ?? titleize(segment),
href,
});
}
return items;
}
Use full-path labels for exact routes and segment labels for broad defaults. For a blog, the final label should usually come from frontmatter or CMS data, not from the slug.
Next.js Usage
In a Next.js App Router page, build labels from route data, content metadata, or database records. The example is static so it can be pasted into a sandbox, but the same shape works with real content.
import { Breadcrumb } from "@/components/Breadcrumb";
import { buildBreadcrumbs } from "@/lib/breadcrumbs";
const siteUrl = "https://claudecodelab.com";
export default async function ArticlePage() {
const pathname = "/en/blog/claude-code-breadcrumb-navigation";
const labels = {
"/": "Home",
"/en": "English",
"/en/blog": "Articles",
"/en/blog/claude-code-breadcrumb-navigation":
"Build Accessible Breadcrumbs with Claude Code",
};
const items = buildBreadcrumbs(pathname, labels);
return (
<main>
<Breadcrumb items={items} siteUrl={siteUrl} />
<h1>Build Accessible Breadcrumbs with Claude Code</h1>
</main>
);
}
Do not hard-code route strings across the whole app. Tell Claude Code which source is canonical: Next.js route params, Astro’s Astro.url.pathname, React Router’s useLocation(), or CMS metadata.
Astro Version
Static Astro blogs can avoid React entirely. The same contract works in a .astro component, and JSON-LD is emitted at build time.
---
type BreadcrumbItem = {
label: string;
href: string;
};
const { items, siteUrl, ariaLabel = "Breadcrumb" } = Astro.props as {
items: BreadcrumbItem[];
siteUrl: string;
ariaLabel?: string;
};
const jsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@id": new URL(item.href, siteUrl).toString(),
name: item.label,
},
})),
};
---
{items.length > 1 && (
<>
<nav class="breadcrumb" aria-label={ariaLabel}>
<ol class="breadcrumb__list">
{items.map((item, index) => {
const isCurrent = index === items.length - 1;
return (
<li class="breadcrumb__item">
{index > 0 && <span class="breadcrumb__separator" aria-hidden="true">/</span>}
{isCurrent ? (
<span class="breadcrumb__current" aria-current="page">{item.label}</span>
) : (
<a class="breadcrumb__link" href={item.href}>{item.label}</a>
)}
</li>
);
})}
</ol>
</nav>
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</>
)}
Mobile CSS
Keep the DOM and JSON-LD complete, but collapse middle visual items on small screens. The current page remains readable and truncated instead of pushing the article down.
.breadcrumb {
margin-block: 0 1rem;
font-size: 0.875rem;
color: #4b5563;
}
.breadcrumb__list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.breadcrumb__item {
align-items: center;
display: inline-flex;
min-width: 0;
}
.breadcrumb__link {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 0.15em;
}
.breadcrumb__current {
color: #111827;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb__separator {
color: #9ca3af;
margin-inline: 0.35rem;
}
@media (max-width: 640px) {
.breadcrumb__list {
flex-wrap: nowrap;
}
.breadcrumb__item:not(:first-child):not(:nth-last-child(-n + 2)) {
display: none;
}
.breadcrumb__item:nth-last-child(2)::after {
color: #9ca3af;
content: "...";
margin-inline: 0.35rem;
}
.breadcrumb__current {
max-width: 58vw;
}
}
Tests
Unit-test the route builder, then use Testing Library or Playwright for rendered semantics. A visual check alone will miss most breadcrumb defects.
import { describe, expect, it } from "vitest";
import { buildBreadcrumbs } from "./breadcrumbs";
describe("buildBreadcrumbs", () => {
it("returns only Home for the root path", () => {
expect(buildBreadcrumbs("/")).toEqual([{ label: "Home", href: "/" }]);
});
it("builds nested breadcrumbs and ignores query strings", () => {
expect(buildBreadcrumbs("/blog/claude-code?page=2")).toEqual([
{ label: "Home", href: "/" },
{ label: "Blog", href: "/blog" },
{ label: "Claude Code", href: "/blog/claude-code" },
]);
});
it("uses localized labels when provided", () => {
expect(
buildBreadcrumbs("/en/blog/claude-code-breadcrumb-navigation", {
"/": "Home",
"/en": "English",
"/en/blog": "Articles",
"/en/blog/claude-code-breadcrumb-navigation": "Accessible breadcrumbs",
}),
).toEqual([
{ label: "Home", href: "/" },
{ label: "English", href: "/en" },
{ label: "Articles", href: "/en/blog" },
{
label: "Accessible breadcrumbs",
href: "/en/blog/claude-code-breadcrumb-navigation",
},
]);
});
});
For E2E, verify nav[aria-label], one aria-current="page", parseable application/ld+json, absolute URLs, and no overlap at mobile width. The Playwright testing guide is the natural follow-up.
Real Use Cases
First, blogs and documentation sites. A trail such as Home > Articles > Claude Code > Title helps readers move back to a topic page and strengthens internal discovery.
Second, ecommerce and paid training pages. Home > Training > Claude Code > Team Workshop gives visitors a clean route from educational content to a commercial offer. For ClaudeCodeLab, that means connecting tutorial traffic to training and consultation without relying only on ads.
Third, SaaS and admin screens. Deep paths like Organization > Project > Settings > Billing reduce confusion in sensitive workflows where users need to know exactly which scope they are editing.
Fourth, multilingual sites. The route prefix, labels, and JSON-LD name values must be localized together. Do not reuse Japanese or English labels blindly across all locales.
Pitfalls
The most common issue is showing the current page only through color. Add aria-current="page" and make the current item semantics explicit.
The second issue is relative JSON-LD URLs. HTML links can be relative, but structured data should be generated from a known production siteUrl.
The third issue is raw slug output. Use titles, category names, product names, or a label dictionary so the breadcrumb reads like navigation, not a filesystem path.
The fourth issue is a mobile trail that wraps forever. Collapse middle items visually while keeping the complete hierarchy in the DOM and JSON-LD.
The fifth issue is duplicate data sources. If visible breadcrumbs and JSON-LD come from different arrays, they will drift after category moves. Generate both from the same items.
Pre-Publish Checklist
navhas a cleararia-label- The current page has
aria-current="page" - Separators are hidden from assistive technology
- JSON-LD uses
@type: "BreadcrumbList" positionstarts at 1 and stays ordered- JSON-LD item URLs are absolute production URLs
- Visible trail and JSON-LD describe the same hierarchy
- Mobile layout does not overlap the title or intro
- Locale labels and URLs match the current language
- Rich Results Test or Search Console validates the markup
Ask Claude Code for a final critical review against WAI-ARIA APG, schema.org BreadcrumbList, and Google Search breadcrumb structured data. The second pass is where small but expensive mistakes usually surface.
CTA and Verification
Breadcrumbs are a small component, but they support a larger content business. On ClaudeCodeLab, they help readers understand the topic they are in before moving to the free cheatsheet, products, or Claude Code training and consultation. If your team wants the prompt, review checklist, and rollout pattern adapted to your repository, the training path is the paid next step.
I verified the code in this article by separating the React component, route helper, and Vitest examples. The most useful design choice was generating visible links and JSON-LD from the same items array. In an earlier draft, separate arrays drifted after a category rename; with one source of truth, review became much simpler.
Summary
Accessible breadcrumbs require more than drawing > between links. Give Claude Code the complete contract: labels, routes, aria-current, JSON-LD, absolute URLs, mobile behavior, and tests. Then review the generated diff against the official accessibility and structured-data sources before publishing.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.