Redirects and Not Found
Handle app-level redirects, missing data, and custom 404 pages.
Choose the Redirect Type
Waku supports two different redirect patterns:
- Use app-level redirects when route code decides where the user should go.
- Use external redirect maps when a proxy, CDN, middleware, or hosting platform should redirect paths before Waku renders.
This guide covers app-level redirects and not-found handling. For infrastructure redirects, see Redirect Maps.
requestRedirect() and requestNotFound() are supported during dynamic server rendering, in server functions, and in runtime handler interceptors before next(). Static pages, layouts, slices, and other immutable or cached sources produce content only; do not record routing intents from their render functions or nested server components.
On static-only deployments, configure redirects at the host. Exclude invalid paths from the static build or render fallback content. A custom /404 page can be static because it renders content rather than requesting navigation.
requestRedirect() and requestNotFound() record navigation without stopping your function. Return normally, or call unstable_cancelRender() from waku/minimal/server to terminate the current render. The deprecated unstable_redirect() and unstable_notFound() combine those steps and return never. You can define your own terminating wrapper:
import { unstable_cancelRender as cancelRender } from 'waku/minimal/server';
import { requestNotFound } from 'waku/router/server';
export function notFound(): never {
requestNotFound();
cancelRender();
}Return after requesting navigation. Ordinary rendering left in the response must eventually finish; Waku does not guarantee cancellation of discarded response streams. Avoid leaving permanently pending work behind a redirect or not-found intent.
Redirect from Server Code
Use requestRedirect from waku/router/server in dynamic server components or server functions, then return. API handlers own their HTTP responses: return Response.redirect() or a response with status 404 there.
// src/pages/account.tsx
import { requestRedirect } from 'waku/router/server';
import { getCurrentUser } from '../lib/auth';
export default async function AccountPage() {
const user = await getCurrentUser();
if (!user) {
requestRedirect('/login');
return null;
}
return <h1>Welcome, {user.name}</h1>;
}
export const getConfig = async () => ({ render: 'dynamic' }) as const;requestRedirect(location, status?) accepts an app-relative href, an absolute HTTP(S) URL string, or a URL object. An absolute URL navigates the whole document, even when it points to the same origin. Validate destinations derived from user input against an application allowlist.
An app-relative redirect must change the path or query. Use client navigation for fragment-only changes, such as <Link to="#comments">Comments</Link> or router.replace('#comments'). A redirect to a different server route can include a fragment, such as /posts?tab=comments#comments. For conditional scrolling after a server function, return the desired section to the client and update the fragment there.
The supported status codes are:
- 303
- 307
- 308
The default is 307. A document response to a form POST uses 303 regardless of the requested status, so the browser follows it with a GET.
Use a local href factory for typed params and search, then pass its string result to requestRedirect:
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { requestRedirect } from 'waku/router/server';
const postHref = createRouteHref({ path: '/posts/[slug]' });
requestRedirect(postHref({ params: { slug: post.slug } }), 303);The factory can bind a search codec directly; no server or browser registry is required. Its result excludes the application base path, which requestRedirect applies. See Typed Routes.
Redirect After a Server Action
Record the redirect after the mutation completes, then return:
// src/pages/posts/new.tsx
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { requestRedirect } from 'waku/router/server';
import { createPost } from '../../lib/posts';
const postHref = createRouteHref({ path: '/posts/[slug]' });
export default function NewPostPage() {
return (
<form
action={async (formData) => {
'use server';
const post = await createPost({
title: String(formData.get('title') || ''),
});
requestRedirect(postHref({ params: { slug: post.slug } }));
return;
}}
>
<input name="title" />
<button>Create post</button>
</form>
);
}With JavaScript, the response carries a route intent and the client navigates to the destination. Without JavaScript, a redirect requested before the shell is sent produces an HTTP redirect; a form POST uses 303 so the destination receives a GET.
To stay on the page and show the action's changes instead, see Rerendering After Server Actions.
Render Not Found for Missing Data
Use requestNotFound in a dynamic route when it matches but the backing data does not exist, then return:
// src/pages/posts/[slug].tsx
import type { PageProps } from 'waku/router';
import { requestNotFound } from 'waku/router/server';
import { getPost } from '../../lib/posts';
export default async function PostPage({ slug }: PageProps<'/posts/[slug]'>) {
const post = await getPost(slug);
if (!post) {
requestNotFound();
return null;
}
return <article>{post.title}</article>;
}
export const getConfig = async () => ({ render: 'dynamic' }) as const;Add a Custom 404 Page
Create src/pages/404.tsx to customize the not-found page:
// src/pages/404.tsx
export default function NotFoundPage() {
return (
<main>
<h1>Not found</h1>
<p>The page you requested does not exist.</p>
</main>
);
}
export const getConfig = async () => {
return {
render: 'static',
} as const;
};If a /404 page exists, Waku uses it for unmatched routes and requestNotFound() intents. If it does not exist, Waku falls back to a minimal not-found response.
A form POST without JavaScript to an unresolved route renders the custom 404 page, when configured, or the default 404 response without executing the submitted action.
A client-side navigation to a missing route is answered the same way: the server sends the 404 page in the response, so the client does not need a second request. That response carries status 200, because it carries a page rather than an error. A direct page load of the same URL is still a 404. Where no Waku server answers, such as a static export, the request 404s and the client fetches the 404 page itself.
Handle Unmatched Requests in Middleware
Middleware in src/middleware wraps Waku, so after await next() it sees Waku's response, including the 404 for a request no route matched. To hand those requests to another handler, such as an existing Hono app during a migration, replace the response:
// src/middleware/legacy.ts
import type { MiddlewareHandler } from 'hono';
import { legacyApp } from '../legacy-app';
const legacy = (): MiddlewareHandler => async (c, next) => {
await next();
if (c.res.status === 404) {
c.res = await legacyApp.fetch(c.req.raw);
}
};
export default legacy;This also replaces the /404 page and requestNotFound() responses, so leave out the /404 page while another app owns unmatched paths.
Pending Client Intents
An intent in the current page response stays attached to that response. A hash-only navigation that reuses the rendered route preserves its pending intent.
Intents from independent slice requests and server function responses without a route belong to the navigation state where the request started. A later Router navigation, including a hash-only change, makes these pending intents stale. A navigation already in flight also takes precedence.
Start mount-time slice requests and server function calls in useEffect, after the Router has committed the route. Requests started from useLayoutEffect can capture the previous navigation state.
If an application needs different behavior, return a destination as the server function's result and let the client decide whether to call router.replace(). The return value remains available even when Waku discards a stale intent.
Use Router's push or replace to coordinate query and hash changes with route rendering. A full route response can replace URL changes made directly with the History API; a partial response leaves them alone.
Document Navigation Handoff
An absolute redirect hands control to the browser. Router does not detect whether the document actually leaves. If a beforeunload prompt or embedding policy prevents departure, Waku does not guarantee URL rollback, removal of an instant preview, or continued processing of old intents and origin rerenders. Start a fresh Router navigation or reload to recover.
Use a native anchor for flows that need the browser's cancelable document navigation and history behavior:
<a href="/account/logout">Log out</a>The server can redirect that document request to another origin without starting a client Router transition.
Status and Streaming Notes
Ready intents are followed before the source route commits during client navigation. Initial client rendering, including No SSR, handles intents after the source tree commits. To redirect before source content mounts on a direct load, record the intent during route resolution or in a handler interceptor before next(), or use middleware.
When not-found is requested before the document response is returned, a direct request server-renders the custom /404 page and returns status 404. Handler interceptors must request it before calling next(), then return next() to render the response. If it is requested from inside a Suspense boundary after the shell has already been sent, the response keeps status 200 and the browser completes the not-found handling after hydration. This late path requires JavaScript; without it, the Suspense fallback remains.
Redirects have the same streaming constraint. Once a response has started streaming to the browser, the server cannot change its HTTP status or response headers.
For redirects that should happen before Waku handles the request at all, use middleware or a hosting-platform redirect map instead.

