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> 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).

A repeat prefetch sends the etags of the stored response, so the server only renders and sends what changed.

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>
  );
};

Both return a promise that resolves once the navigation you asked for has been handled: after the response when the route needs one, right away when it does not, such as a static route already loaded once or the route you are already on, and also when a newer navigation supersedes it.

When a fetch redirects to another route or needs the custom 404 page, the same navigation fetches that destination before it resolves. Anything your page throws later while rendering, such as a late unstable_notFound() or unstable_redirect(), is followed by a navigation of its own after this promise has settled.

A missing route depends on who answers the request. A Waku server can send the 404 page as the first response. Where the first request instead reports a missing route, the client fetches the custom 404 page itself. In both cases, the promise resolves with the /404 route while the address bar keeps the URL that was requested. Without a custom 404 route, the promise rejects and the built-in Not Found page is shown.

The promise also rejects when the navigation fails outright and when a redirect hands the page to the browser. A redirect to a route of this app is different: the client follows it and the promise resolves with that destination. Catch failures when you call these methods programmatically. Navigation failures are rendered through ErrorBoundary. The promise does not wait for React to finish rendering the destination, so the address bar may still show the previous URL when it resolves.

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 requested URL, so the 404 page shows at the address the user tried.

An instant navigation that can commit from cache, or that needs no fetch, 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 that commit. When the shell is not cached, navigation waits for the response and uses the custom transition to commit the destination; useNavigationStatus_UNSTABLE() reports pending for that wait. When the cache commit is available, the shell itself is the pending state.

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, pointerEvents: 'none' }}
    >
      Loading...
    </span>
  );
};
'use client';

import { Link as WakuLink } from 'waku';
import type {
  LinkProps,
  Unstable_RoutePath as RoutePath,
} from 'waku/router/client';
import { PendingIndicator } from './pending-indicator';

export const PendingLink = <Path extends RoutePath>({
  children,
  ...props
}: LinkProps<Path>) => (
  <WakuLink {...props}>
    {children}
    <PendingIndicator />
  </WakuLink>
);

Use PendingLink where a link needs an indicator. Keep the indicator non-interactive and aria-hidden (as above) so it does not affect the link's accessible name or introduce a nested interactive target.

useNavigationStatus_UNSTABLE() must be called from a Client Component rendered inside the <Link> because the status belongs to the nearest link. It does not observe programmatic navigation, browser back and forward, or a link with a custom transition. Instant links report pending when they fall back to waiting for a response; a cache commit does not, because the shell itself is the pending state. Called outside any <Link>, the hook returns an empty object (not { pending: false }).

Observe Route Changes

useRouter().unstable_events was removed. For pending UI on a link, use useNavigationStatus_UNSTABLE() above. For view tracking, observe the committed route fields:

'use client';

import { useEffect } from 'react';
import { useRouter } from 'waku';

export const NavigationLogger = () => {
  const { path, query, hash } = useRouter();
  useEffect(() => {
    console.log('viewed route', { path, query, hash });
  }, [path, query, hash]);
  return null;
};

The effect runs for the initial route and whenever the current route fields change. It does not run again for reload() or navigation to the same URL, and it does not observe the start of a navigation. Instant navigation commits the requested route before reconciliation, so a redirected instant navigation logs the optimistic route and then the reconciled one; keep the previous value if you only want landings.

Catch the promises returned by programmatic push, replace, and reload calls to handle fetch errors. There is no global failure callback for <Link> or browser back and forward; failed navigations surface through ErrorBoundary from waku/router/client (or your own boundary around the router). Do not treat promise resolution as a committed-route signal: a newer navigation can supersede the request, and a render-time redirect or 404 can start another navigation after it settles. Observe the route fields above when you need the destination that was committed.

Custom Transitions

For advanced navigation effects, pass unstable_startTransition to control how Waku commits the destination. Waku waits for required route data before starting the custom transition. One common use case is integrating the browser View Transitions API:

'use client';

import { Link as WakuLink } from 'waku';
import type {
  LinkProps,
  Unstable_RoutePath as RoutePath,
} from 'waku/router/client';

const startViewTransition =
  typeof document !== 'undefined' && document.startViewTransition
    ? (fn: () => void) => {
        document.startViewTransition(fn);
      }
    : undefined;

export const ViewTransitionLink = <Path extends RoutePath>(
  props: LinkProps<Path>,
) => <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.

designed bycandycode alternative graphic design web development agency San Diego