Skip to main content

Breadcrumbs from Routes

A breadcrumb trail is a rendering of where you are in the route tree. That means it is derived state: your router already knows the answer, so nothing needs to store it, push it, or keep it in sync. This recipe wires Breadcrumb to React Router's route handles and reads the trail out of useMatches().

Try it in the live demo, directly above the file that renders it.

Why not a context provider

The tempting shape is a provider holding the trail, with pages pushing their crumb up from an effect (useBreadcrumbLabel(endpoint.name)). Avoid it. It buys nothing the router does not already give you, and costs four things:

  • A wrong first paint. The crumb is pushed in an effect, so the first render has no leaf. Server-rendered HTML has none at all.
  • Clobbering on navigation. The outgoing route's cleanup and the incoming route's push race. Whether the new crumb survives depends on effect ordering, not on anything you wrote.
  • A fixed depth. A provider with section / intermediate / leaf slots cannot express a four-level trail without a new slot and a new hook.
  • Two sources of truth. The route tree already encodes the hierarchy; a provider encodes it again, and the two drift.

Deriving from matches has none of these: it is correct on the server, correct on the first frame, has no ordering to get wrong, and is as deep as your route tree.

Anatomy

routes.ts                 the route tree — the single source of truth for hierarchy└── each route module    └── export const handle = { breadcrumb: … }    what this level is called        └── <RouteBreadcrumbs />                   reads useMatches(), renders the trail            └── Breadcrumb.Root > List > Item > Link | Label | Page

Three pieces, each with one job:

  1. handle.breadcrumb on a route module: what this level is called, built with routeBreadcrumb.
  2. buildCrumbs from @ngrok/mantle/breadcrumb: filters and maps matches to crumbs. Pure and router-agnostic, and mantle tests its contract.
  3. One component that calls useMatches(), and one that renders: the only file this recipe asks you to copy. Keeping the hook at the edge is what lets you test the rendering with a fixture.

The handle

handle is a static export of the route module, so (unlike loader data) it is never serialized: it is read straight off the loaded module in the client bundle, and appears nowhere in React Router's single-fetch payload. It is also unconstrained (RouteObject.handle?: any; UIMatch's Handle parameter defaults to unknown), so it can hold a function as happily as a string.

A label must resolve synchronously. It runs during render, on the server too; that is what keeps the trail correct in the initial HTML and stable on the first frame. See Server rendering.

The crumb model and the trail builder ship in @ngrok/mantle/breadcrumb, so there is nothing to copy for this half:

  • routeBreadcrumb creates the crumbs a route contributes: a link crumb by default, routeBreadcrumb.label for a section prefix that never links, and routeBreadcrumb.content for a segment that renders itself from fetched data.
  • BreadcrumbHandle<TMatch> types the handle export. TMatch is your router's match type: pass react-router's UIMatch whenever the breadcrumb function reads the match, and leave it off otherwise.
  • buildCrumbs derives the trail from useMatches(). It is router-agnostic (it reads only id, pathname, and handle off each match, which UIMatch satisfies structurally) and pure, so you can assert your own trails as plain values.

A route that wants a crumb exports the handle:

// app/routes/endpoints.tsximport type { BreadcrumbHandle } from "@ngrok/mantle/breadcrumb"; export const handle = { breadcrumb: "Endpoints" } satisfies BreadcrumbHandle;

Choosing a label

In order of preference. Reach for the first one that works.

1. A static string. Most levels are a fixed section name. Put it on the route that is an ancestor of the pages that should show it (see the trail follows nesting):

// app/routes/endpoints.tsx — the layout route at path "endpoints"import type { BreadcrumbHandle } from "@ngrok/mantle/breadcrumb"; export const handle = { breadcrumb: "Endpoints" } satisfies BreadcrumbHandle;

2. Derived from the URL. Params are on the match, so this is still synchronous. Return an array (a route may contribute more than one crumb):

