Cookbook Router
@cookbook/router-react

Links API

Complete Link and NavLink props, typed URL generation, prefetch behavior, active matching, and native browser escape hatches.

The links entrypoint exports router-aware anchors and the browser escape-hatch helper they use internally.

import {
  Link,
  NavLink,
  shouldPreserveBrowserBehavior,
} from '@cookbook/router-react/links';

import type {
  LinkPrefetch,
  LinkProps,
  NavLinkEnd,
  NavLinkEndOptions,
  NavLinkProps,
  NavLinkRenderProps,
} from '@cookbook/router-react/links';

The package root re-exports the same public link surface.

Link and NavLink always render real <a> elements. They intercept only the clicks that should become router navigation. Everything else stays browser-native. That is not decorative; users expect modifier keys, downloads, new tabs, mail links, and external links to behave like the web.

type LinkPrefetch =
  | false
  | 'hover'
  | 'focus'
  | 'interaction'
  | 'mount';

interface LinkProps<Route extends RouteId = RouteId>
  extends Omit<
    AnchorHTMLAttributes<HTMLAnchorElement>,
    'href'
  > {
  readonly route?: Route;
  readonly to?: Route;
  readonly href?: string;
  readonly params?: HrefOptions<Route>['params'];
  readonly search?: HrefOptions<Route>['search'];
  readonly hash?: HrefOptions<Route>['hash'];
  readonly url?: HrefOptions<Route>['url'];
  readonly intercept?: InterceptInput | false;
  readonly context?: HrefOptions<Route>['context'];
  readonly preventScrollReset?: boolean;
  readonly replace?: boolean;
  readonly prefetch?: LinkPrefetch;
  readonly children?: ReactNode;
}

function Link<Route extends RouteId = RouteId>(
  props: LinkProps<Route>,
): ReactElement;

Use route or its alias to for generated, contract-checked internal URLs.

Use href for external destinations or deliberate browser-native navigation.

import { Link } from '@cookbook/router-react/links';

export function UserActivityLink() {
  return (
    <Link
      to="users.details"
      params={{ slug: 'ada-lovelace' }}
      search={{ tab: 'activity' }}
      prefetch="interaction"
    >
      View activity
    </Link>
  );
}

Explicit external link:

import { Link } from '@cookbook/router-react/links';

export function ExternalDocsLink() {
  return (
    <Link
      href="https://example.com/docs"
      target="_blank"
      rel="noreferrer"
    >
      External docs
    </Link>
  );
}

Explicit internal href without a route ID:

import { Link } from '@cookbook/router-react/links';

export function NativeInternalLink() {
  return <Link href="/users/ada-lovelace">Open user</Link>;
}

That renders a real anchor and preserves browser navigation on click because there is no route ID to pass to typed navigation.

Prop

Type

Route, to, and href precedence

route wins over to when both are supplied:

<Link route="users.details" to="users.index" params={{ slug }}>
  User
</Link>

The component uses users.details.

When href is supplied, it controls the rendered anchor URL:

<Link
  to="users.details"
  params={{ slug: 'ada-lovelace' }}
  href="/manual-user-url"
>
  User
</Link>

The rendered <a> receives:

<a href="/manual-user-url">User</a>

But an intercepted router click still navigates using the route ID and route options, not the explicit href.

That means the example above displays /manual-user-url in the browser status bar, while a normal router-handled click navigates to users.details.

Do not mix href with route or to unless you intentionally want different native-click and router-click behavior. Most applications should choose one model.

Click behavior

Link calls the user-provided onClick first.

If that handler calls event.preventDefault(), router navigation is skipped.

<Link
  to="users.details"
  params={{ slug }}
  onClick={(event) => {
    if (!canNavigate) {
      event.preventDefault();
    }
  }}
>
  User
</Link>

After that, the component preserves browser behavior for:

  • already-prevented events;
  • non-left mouse buttons;
  • modifier keys;
  • target values other than _self;
  • download;
  • mailto: URLs;
  • tel: URLs;
  • cross-origin HTTP(S) URLs.

Router navigation happens only when all of these are true:

  • the click is an unmodified left click;
  • the event was not prevented;
  • the target is absent or _self;
  • no download attribute is present;
  • the href is not mailto:, tel:, or cross-origin HTTP(S);
  • a route or to ID exists.
<Link to="documents.details" params={{ documentId }}>
  Open document
</Link>

With replace, the click calls router.navigate.replace() instead of router.navigate.to():

