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/leafslots 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
Three pieces, each with one job:
handle.breadcrumbon a route module: what this level is called, built withrouteBreadcrumb.buildCrumbsfrom@ngrok/mantle/breadcrumb: filters and maps matches to crumbs. Pure and router-agnostic, and mantle tests its contract.- 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:
routeBreadcrumbcreates the crumbs a route contributes: a link crumb by default,routeBreadcrumb.labelfor a section prefix that never links, androuteBreadcrumb.contentfor a segment that renders itself from fetched data.BreadcrumbHandle<TMatch>types thehandleexport.TMatchis your router's match type: pass react-router'sUIMatchwhenever the breadcrumb function reads the match, and leave it off otherwise.buildCrumbsderives the trail fromuseMatches(). It is router-agnostic (it reads onlyid,pathname, andhandleoff each match, whichUIMatchsatisfies structurally) and pure, so you can assert your own trails as plain values.
A route that wants a crumb exports the handle:
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):
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):
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:
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.
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.Pagerule stops at its boundary: the component rendersBreadcrumb.Pageon its own last item.
The shape to avoid is the fetch without the skeleton, a label that renders a text fallback and swaps:
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>:
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:
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:
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.handleis available on the server. Route modules are loaded server-side too, so the labels are there for the first render: notypeof windowguard, nouseEffect, no hydration-only branch.buildCrumbsis pure. No hooks, no browser APIs, noDate.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.Skeletonis 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:
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.breadcrumbis 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:
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
breadcrumbreturns an array. One route can contribute the whole trail:
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:
Trade-offs
- No collapsing.
Breadcrumbhas no ellipsis part, so a long trail scrolls sideways from the current page rather than truncating toHome / … / 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.Skeletonfallback), or it moves into the loader. A bare fallback label that swaps on resolve is the one shape with no place here. - A
breadcrumbfunction runs during the shell's render. Keep it a plain projection ofparams/loaderDatawith a fallback (the?? match.params.endpointIdabove). If it throws, it throws in the chrome, a wider blast radius than throwing inside the page. handleis 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.