Navigation and Prefetching
How Waku navigates between routes, caches static RSC payloads, prefetches likely next pages, and reveals cached shells instantly.
- Experimental
How Client Navigation Works
Waku's <Link> component renders an anchor element and handles normal same-tab clicks with the client router. On navigation, Waku fetches the route's RSC payload and updates the current route without a full document reload.
Use <Link> for internal app routes:
import { Link } from 'waku';
export const Nav = () => (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
);Use a regular <a> element for external URLs, downloads, and links that intentionally open in another browsing context.
Static Route Caching
After a static route has been loaded, Waku can reuse its cached RSC payload on later visits. For example, if the user starts on /, navigates to /about, and then returns to /, the client can render / from the cache instead of requesting that static route again.
Dynamic routes are different. Waku expects dynamic route output to be request-specific, so a visit to a dynamic route may need a fresh server request. Prefetching is most useful when you want to start that work before the user clicks.
Manual Prefetching
Use router.prefetch() when a client component knows which route the user is likely to visit next.
'use client';
import { useRouter } from 'waku';
export const DashboardButton = () => {
const router = useRouter();
return (
<button
onFocus={() => router.prefetch('/dashboard')}
onMouseEnter={() => router.prefetch('/dashboard')}
onClick={() => router.push('/dashboard')}
>
Dashboard
</button>
);
};router.prefetch() accepts the same targets as router.push(): a typed route href (autocompleted from your routes), or a structured { to, params, search, hash } target for prefetching a dynamic route. For a computed string that is not a known route, cast it with as Unstable_RouteHref.
router.prefetch({ to: '/posts/[slug]', params: { slug } });For navigating to dynamic routes with typed params, see Typed Routes.
Link Prefetching
<Link> also has experimental prefetch helpers for common interaction patterns:
import { Link } from 'waku';
export const Nav = () => (
<nav>
<Link to="/docs" unstable_prefetchOnEnter={{}}>
Docs
</Link>
<Link to="/blog" unstable_prefetchOnView={{ mode: 'once' }}>
Blog
</Link>
</nav>
);- unstable_prefetchOnEnter starts prefetching when the pointer enters the link.
- unstable_prefetchOnView starts prefetching when the link enters the viewport.
Both props take an options object. Passing an empty object enables prefetching with the defaults; omitting the prop disables it.
- mode: 'always' (the default) fetches on the first trigger and dedupes repeat triggers for the same path and query within the ttl. Within the ttl, navigation reuses the prefetched response without another request.
- mode: 'once' fetches a route at most once per session, ignoring the query. Its main purpose is to warm the route's static parts, which cannot change within a build, so instant navigation can paint them even on the route's first visit. Warmed routes are kept in a bounded store, so a long session may eventually fetch a route again.
- ttl sets how long a prefetched response stays reusable, in milliseconds (default: 60000).
The same options work with router.prefetch(to, options).
Prefer intent-based prefetching for expensive dynamic routes. View-based prefetching can be useful for short pages with a small number of important links, but it can waste server work if applied to every link in a large list.
Instant Navigation
By default, navigating to a dynamic route waits for the server response before the page updates. With the experimental unstable_instant option, Waku instead paints the route's cached static shell (including its <Suspense> fallbacks) right away, then streams the dynamic parts in.
It relies on two things:
- The route's static shell is already cached, from an earlier visit or from a prefetch (any prefetch caches the static parts it learns, so a prefetched route's first visit can be instant).
- The dynamic part of the page sits inside a <Suspense> boundary, so there is a fallback to show while it streams.
Opt in per navigation with the unstable_instant prop on <Link>. Here a static layout wraps the dynamic page in <Suspense>:
import { Suspense } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'waku';
export default function Layout({ children }: { children: ReactNode }) {
return (
<div>
<nav>
<Link to="/posts/1" unstable_instant>
Post 1
</Link>
<Link to="/posts/2" unstable_instant>
Post 2
</Link>
</nav>
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
</div>
);
}The page it streams in is an ordinary dynamic route:
import type { PageProps } from 'waku/router';
export default async function Post({ id }: PageProps<'/posts/[id]'>) {
const post = await loadPost(id); // request-specific work
return <article>{post.body}</article>;
}
export const getConfig = async () => {
return { render: 'dynamic' } as const;
};You can also navigate programmatically with router.push or router.replace:
'use client';
import { useRouter } from 'waku';
export const PostButton = () => {
const router = useRouter();
return (
<button onClick={() => router.push('/posts/1', { unstable_instant: true })}>
Post 1
</button>
);
};When the shell is cached, the layout and the Loading... fallback appear with no round trip, and the post's content streams into the fallback once the server responds. When the shell is not cached, the navigation falls back to normal behavior: the current page stays put until the response arrives, so there is no blank flash.
The <Suspense> boundary decides how localized the loading state is. Wrapping the whole page area (as above) swaps the page for a skeleton; wrapping only a dynamic slice inside an otherwise-static page leaves the rest in place and shows the skeleton for just that slice.
Because an instant navigation commits before the server responds, it reconciles afterward: a server redirect updates the URL to the redirect target, while a not-found (404) response keeps the attempted URL, so the 404 page shows at the address the user tried.
Instant navigation commits urgently rather than inside a React transition, since a transition would suppress the immediate skeleton. A custom unstable_startTransition therefore does not run for instant links, and useNavigationStatus_UNSTABLE() does not report pending for them; drive pending UI from route-change events instead.
Pending UI
A descendant of <Link> can read the navigation status with useNavigationStatus_UNSTABLE() and render pending UI while a navigation transition is in progress. It works like React's useFormStatus: it reflects the nearest enclosing <Link>, and pending stays true until the destination route's async components resolve (including client-only Suspense).
'use client';
import { useNavigationStatus_UNSTABLE as useNavigationStatus } from 'waku/router/client';
export const PendingIndicator = () => {
const { pending } = useNavigationStatus();
return (
<span aria-hidden style={{ opacity: pending ? 1 : 0 }}>
Loading...
</span>
);
};import { Link } from 'waku';
import { PendingIndicator } from './pending-indicator';
export const NavLink = () => (
<Link to="/reports">
Reports
<PendingIndicator />
</Link>
);useNavigationStatus_UNSTABLE() must be called from a Client Component rendered inside the <Link>, which means the indicator lives inside the <a>. Keep it non-interactive and aria-hidden (as above), since it becomes part of the link's accessible name and click target; to render pending UI outside the anchor, drive it from route-change events instead. Called outside any <Link>, the hook returns an empty object (not { pending: false }).
Custom Transitions
For advanced navigation effects, pass unstable_startTransition to control how Waku starts the route transition. One common use case is integrating the browser View Transitions API:
'use client';
import type { ComponentProps } from 'react';
import { Link as WakuLink } from 'waku';
const startViewTransition =
typeof document !== 'undefined' && document.startViewTransition
? (fn: () => void) => {
document.startViewTransition(fn);
}
: undefined;
export const Link = (props: ComponentProps<typeof WakuLink>) => (
<WakuLink {...props} unstable_startTransition={startViewTransition} />
);Because unstable_startTransition replaces React's transition, useNavigationStatus_UNSTABLE() stays { pending: false } for links that use it.
The prefetch, instant-navigation, and transition props in this guide are experimental and may change.