<Link to="login" replace>
  Sign in
</Link>

With intercept={false}, configured interception is bypassed for that navigation:

<Link
  to="documents.details"
  params={{ documentId }}
  intercept={false}
>
  Open full page
</Link>

With a call-site intercept, the destination route can render through a slot:

<Link
  to="documents.details"
  params={{ documentId }}
  intercept={{
    slot: 'modal',
    view: DocumentPreview,
  }}
  preventScrollReset
>
  Preview document
</Link>

aria-disabled is not a click blocker. It only prevents prefetch in the current implementation.

If a link must not navigate, do not render it as an enabled link, remove the destination, or prevent the click yourself.

LinkPrefetch

type LinkPrefetch =
  | false
  | 'hover'
  | 'focus'
  | 'interaction'
  | 'mount';
ValueTrigger
falseNo speculative preload.
'hover'Preload on pointer enter.
'focus'Preload on focus.
'interaction'Preload on pointer enter or focus.
'mount'Preload once per resolved href while mounted.

Prefetch calls router.preloadHref().

<Link to="users.details" params={{ slug }} prefetch="hover">
  User
</Link>

Focus prefetch:

<Link to="settings" prefetch="focus">
  Settings
</Link>

Interaction prefetch:

<Link to="reports" prefetch="interaction">
  Reports
</Link>

Mount prefetch:

<Link to="dashboard" prefetch="mount">
  Dashboard
</Link>

prefetch="mount" runs again if the resolved href changes while the component remains mounted.

<Link
  to="users.details"
  params={{ slug }}
  prefetch="mount"
>
  Current user
</Link>

Changing slug changes the resolved href, so the component can preload the new target.

Prefetch is skipped for:

  • empty hrefs;
  • external hrefs;
  • aria-disabled={true};
  • aria-disabled="true".

Prefetch rejections are swallowed. Speculation must not create unhandled promise noise.

Explicit internal hrefs can prefetch even without a route ID:

<Link href="/users/ada-lovelace" prefetch="hover">
  User
</Link>

That can warm the router through preloadHref(), but click navigation remains browser-native because no route ID exists.

Per-link URL options are forwarded to preloadHref() only as URL options:

<Link
  to="products"
  search={{ tags: ['router', 'typescript'] }}
  url={{ arrayFormat: 'comma' }}
  prefetch="interaction"
>
  Products
</Link>

shouldPreserveBrowserBehavior()

function shouldPreserveBrowserBehavior(
  event: MouseEvent<HTMLAnchorElement>,
  href: string,
  target?: string,
  download?: AnchorHTMLAttributes<HTMLAnchorElement>['download'],
): boolean;

Returns true when a click must stay browser-native.

Prop

Type

It preserves browser behavior for:

  • event.defaultPrevented;
  • event.button !== 0;
  • metaKey;
  • altKey;
  • ctrlKey;
  • shiftKey;
  • target other than _self;
  • any defined download;
  • mailto:;
  • tel:;
  • cross-origin HTTP(S).
import {
  shouldPreserveBrowserBehavior,
} from '@cookbook/router-react/links';

