Skip to contentAsheeUIAsheeUI

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.