Dynamic Breadcrumbs in React with Modern Routing

Build dynamic, accessible, SEO-friendly breadcrumbs in React using React Router v6+, loaders, and route handles—complete patterns, code, and pitfalls.

ASOasis
7 min read
Dynamic Breadcrumbs in React with Modern Routing

Image used for representation purposes only.

Why breadcrumbs matter in React apps

Breadcrumbs give users a compact sense of place and a quick way to move up the information hierarchy. In multi-level UIs—think projects → issues → details—they reduce pogo-sticking, improve discoverability, and can even enhance SEO when paired with structured data.

This guide shows how to build dynamic, accessible, and SEO-friendly breadcrumbs in React, focusing on React Router v6+ patterns that work with nested routes and data loaders.

Approaches to generating breadcrumbs

There are three common strategies:

  • URL-splitting: Derive labels by splitting location.pathname (fast to prototype, poor for localization and dynamic labels).
  • Route metadata: Store a breadcrumb label or generator on each route (recommended for most apps).
  • Data-driven: Fetch entity names (e.g., project name) via route loaders and render from that data (best UX for dynamic segments).

We’ll center on route metadata and data-driven patterns.

Prerequisites

  • React 18+
  • React Router v6.4+ (data routers). These examples use createBrowserRouter and Loader functions.

Install:

npm install react-router-dom

The core idea: route “handles” for breadcrumbs

React Router lets you attach arbitrary metadata to a route via handle. A handle can carry a static label or a function that receives the active match. With useMatches(), you can read the matched routes top-to-bottom and compose your breadcrumb trail.

Define routes with breadcrumb handles

// router.jsx
import {
  createBrowserRouter,
  RouterProvider,
} from "react-router-dom";
import AppLayout from "./AppLayout";
import Home from "./Home";
import Projects from "./Projects";
import ProjectLayout from "./ProjectLayout";
import ProjectOverview from "./ProjectOverview";
import Issues from "./Issues";
import IssueDetails from "./IssueDetails";

async function projectLoader({ params }) {
  // Fetch the project once; reuse its name in UI (and breadcrumbs)
  const res = await fetch(`/api/projects/${params.projectId}`);
  if (!res.ok) throw new Response("Not Found", { status: 404 });
  return res.json();
}