function CustomAnchor({
  href,
  onNavigate,
  ...props
}: {
  readonly href: string;
  readonly onNavigate: () => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
  return (
    <a
      {...props}
      href={href}
      onClick={(event) => {
        if (
          shouldPreserveBrowserBehavior(
            event,
            href,
            props.target,
            props.download,
          )
        ) {
          return;
        }

        event.preventDefault();
        onNavigate();
      }}
    />
  );
}

During SSR, an absolute HTTP(S) URL is treated as external because client origin equality cannot be proven without window.location.

This helper is public for design-system wrappers that need the same escape-hatch policy as Link.

interface NavLinkRenderProps {
  readonly isActive: boolean;
}

interface NavLinkEndOptions {
  readonly search?: 'all' | 'ignore';
}

type NavLinkEnd =
  | boolean
  | NavLinkEndOptions;

interface NavLinkProps<Route extends RouteId = RouteId>
  extends Omit<
    AnchorHTMLAttributes<HTMLAnchorElement>,
    'children' | 'href'
  > {
  readonly route?: Route;
  readonly to?: Route;
  readonly href?: string;
  readonly params?: HrefOptions<Route>['params'];
  readonly search?: HrefOptions<Route>['search'];
  readonly hash?: HrefOptions<Route>['hash'];
  readonly url?: HrefOptions<Route>['url'];
  readonly replace?: boolean;
  readonly intercept?: false | InterceptInput;
  readonly context?: HrefOptions<Route>['context'];
  readonly preventScrollReset?: boolean;
  readonly prefetch?: LinkPrefetch;
  readonly end?: NavLinkEnd;
  readonly children?:
    | ReactNode
    | ((props: NavLinkRenderProps) => ReactNode);
}

function NavLink<Route extends RouteId = RouteId>(
  props: NavLinkProps<Route>,
): ReactElement;

NavLink computes the target href, compares it to the current router location, sets aria-current="page" while active, and delegates rendering and click behavior to Link.

import { NavLink } from '@cookbook/router-react/links';

export function SettingsNav() {
  return (
    <NavLink to="settings.profile" end>
      Profile
    </NavLink>
  );
}

Render-prop children receive active state:

import { NavLink } from '@cookbook/router-react/links';

export function ReportsNavItem() {
  return (
    <NavLink to="reports">
      {({ isActive }) => (
        <span data-active={isActive}>
          Reports
        </span>
      )}
    </NavLink>
  );
}

Prop

Type

NavLink throws when no destination is supplied:

NavLink requires route, to, or href.

The destination can come from route, to, or href.

<NavLink href="/settings">Settings</NavLink>

Same-origin absolute hrefs can be active in the browser:

<NavLink href={`${window.location.origin}/settings`}>
  Settings
</NavLink>

Cross-origin absolute URLs cannot be active. During SSR, absolute HTTP(S) URLs are not normalized because there is no browser origin to compare.

Active matching

NavLink uses the current router location.href, current location.pathname, the resolved target href, and end.

endRule
omitted or falseActive when the current href equals the target href, or when current pathname starts with the target pathname.
trueActive only when the full current href equals the target href. Search and hash must match.
{ search: 'all' }Same as true. Search and hash must match.
{ search: 'ignore' }Compares pathname and hash while ignoring search.

Exact active link:

<NavLink
  to="users.details"
  params={{ slug }}
  search={{ tab: 'settings' }}
  end
>
  Settings
</NavLink>

Ignore search while keeping pathname and hash significant:

<NavLink
  to="users.details"
  params={{ slug }}
  search={{ tab: 'profile' }}
  hash="top"
  end={{ search: 'ignore' }}
>
  User
</NavLink>

The search: 'ignore' mode strips search from both sides and still compares hash. These two are active relative to each other:

/items?sort=new#top
/items?sort=old#top

These are not:

/items?sort=new#top
/items?sort=new#details

Native behavior escape hatches

A Link or NavLink does not turn every anchor into router navigation.

These stay browser-native:

<Link href="mailto:team@example.com">Email</Link>
<Link href="tel:+15550123">Call</Link>
<Link href="https://example.com">External</Link>
<Link to="users" target="_blank">Open in new tab</Link>
<Link to="report" download>Download report</Link>

Modifier keys stay native too:

Cmd/Ctrl click
Shift click
Alt click
Middle click

That is the right behavior. Breaking browser muscle memory to make a router look clever is how users lose tabs, downloads, and trust.

Export inventory

@cookbook/router-react/links exports these values:

Link
NavLink
shouldPreserveBrowserBehavior

It exports these public types:

LinkPrefetch
LinkProps
NavLinkEnd
NavLinkEndOptions
NavLinkProps
NavLinkRenderProps

The package root re-exports the same public values and types.

Where this bites

Prefix matching is textual

Non-end matching is textual prefix matching, not path-segment matching.

This means /users also prefixes:

/users-old
/usersettings

Use end for exact navigation items:

<NavLink to="users" end>
  Users
</NavLink>

href plus route can lie to the user

This renders one URL but router-clicks another route:

<Link
  href="/manual"
  to="users.details"
  params={{ slug }}
>
  User
</Link>

The anchor href is /manual. The intercepted router click uses users.details.

Use either href or to unless you are intentionally splitting native behavior from router behavior.

aria-disabled is not disabled navigation

This skips prefetch:

<Link
  to="users.details"
  params={{ slug }}
  prefetch="hover"
  aria-disabled="true"
>
  User
</Link>

It does not stop click navigation.

If the link should not navigate, prevent the click or do not render an enabled destination.

Explicit local hrefs do not router-navigate

This can prefetch:

<Link href="/users/ada-lovelace" prefetch="hover">
  User
</Link>

But the click stays native because there is no route ID.

Typed navigation needs route or to.

On this page