// app/routes/endpoints/$endpointType.$endpointId.tsximport { routeBreadcrumb, type BreadcrumbHandle } from "@ngrok/mantle/breadcrumb";import type { UIMatch } from "react-router"; export const handle = {	breadcrumb: (match) => [routeBreadcrumb(match.params.endpointId)],} satisfies BreadcrumbHandle<UIMatch>;

Omitting to links the crumb to the contributing route's own pathname, which is what you want whenever the crumb is that route.

3. Derived from loader data. If the label is already in the route's loader data, read it off the match with a param fallback. This is SSR-correct and never changes after the first paint:

// app/routes/endpoints/$endpointType.$endpointId.tsximport { routeBreadcrumb, type BreadcrumbHandle } from "@ngrok/mantle/breadcrumb";import type { UIMatch } from "react-router";import type { Route } from "./+types/$endpointType.$endpointId"; export const handle = {	breadcrumb: (match) => [		routeBreadcrumb(match.loaderData?.endpoint.url ?? match.params.endpointId),	],} satisfies BreadcrumbHandle<UIMatch<Route.ComponentProps["loaderData"]>>;

note

React Router 8 renamed this field: it is match.loaderData, not match.data as in v7. Passing UIMatch<YourLoaderData> as BreadcrumbHandle's match type is what gives you a checked loaderData instead of unknown.

4. Backed by a query. Some names only exist in fetched data: Apps > my-app > Settings, where my-app is a resource name the URL does not carry and no loader fetched. Contribute a content crumb: a component that renders the whole segment itself and holds the row's shape with Breadcrumb.Skeleton while its query is pending.

A content crumb must render a host element at its root, never bare text. Breadcrumb.List renders an <ol>, so the crumb's outermost node is a direct child of a list: a bare string there is a text node React removes by itself, and a browser translation engine has reparented it first, so the removal throws and the page goes blank. Every shape below returns a Breadcrumb.Item or a Fragment of them. Browser translation covers the mechanism.

// app/routes/apps/$appId.settings.tsximport { routeBreadcrumb, type BreadcrumbHandle } from "@ngrok/mantle/breadcrumb";import type { UIMatch } from "react-router";import { AppSettingsTrail } from "~/features/apps/app-settings-trail"; export const handle = {	breadcrumb: (match) => [routeBreadcrumb.content(<AppSettingsTrail appId={match.params.appId} />)],} satisfies BreadcrumbHandle<UIMatch>;
// app/features/apps/app-settings-trail.tsximport { Breadcrumb } from "@ngrok/mantle/breadcrumb";import { Link } from "react-router";import { useApp } from "./queries"; /** * The app's trail segment. It calls the same query hook as the app's pages, so * the crumb and the page share one cache entry and the crumb adds no request. */export function AppSettingsTrail({ appId }: { appId: string | undefined }) {	const appQuery = useApp(appId); 	if (appQuery.isPending) {		// a stable best-guess width for the two crumbs the segment resolves to		return <Breadcrumb.Skeleton className="w-40" />;	} 	return (		<>			<Breadcrumb.Item>				<Breadcrumb.Link asChild>					<Link to={`/apps/${appId}`}>{appQuery.data?.name ?? appId}</Link>				</Breadcrumb.Link>			</Breadcrumb.Item>			<Breadcrumb.Separator />			<Breadcrumb.Item>				<Breadcrumb.Page>Settings</Breadcrumb.Page>			</Breadcrumb.Item>		</>	);}

Three rules keep the pending trail honest:

  • Share the page's query. The content component calls the same TanStack Query hook as the route's page, so both read one cache entry. Navigate in from a list that already populated the cache and the name is instant, with no skeleton at all.
  • Size the skeleton to the resolved trail. Pass a w-* utility that approximates the segment the skeleton stands in for, so the row keeps its width on resolve instead of reflowing the shell header.
  • The segment owns link vs current page. The trail builder cannot see inside a content node, so the deepest-crumb-becomes-Breadcrumb.Page rule stops at its boundary: the component renders Breadcrumb.Page on its own last item.

The shape to avoid is the fetch without the skeleton, a label that renders a text fallback and swaps:

// ✗ don't: the crumb changes width when the query resolves, and the flicker// depends on how the reader arrived (warm cache vs hard reload)function EndpointCrumb() {	const endpointQuery = useEndpoint({ id: useParams().endpointId });	return <>{endpointQuery.data?.endpoint.url ?? "…"}</>;}

When the trail can stop instead

A crumb that repeats the page's <h1> adds a second place for the same string to render. For a resource detail page, the trail can often stop at the section, because the resource's identity is already the page's <h1>:

Endpoints                       ← the trailhttps://forward-labels.test     ← the page's h1, where the name belongs

Reach for a content crumb when the trail genuinely needs the fetched name: a deep shell where the resource is an ancestor of the current page, not the page itself (Apps > my-app > Settings).

The prefix crumb

Some levels are sections, not destinations. In ngrok's dashboard settings area the trail reads Settings > General, but /settings only redirects to /settings/general; a Settings link would bounce the user back to the page they are already on. The crumb still belongs in the trail; the link does not.

Give the layout route that owns the section a routeBreadcrumb.label crumb:

// app/routes/settings.tsx — the layout route at path "settings"import { routeBreadcrumb, type BreadcrumbHandle } from "@ngrok/mantle/breadcrumb"; export const handle = {	breadcrumb: () => [routeBreadcrumb.label("Settings")],} satisfies BreadcrumbHandle;

The renderer draws it as a Breadcrumb.Label: muted text with no href and no aria-current, still counted as a step in the <ol>. Every child route contributes its own crumb under it, so /settings/general renders Settings > General and /settings/auth renders Settings > Auth with no per-page wiring.

A label crumb carries no to at all: Crumb is a discriminated union on kind, and the routeBreadcrumb factory stamps the discriminant, so an unlinked link crumb is unrepresentable and no call site hand-writes one.

Building and rendering the trail

The one file this recipe asks you to copy. RouteBreadcrumbs is the only thing that touches the router; Breadcrumbs is pure rendering, which is what makes it testable with a fixture, and what lets the demo below use it unmodified. It stays in your app rather than in mantle because it owns the one decision mantle cannot make: which Link component your router uses.

Pick a URL. The trail grows, the leaf becomes the current page, and its ancestors become links:

note