async function issueLoader({ params }) {
  const res = await fetch(`/api/projects/${params.projectId}/issues/${params.issueId}`);
  if (!res.ok) throw new Response("Not Found", { status: 404 });
  return res.json();
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <AppLayout />,
    handle: { breadcrumb: "Home" },
    children: [
      { index: true, element: <Home /> },
      {
        path: "projects",
        element: <Projects />,
        handle: { breadcrumb: "Projects" },
      },
      {
        path: "projects/:projectId",
        element: <ProjectLayout />,
        loader: projectLoader,
        handle: {
          // Use loader data for a friendly label; fallback to the ID
          breadcrumb: (match) => match.data?.name ?? `Project ${match.params.projectId}`,
        },
        children: [
          { index: true, element: <ProjectOverview />, handle: { breadcrumb: "Overview" } },
          {
            path: "issues",
            element: <Issues />,
            handle: { breadcrumb: "Issues" },
          },
          {
            path: "issues/:issueId",
            loader: issueLoader,
            element: <IssueDetails />,
            handle: {
              breadcrumb: (match) => match.data?.title ?? `#${match.params.issueId}`,
            },
          },
        ],
      },
    ],
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

Render breadcrumbs with useMatches

// Breadcrumbs.jsx
import { Link, useMatches } from "react-router-dom";

export default function Breadcrumbs() {
  const matches = useMatches();

  const items = matches
    .filter((m) => m.handle?.breadcrumb)
    .map((m, index, arr) => {
      const label =
        typeof m.handle.breadcrumb === "function"
          ? m.handle.breadcrumb(m)
          : m.handle.breadcrumb;
      const isLast = index === arr.length - 1;
      return (
        <li key={m.pathname} aria-current={isLast ? "page" : undefined}>
          {isLast ? (
            <span>{label}</span>
          ) : (
            <Link to={m.pathname}>{label}</Link>
          )}
        </li>
      );
    });

  return (
    <nav aria-label="Breadcrumb">
      <ol className="breadcrumbs">{items}</ol>
    </nav>
  );
}

This approach:

  • Preserves route hierarchy automatically.
  • Reuses loader data (no duplicate fetches for labels).
  • Supports static and dynamic labels.

Accessibility and UX details

  • Use a nav with aria-label=“Breadcrumb” wrapping an ordered list.
  • Do not link the current page crumb; set aria-current=“page” on it.
  • Prefer CSS-generated separators to avoid reading extraneous characters.

Example CSS:

.breadcrumbs {
  display: flex;
  flex-wrap: wrap;
  list-style: none;
  gap: .25rem .5rem;
  padding: 0;
  margin: 0;
}
.breadcrumbs li:not(:last-child) a::after {
  content: "/";
  margin-left: .5rem;
  color: var(--muted);
}
.breadcrumbs li { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

Handling loading states for dynamic crumbs

When a route is pending, the loader data may be undefined. Provide a graceful fallback in the breadcrumb handle function.

You can also show a skeleton while awaiting data below the route. If you use + patterns, keep the breadcrumb label resilient:

handle: {
  breadcrumb: (m) => m.data?.name ?? "Loading…",
}

If flicker bothers you, consider optimistic state from a list view (e.g., you already know the project name from the projects table) and stash it in router state or a cache to use as an interim label.

Keeping query and hash when navigating up

By default, breadcrumb links omit search and hash. If you want to preserve them, you can enrich the Link target:

import { Link, useLocation } from "react-router-dom";

function CrumbLink({ to, children }) {
  const { search, hash } = useLocation();
  return <Link to={{ pathname: to, search, hash }}>{children}</Link>;
}

Swap Link for CrumbLink in Breadcrumbs when appropriate.

Alternative: build from matchRoutes (non-data routers)

If you’re on React Router v6.3 or not using data routers, you can still generate crumbs from a route config.

import { matchRoutes, Link, useLocation } from "react-router-dom";

function BreadcrumbsFromConfig({ routes }) {
  const location = useLocation();
  const matched = matchRoutes(routes, location) ?? [];

  return (
    <nav aria-label="Breadcrumb">
      <ol className="breadcrumbs">
        {matched
          .filter((m) => m.route.handle?.breadcrumb)
          .map((m, i, arr) => {
            const label =
              typeof m.route.handle.breadcrumb === "function"
                ? m.route.handle.breadcrumb({ params: m.params })
                : m.route.handle.breadcrumb;
            const to = m.pathname ?? m.route.path; // fallback
            const isLast = i === arr.length - 1;
            return (
              <li key={to} aria-current={isLast ? "page" : undefined}>
                {isLast ? <span>{label}</span> : <Link to={to}>{label}</Link>}
              </li>
            );
          })}
      </ol>
    </nav>
  );
}

Note: Without loaders, dynamic labels may require a separate data fetch. Avoid N requests just to name crumbs; instead, load what you need in a parent page and pass names down via context or a store.

TypeScript niceties (optional)

You can type your handle for safer code:

// types/router.d.ts
import "react-router-dom";
import { UIMatch } from "react-router-dom";

type BreadcrumbHandle = {
  breadcrumb?: string | ((match: UIMatch) => React.ReactNode);
};

declare module "react-router-dom" {
  interface IndexRouteObject { handle?: BreadcrumbHandle }
  interface NonIndexRouteObject { handle?: BreadcrumbHandle }
}

Now handle.breadcrumb is type-checked across your app.

Internationalization (i18n)

For static labels, wrap the breadcrumb generator with your i18n library:

handle: { breadcrumb: () => t("routes.projects") }

For dynamic labels, return already-localized names from your loaders (e.g., a localized project title) or map raw data through translators before rendering.

SEO with structured data

Visible breadcrumbs aid users; structured data helps search engines. Emit JSON-LD reflecting your current crumbs.

import { useEffect } from "react";
import { useMatches } from "react-router-dom";

function BreadcrumbJsonLd() {
  const matches = useMatches().filter((m) => m.handle?.breadcrumb);
  const items = matches.map((m, i) => ({
    "@type": "ListItem",
    position: i + 1,
    name:
      typeof m.handle.breadcrumb === "function"
        ? String(m.handle.breadcrumb(m))
        : String(m.handle.breadcrumb),
    item: window.location.origin + m.pathname,
  }));

  const json = {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: items,
  };

  useEffect(() => {
    const script = document.createElement("script");
    script.type = "application/ld+json";
    script.text = JSON.stringify(json);
    document.head.appendChild(script);
    return () => { document.head.removeChild(script); };
  }, [JSON.stringify(items)]);

  return null;
}

Be sure that the JSON-LD matches what users see in the DOM.

Common pitfalls and how to avoid them

  • Duplicate fetching for labels: Use loader data to name dynamic crumbs; don’t fire separate fetches from the breadcrumb component.
  • Linking the current crumb: Keep the last crumb non-interactive and add aria-current=“page”.
  • Labels from raw IDs: Provide friendly fallbacks (e.g., #1234) but upgrade to names when loader data resolves.
  • Overly long labels: Truncate with CSS ellipsis, add title attributes for full names, and consider tooltips on hover.
  • Pathless and index routes: Pathless parents can still carry a breadcrumb handle for grouping; index routes usually should not introduce a new crumb unless it clarifies context (e.g., “Overview”).
  • Preserving app state: Navigating via breadcrumbs may reset filters. Decide whether to carry query params upward or compute sensible defaults.

Testing your breadcrumbs

  • Unit test handle functions with mock matches.
  • Integration test navigation flows to ensure the crumb trail updates correctly for dynamic segments.
  • Axe or Lighthouse for a11y checks on nav/aria attributes.

Complete minimal example

A compact example tying it together.

// AppLayout.jsx
import Breadcrumbs from "./Breadcrumbs";

export default function AppLayout() {
  return (
    <div className="app">
      <header>
        <h1>Acme Tracker</h1>
        <Breadcrumbs />
      </header>
      <main>
        {/* nested route outlet here */}
      </main>
    </div>
  );
}
// ProjectLayout.jsx
import { Outlet, useLoaderData } from "react-router-dom";

export default function ProjectLayout() {
  const project = useLoaderData();
  return (
    <section>
      <h2>{project.name}</h2>
      <Outlet />
    </section>
  );
}

With the route config shown earlier, you now have dynamic, accessible breadcrumbs that reflect loader-fetched names all the way down to an issue title.

When not to use breadcrumbs

  • Very shallow IA (1–2 levels) where a back button or tabs are clearer.
  • Linear funnels where stepping back breaks the flow.

Summary

Dynamic breadcrumbs in React are straightforward when you:

  • Store labels in route handles.
  • Reuse loader data for dynamic segments.
  • Render with useMatches for a stable hierarchy.
  • Harden UX with accessibility, truncation, and sensible fallbacks.
  • Optionally add JSON-LD for SEO.

Adopt this pattern once, and every new route in your app can “teach” the breadcrumb how to label itself—no extra plumbing needed.

Related Posts