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.
Link
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;
targetvalues 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
downloadattribute is present; - the href is not
mailto:,tel:, or cross-origin HTTP(S); - a
routeortoID 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';| Value | Trigger |
|---|---|
false | No 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;targetother 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.
NavLink
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.
end | Rule |
|---|---|
omitted or false | Active when the current href equals the target href, or when current pathname starts with the target pathname. |
true | Active 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#topThese are not:
/items?sort=new#top
/items?sort=new#detailsNative 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 clickThat 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
shouldPreserveBrowserBehaviorIt exports these public types:
LinkPrefetch
LinkProps
NavLinkEnd
NavLinkEndOptions
NavLinkProps
NavLinkRenderPropsThe 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
/usersettingsUse 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.
Providers and render helpers
Deep reference for RouterProvider, StaticRouterProvider, external-store subscription, React route traversal, route contexts, and route boundaries.
Outlets, slots, and lazy views
Exact component contracts for nested branch output, outlet context, named slots, slot-local error isolation, and preloadable lazy route views.