The demo switches between fixture match arrays rather than really navigating: a demo embedded in this page cannot mount a second router (React Router allows exactly one per tree). That is why the render half takes crumbs as a prop: the same component runs against real useMatches() output in your app, and against a fixture here and in your tests.
import type { ResolvedCrumb } from "@ngrok/mantle/breadcrumb";import { Breadcrumb, buildCrumbs } from "@ngrok/mantle/breadcrumb";import { Fragment } from "react";import { Link, useMatches } from "react-router"; /** A crumb the renderer wraps in its own `Breadcrumb.Item`. */type ItemCrumb = Exclude<ResolvedCrumb, { kind: "content" }>; /** * One crumb's content. The deepest crumb is the current page; a label crumb is * a plain `Breadcrumb.Label`; every other crumb links to its route. */function CrumbContent({ crumb, isCurrentPage }: { crumb: ItemCrumb; isCurrentPage: boolean }) {	if (isCurrentPage) {		return <Breadcrumb.Page>{crumb.label}</Breadcrumb.Page>;	} 	if (crumb.kind === "label") {		// a section prefix whose index URL only redirects — a link there would		// bounce the user back to where they already are		return <Breadcrumb.Label>{crumb.label}</Breadcrumb.Label>;	} 	return (		// asChild, so the router owns navigation — a bare href on		// Breadcrumb.Link would full-page-navigate		<Breadcrumb.Link asChild>			<Link to={crumb.to}>{crumb.label}</Link>		</Breadcrumb.Link>	);} /** * Renders a breadcrumb trail from already-resolved crumbs. The last crumb is the * current page (`aria-current="page"`, not a link), and every earlier one links * to its route — except a label crumb, which renders as a non-link * `Breadcrumb.Label`, and a content crumb, which renders its own items. A * content crumb decides link vs current page itself, because the renderer * cannot see inside it. * * Kept separate from {@link RouteBreadcrumbs} so nothing here touches the router: * this is the half you can render with a fixture in a test, or in a docs demo that * cannot mount a second router. * * @example * ```tsx * <Breadcrumbs *   crumbs={[ *     { kind: "link", key: "endpoints:0", label: "Endpoints", to: "/endpoints" }, *     { kind: "link", key: "endpoint:0", label: "ep_1", to: "/endpoints/cloud/ep_1" }, *   ]} * /> * ``` */function Breadcrumbs({ crumbs }: { crumbs: ReadonlyArray<ResolvedCrumb> }) {	if (crumbs.length === 0) {		return null;	} 	return (		<Breadcrumb.Root>			<Breadcrumb.List>				{crumbs.map((crumb, index) => (					<Fragment key={crumb.key}>						{index > 0 && <Breadcrumb.Separator />}						{crumb.kind === "content" ? (							crumb.content						) : (							<Breadcrumb.Item>								<CrumbContent crumb={crumb} isCurrentPage={index === crumbs.length - 1} />							</Breadcrumb.Item>						)}					</Fragment>				))}			</Breadcrumb.List>		</Breadcrumb.Root>	);} /** * The breadcrumb trail for the current route. Render it once, in your app shell's * header — it derives everything from the matched route chain, so no route needs to * push anything up. * * This is the only piece that touches the router, which keeps the hook at the edge. * * @example * ```tsx * <AppLayout.Header> *   <AppLayout.HeaderStart> *     <Sidebar.Trigger /> *   </AppLayout.HeaderStart> *   <AppLayout.HeaderContent> *     <RouteBreadcrumbs /> *   </AppLayout.HeaderContent> * </AppLayout.Header> * ``` */function RouteBreadcrumbs() {	return <Breadcrumbs crumbs={buildCrumbs(useMatches())} />;} export {	//,	Breadcrumbs,	RouteBreadcrumbs,};

Breadcrumb.Page clones aria-current="page" onto its child, and Root / List / Item render <nav aria-label="Breadcrumb"> → <ol> → <li>, so the trail is an ordered list to a screen reader rather than a row of anonymous text.

Server rendering

Everything here runs on the server unchanged, which is the point of deriving rather than synchronizing:

  • useMatches() is SSR-safe. The matched chain is what the server router already resolved to render the page, so the trail is in the initial HTML.
  • handle is available on the server. Route modules are loaded server-side too, so the labels are there for the first render: no typeof window guard, no useEffect, no hydration-only branch.
  • buildCrumbs is pure. No hooks, no browser APIs, no Date.now(). Same input, same output, both sides.
  • No hydration mismatch, as long as labels are synchronous. Server and client first-render compute the label from the same params and loader data, so the markup matches. A bare query-backed label breaks exactly this: it renders a text fallback on the server, matches on hydration, then changes (no mismatch warning, but a visible reflow).
  • A content crumb ships its skeleton in the initial HTML. The server has no query cache, so the pending branch renders. Because Breadcrumb.Skeleton is sized like the resolved trail, the row's shape is stable from the first byte. The labels fill in client-side, which is the deliberate trade a query-backed crumb makes.

A label that depends on other client-only state (localStorage, window.matchMedia) has no place in the trail at all: move the data into the loader or drop the crumb.

Where it goes in the shell

Render it in every route's AppLayout.HeaderContent. Each route renders its own header, so the trail is on every page. One header per page is the canonical shell:

// app/routes/endpoints.tsximport { AppLayout } from "@ngrok/mantle/app-layout";import { Sidebar } from "@ngrok/mantle/sidebar";import { RouteBreadcrumbs } from "~/features/breadcrumbs/breadcrumbs"; export default function EndpointsPage() {	return (		<>			<AppLayout.Header>				<AppLayout.HeaderStart>					<Sidebar.Trigger />				</AppLayout.HeaderStart>				<AppLayout.HeaderContent>					<RouteBreadcrumbs />				</AppLayout.HeaderContent>			</AppLayout.Header>			<AppLayout.Body>{/* the page */}</AppLayout.Body>		</>	);}

AppLayout.Header is a shrink-0 sibling outside the scroll container, so the trail is pinned by construction. AppLayout.HeaderContent is min-w-0 flex-1, so a long trail scrolls inside it instead of pushing the actions off the card. The actions beside it are the route's own, in AppLayout.HeaderActions. A shell that keeps its header instead derives them from a handle, which is the documented deviation: Header Actions from Routes. Resist the urge to hand-roll a sticky top-0 z-10 breadcrumb bar inside the scrolling region instead: a sticky element is still in flow, so it silently steals its own height from any page asking for h-full, and it competes for the same layer as page-authored sticky content.

Choosing which routes get a crumb

Not every matched route deserves one, and this is a judgment call the recipe cannot make for you. Two rules that hold up:

  • Omit pathless layouts and gate routes. They match, but they are not places a user can be. Leaving off handle.breadcrumb is the whole opt-out: no filtering list to maintain.
  • Omit a level that another affordance already owns. If a detail page renders a tab row (Overview / Traffic Policy / Settings), those tabs are routes, but giving them crumbs prints the same label twice, once as the trail's leaf and once as the selected tab. Let the trail end at the resource and let the tabs own the level below it.

For the same reason, a crumb trail replaces an ad-hoc ← Back link. Shipping both gives a user two competing answers to "where am I".

The trail follows nesting, not the URL

This is the one that will bite you. useMatches() returns the route ancestry, which is whatever your route config nests, not the URL's path segments. Those two diverge more often than you would guess, because a detail page frequently needs to escape its list page's layout:

// a real shape, from ngrok's dashboard route configroute("endpoints", "routes/endpoints/_index.tsx", [	route("new", "routes/endpoints/new.tsx"),]),// detail breaks out of the endpoints list layoutroute("endpoints/:endpointType/:endpointId", "routes/endpoints/$endpointType.$endpointId.tsx", [	route("traffic-policy", "…"),]),

The detail route is a sibling of endpoints, not a child. So on /endpoints/cloud/ep_1 the endpoints route never matches, its handle is never read, and the trail is a single non-link crumb with no way up, even though the URL clearly has a parent. It fails silently: nothing errors, you get a shorter trail than you designed.

Two ways out, and the right one depends on why the routes are split:

  • Nest the detail route under the list route if it can share the layout. Then ancestry and URL agree and the crumb comes for free.
  • Name the ancestor from the detail route when it genuinely must break out. This is why breadcrumb returns an array. One route can contribute the whole trail:
// app/routes/endpoints/$endpointType.$endpointId.tsximport { routeBreadcrumb, type BreadcrumbHandle } from "@ngrok/mantle/breadcrumb";import type { UIMatch } from "react-router"; export const handle = {	breadcrumb: (match) => [		routeBreadcrumb("Endpoints", { to: "/endpoints" }),		routeBreadcrumb(match.params.endpointId),	],} satisfies BreadcrumbHandle<UIMatch>;

Whichever you pick, assert it: a test over buildCrumbs for each detail URL you care about is the only thing that catches a trail quietly losing its ancestor when someone reorganises the route config. The tests below include exactly that case.

Testing it

Mantle tests buildCrumbs' own contract, so your coverage pins what mantle cannot know: the trails your route config produces. buildCrumbs is a plain function over an array of matches, so those assertions need no router at all. Breadcrumbs needs a MemoryRouter (a router context only, not your route tree) because non-leaf crumbs render a real Link. And createRoutesStub forwards handle from stub routes onto the matches, which covers the useMatches() edge end to end.

This is the renderer's test file, verbatim. It runs in this repo, including a guard on the sibling-route trap, which is the failure most worth pinning down:

// @vitest-environment happy-domimport { Breadcrumb, buildCrumbs, routeBreadcrumb } from "@ngrok/mantle/breadcrumb";import { cleanup, render, screen } from "@testing-library/react";import { createRoutesStub, MemoryRouter, type UIMatch } from "react-router";import { afterEach, describe, expect, it } from "vitest";import { Breadcrumbs, RouteBreadcrumbs } from "./breadcrumbs"; afterEach(() => {	cleanup();}); describe("buildCrumbs", () => {	// Mantle owns buildCrumbs' contract; what belongs here is a trail-shaped	// assertion per detail URL your app cares about, like this one.	it("keeps the ancestor a sibling detail route names for itself", () => {		// The sibling-route case: `endpoints/:type/:id` is registered beside		// `endpoints`, not inside it, so the detail route has to name the ancestor.		const crumbs = buildCrumbs([			{				id: "endpoint",				pathname: "/endpoints/cloud/ep_1",				handle: {					breadcrumb: () => [						routeBreadcrumb("Endpoints", { to: "/endpoints" }),						routeBreadcrumb("ep_1"),					],				},			},		]); 		expect(crumbs).toEqual([			{ kind: "link", key: "endpoint:0", label: "Endpoints", to: "/endpoints" },			{ kind: "link", key: "endpoint:1", label: "ep_1", to: "/endpoints/cloud/ep_1" },		]);	});}); describe("Breadcrumbs", () => {	it("renders nothing for an empty trail", () => {		const { container } = render(			<MemoryRouter>				<Breadcrumbs crumbs={[]} />			</MemoryRouter>,		);		expect(container.querySelector('[data-slot="breadcrumb"]')).toBeNull();	}); 	it("marks only the deepest crumb as the current page", () => {		render(			// MemoryRouter, because ancestor crumbs render a router Link			<MemoryRouter>				<Breadcrumbs					crumbs={[						{ kind: "link", key: "endpoints:0", label: "Endpoints", to: "/endpoints" },						{ kind: "link", key: "endpoint:0", label: "ep_1", to: "/endpoints/cloud/ep_1" },					]}				/>			</MemoryRouter>,		); 		expect(screen.getByRole("link", { name: "Endpoints" }).getAttribute("href")).toBe("/endpoints");		// the leaf is not a link, and carries aria-current		expect(screen.queryByRole("link", { name: "ep_1" })).toBeNull();		expect(screen.getByText("ep_1").getAttribute("aria-current")).toBe("page");	}); 	it("renders a prefix crumb as plain text — no link, no aria-current", () => {		render(			<MemoryRouter>				<Breadcrumbs					crumbs={[						{ kind: "label", key: "settings:0", label: "Settings" },						{ kind: "link", key: "general:0", label: "General", to: "/settings/general" },					]}				/>			</MemoryRouter>,		); 		const settings = screen.getByText("Settings");		expect(settings.closest("a")).toBeNull();		expect(settings.getAttribute("aria-current")).toBeNull();		expect(screen.getByText("General").getAttribute("aria-current")).toBe("page");	}); 	it("renders a content crumb's own items as direct children of the list", () => {		const { container } = render(			<MemoryRouter>				<Breadcrumbs					crumbs={[						{ kind: "link", key: "apps:0", label: "Apps", to: "/apps" },						{							kind: "content",							key: "app:0",							content: (								<Breadcrumb.Item>									<Breadcrumb.Page>my-app</Breadcrumb.Page>								</Breadcrumb.Item>							),						},					]}				/>			</MemoryRouter>,		); 		expect(screen.getByText("my-app").getAttribute("aria-current")).toBe("page");		expect(container.querySelectorAll('[data-slot="breadcrumb-separator"]')).toHaveLength(1);		// The crumb's own node is a direct child of the `<ol>`, so it has to be a		// host element. A bare string there is a text node React removes by itself,		// which throws once a translation engine has reparented it.		expect(screen.getByText("my-app").closest("li")?.parentElement).toBe(screen.getByRole("list"));	}); 	it("renders a pending content crumb as the skeleton placeholder", () => {		render(			<MemoryRouter>				<Breadcrumbs					crumbs={[						{							kind: "content",							key: "app:0",							content: <Breadcrumb.Skeleton className="w-40" />,						},					]}				/>			</MemoryRouter>,		); 		expect(screen.getByRole("status").textContent).toBe("Loading breadcrumbs…");		expect(screen.queryByRole("link")).toBeNull();	});}); describe("RouteBreadcrumbs (through a real router)", () => {	it("builds the trail from nested route handles", () => {		// createRoutesStub forwards `handle` onto the matches, so this exercises the		// real useMatches() path without standing up the whole app.		const Stub = createRoutesStub([			{				path: "/endpoints",				handle: { breadcrumb: "Endpoints" },				Component: () => <RouteBreadcrumbs />,				children: [					{						path: ":endpointId",						handle: { breadcrumb: (m: UIMatch) => [routeBreadcrumb(m.params.endpointId)] },					},				],			},		]); 		render(<Stub initialEntries={["/endpoints/ep_1"]} />); 		expect(screen.getByRole("link", { name: "Endpoints" }).getAttribute("href")).toBe("/endpoints");		expect(screen.getByText("ep_1").getAttribute("aria-current")).toBe("page");	}); 	it("renders a layout route's prefix crumb as a non-link", () => {		// The settings shape: `/settings` only redirects to `/settings/general`, so		// the layout route opts its crumb out of linking with `routeBreadcrumb.label`.		const Stub = createRoutesStub([			{				path: "/settings",				handle: { breadcrumb: () => [routeBreadcrumb.label("Settings")] },				Component: () => <RouteBreadcrumbs />,				children: [{ path: "general", handle: { breadcrumb: "General" } }],			},		]); 		render(<Stub initialEntries={["/settings/general"]} />); 		const settings = screen.getByText("Settings");		expect(settings.closest("a")).toBeNull();		expect(settings.getAttribute("aria-current")).toBeNull();		expect(screen.getByText("General").getAttribute("aria-current")).toBe("page");	}); 	it("renders a route's content crumb", () => {		const Stub = createRoutesStub([			{				path: "/apps/:appId",				handle: {					breadcrumb: () => [						routeBreadcrumb.content(							<Breadcrumb.Item>								<Breadcrumb.Page>my-app</Breadcrumb.Page>							</Breadcrumb.Item>,						),					],				},				Component: () => <RouteBreadcrumbs />,			},		]); 		render(<Stub initialEntries={["/apps/app_123"]} />); 		expect(screen.getByText("my-app").getAttribute("aria-current")).toBe("page");	}); 	it("loses the ancestor when the detail route is a sibling, not a child", () => {		// Regression guard on the recipe's central caveat: the trail follows route		// NESTING, not URL depth. Registered as siblings, `/endpoints` never matches		// on the detail URL, so its handle is never read.		const Stub = createRoutesStub([			{ path: "/endpoints", handle: { breadcrumb: "Endpoints" } },			{				path: "/endpoints/:endpointId",				handle: { breadcrumb: (m: UIMatch) => [routeBreadcrumb(m.params.endpointId)] },				Component: () => <RouteBreadcrumbs />,			},		]); 		render(<Stub initialEntries={["/endpoints/ep_1"]} />); 		expect(screen.queryByRole("link", { name: "Endpoints" })).toBeNull();		expect(screen.getByText("ep_1").getAttribute("aria-current")).toBe("page");	});});

note

These use plain DOM assertions (getAttribute, queryByRole(…) === null) rather than @testing-library/jest-dom matchers, so they run with no extra setup. If your app already has jest-dom, toHaveAttribute / toBeInTheDocument read better; use them.

Trade-offs

  • No collapsing. Breadcrumb has no ellipsis part, so a long trail scrolls sideways from the current page rather than truncating to Home / … / Current. Fine to about four levels; past that, consider whether the depth itself is the problem.
  • String labels must be synchronous. A label you can only get from a query needs the content-crumb shape (the same query as the page, plus a Breadcrumb.Skeleton fallback), or it moves into the loader. A bare fallback label that swaps on resolve is the one shape with no place here.
  • A breadcrumb function runs during the shell's render. Keep it a plain projection of params / loaderData with a fallback (the ?? match.params.endpointId above). If it throws, it throws in the chrome, a wider blast radius than throwing inside the page.
  • handle is not available for unmatched routes. You cannot build a full sitemap this way: only the chain to the current URL, which is exactly what a breadcrumb is.