Documentation

Routing

Breadcrumbs

Add localized crumbs to the VitNode breadcrumb trail from plugin routes, layouts, AdminCP pages and the app's own file routes.

VitNode renders the breadcrumb trail in the public header and in the AdminCP shell. Each route that declares a crumb adds one entry, and the trail reads from parent to child:

Site notes / Office closed on Friday

A route declares only the label of its own crumb. The shell renders the <nav> landmark, the separators, the locale-aware links and aria-current="page" on the last crumb.

Add a crumb to a plugin route

Set breadcrumb in the page module's route to a component that returns the label. It renders inside the route's declared messages, so useTranslations works:

plugins/site-notes/src/pages/notes-page.tsx
import { defineRoute } from '@vitnode/core/tanstack/plugin-routes'
import { useTranslations } from 'use-intl'

const NotesCrumb = () => {
  const t = useTranslations('site-notes.notes')

  return t('title')
}

export const route = defineRoute({
  breadcrumb: NotesCrumb, 
})

Use loader data in a crumb

A crumb receives { loaderData, params, search }, the same values the page gets. On a dynamic page, show the record's name instead of its id, without a second request. This extends the note page from Add a route, where loadNote and NotePageData are defined:

plugins/site-notes/src/pages/note-page.tsx
export const route = defineRoute({
  load: async ({ context, params }) =>
    await loadNote(context.queryClient, params.id),
  head: ({ loaderData }) => ({
    title: loaderData?.title,
    description: loaderData?.body,
  }),
  breadcrumb: ({ loaderData }) => loaderData.title, 
})

Declare breadcrumb after load, so TypeScript types loaderData from the loader. A crumb sits under every crumb of the routes above it. When the list and the note page are siblings rather than a layout and its child, the trail shows only the note. Put both under a layout() with its own crumb to get Site notes / Office closed on Friday.

Leave a route out

A route without breadcrumb adds nothing, and the crumbs above it stay. Write breadcrumb: false (or null) to make that explicit, for example on an index() page whose layout already names the screen. The trail then ends at the deepest route that declares a crumb, and that crumb gets aria-current="page".

Several crumbs from one route

Some pages know more of the trail than their own label. A forum topic, for example, can list every parent forum from its loader data. Declare a group with routeBreadcrumbGroup from @vitnode/core/tanstack/plugin-routes, and render the items yourself with the breadcrumb components:

plugins/site-notes/src/pages/note-page.tsx
import {
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from '@vitnode/core/components/ui/breadcrumb'
import { HrefLink } from '@vitnode/core/tanstack/link'
import { defineRoute, routeBreadcrumbGroup } from '@vitnode/core/tanstack/plugin-routes'

export const route = defineRoute({
  load: async ({ context, params }) =>
    await loadNote(context.queryClient, params.id),
  breadcrumb: routeBreadcrumbGroup<NotePageData>(({ loaderData }) => (
    <>
      <BreadcrumbItem>
        <BreadcrumbLink render={<HrefLink href="/site-notes/notes" />}>
          Site notes
        </BreadcrumbLink>
      </BreadcrumbItem>
      <BreadcrumbSeparator />
      <BreadcrumbItem>
        <BreadcrumbPage>{loaderData.title}</BreadcrumbPage>
      </BreadcrumbItem>
    </>
  )),
})

The shell adds the separator before the group. Inside it, you add the separators between your own items and mark the last one with BreadcrumbPage. Translate the labels with useTranslations in a small component, as in the first example.

A single crumb returns only its label

A plain breadcrumb component returns text or an inline element, such as an icon with text. Never render <nav>, links or separators in it, because the shell already does. Only a group renders BreadcrumbItem and friends.

AdminCP crumbs

An AdminCP route declares a crumb the same way. To label the crumbs from the AdminCP navigation, so they match the sidebar, use adminBreadcrumb from @vitnode/core/tanstack/admin. segments is the path below /admin:

plugins/site-notes/src/pages/admin-notes-page.tsx
import { definePluginRoute } from '@vitnode/core/routing'
import { adminBreadcrumb } from '@vitnode/core/tanstack/admin'

export const route = definePluginRoute({
  breadcrumb: adminBreadcrumb({ segments: ['site-notes'] }), 
})

Each segment gets the title of the navigation entry whose link matches it, and a segment with no entry gets a readable version of its name. Pass overrideLastLabel when the page knows a better name for the last crumb, such as a record's title. The AdminCP does not look labels up on its own, so a route without breadcrumb adds no crumb there either.

Crumbs on the app's own routes

A file route in your app declares its crumb as staticData.breadcrumb. App routes do not get plugin messages, so a crumb that renders strings wraps itself in RouteMessages from @vitnode/core/tanstack/i18n:

apps/web/src/site/pricing/breadcrumb.tsx
import { RouteMessages } from '@vitnode/core/tanstack/i18n'
import { useTranslations } from 'use-intl'

const PricingLabel = () => {
  const t = useTranslations('site.pricing')

  return t('title')
}

export const PricingBreadcrumb = () => (
  <RouteMessages namespaces={['site.pricing']}>
    <PricingLabel />
  </RouteMessages>
)
apps/web/src/routes/_main/pricing.tsx
import { createFileRoute } from '@tanstack/react-router'

import { PricingBreadcrumb } from '@/site/pricing/breadcrumb'
import { PricingPage } from '@/site/pricing/pricing-page'

export const Route = createFileRoute('/_main/pricing')({
  staticData: { breadcrumb: <PricingBreadcrumb /> }, 
  component: PricingPage,
})

site.pricing is a namespace from your app's own locale files; see Your app's own strings.