Links and substitution
Why the framework renders your router's link, and how a page states which component that is.
Every navigation component draws links: a navbar's destinations, a footer's groups, a
breadcrumb trail, a sidebar's sections, a pagination control. None of them render a bare
<a>. A bare anchor reloads the document, which throws away the state the application has
built and defeats the router above it, so each of these components lets the page state
which component to render instead.
Two places to state it
Per destination. An item carries the component and its props:
<Breadcrumb
items={[
{ label: "Invoices", href: "/invoices", component: RouterLink, componentProps: { prefetch: true } },
{ label: "INV-1041", isCurrent: true },
]}
/>
Per component. A family-level link states it once for every destination it renders:
<Navbar
links={[{ label: "Campaigns", href: "/campaigns", isActive: true }]}
link={{ component: RouterLink, props: { prefetch: true } }}
/>
Where both are given, the item wins: a destination that needs different props than its siblings can say so without restating the component. The merged props are the family's first, then the item's, so the more specific statement is the one that takes effect.
Support for the two levels is not uniform, so check the component's page:
| Component | Per destination | Once per component |
|---|---|---|
Navbar |
links[].component + componentProps |
link={{ component, props }} |
Footer |
groups[].links[] and social/legal items |
link={{ component, props }} |
Breadcrumb |
items[].component + componentProps |
link={{ component, props }}, with the step winning |
Card, Link, Image, Button |
component + componentProps, or link |
|
Sidebar |
link={{ component, props }}, merged under each item's anchorProps |
Sidebar is the one that states the link once: it renders sections of rows rather than a
list of destinations, so its link is the family's and each item's anchorProps are merged
on top of it.
What the substitute receives
The component is rendered in place of the anchor and receives the anchor's props,
href, className, aria-current, the press handler, plus whatever componentProps
adds. A router's link is written for that shape: it takes a target, renders an anchor, and
intercepts the press.
This is the seam that makes one page description work in Next.js, Vite and TanStack Start. The application states its router once, at the link that renders it, and the page states destinations. See Page compositions for the same idea applied to a whole page.
Current, disabled and external
| State | Prop | What it does |
|---|---|---|
| Current | isActive (items) / isCurrent (breadcrumb) |
Marks the destination as the current page with aria-current="page" and the active treatment, so it is announced rather than only coloured |
| Disabled | disabled (items, Link) |
aria-disabled and a press that is refused; the destination stays in the reading order |
| External | isExternal (Link, footer items) |
Renders the external affordance and the target/rel an off-site destination needs, so a reader is told where the link goes |
A destination that is current is not a destination the reader needs to press again, which is
why aria-current carries that meaning rather than the colour.
Images
The same seam exists for images, because a framework's image component knows things a bare
<img> does not, the intrinsic size, the loader, the allowed host. Image, Card.image
and Avatar take component and componentProps for exactly that reason.
import NextImage from "next/image";
<Image
src="/campaign.png"
alt="The campaign"
component={NextImage}
componentProps={{ width: 640, height: 360 }}
/>
When not to substitute
If the application does not use a client-side router, a plain Vite page, a marketing site that reloads, leave the substitution out. The components render anchors by default, and an anchor is the right element for a document that navigates.