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.

It does not wait for a follow. Anything your page throws while rendering, such as a late unstable_notFound() or unstable_redirect(), is followed by a navigation of its own that starts after this promise has settled.

A missing route depends on who answers the request. A Waku server sends the 404 page as the response, so the promise resolves and the route you land on is /404. Where no Waku server answers, such as a static export or a CDN serving its own 404, the request fails and the client fetches the 404 page itself: there the promise rejects and the 404 page arrives from a second navigation.

The promise also rejects when the navigation fails outright, and when a redirect carried by the response hands the page to the browser. Catch it if you call these programmatically, or an auth redirect at the fetch layer will surface as an unhandled rejection. It 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 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. An instant navigation paints its cached shell immediately, so 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 }}>
      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; there is no supported way yet to render it outside the anchor, because a navigation runs in a transition that deliberately keeps the current page on screen until the destination is ready. Called outside any <Link>, the hook returns an empty object (not { pending: false }).

Route Change Events

Subscribe to route change events to observe navigations, for example to log them or to report them to analytics:

'use client';

import { useEffect } from 'react';
import { useRouter } from 'waku';
import type { Unstable_RouteProps } from 'waku/router/client';

export const NavigationLogger = () => {
  const { unstable_events } = useRouter();
  useEffect(() => {
    const started = (route: Unstable_RouteProps) =>
      console.log('navigating to', route.path);
    const landed = (route: Unstable_RouteProps) =>
      console.log('landed on', route.path);
    const gaveUp = (route: Unstable_RouteProps) =>
      console.log('gave up on', route.path);
    unstable_events.on('start', started);
    unstable_events.on('complete', landed);
    unstable_events.on('error', gaveUp);
    return () => {
      unstable_events.off('start', started);
      unstable_events.off('complete', landed);
      unstable_events.off('error', gaveUp);
    };
  }, [unstable_events]);
  return null;
};

Every start is followed by exactly one complete or error, and they never interleave: when a second navigation supersedes one that is still running, the first one's error arrives before the second one's start.

complete carries the route the response landed on, which is not always the one you asked for, because the server can answer with the destination of a redirect or with the 404 page. It fires when the response has been handled, not when React has finished rendering it. error means the navigation failed or was superseded, and reports the route it announced.

A follow is a navigation of its own, so it opens its own start. A late unstable_redirect() reports complete for the page that threw it and then a second pair for the destination. A navigation that fails first, such as a 404 with no Waku server to answer it, reports error and then the follow's own pair.

A listener that throws is reported to the console and does not affect the navigation or the other listeners. A listener that navigates gets a navigation right away, and its events queue behind the one being delivered, so every listener sees the same order whenever it subscribed.

A redirect carried by the response hands the page to the browser. It still reports error for the navigation it was asked to make, and the full page load follows.

For a spinner, prefer useNavigationStatus_UNSTABLE() above. A start listener runs inside the navigation's transition, so state you set there is batched with the navigation itself and may not paint on its own.

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.

designed bycandycode alternative graphic design web development agency San Diego