---
slug: redirects-and-not-found
title: Redirects and Not Found
description: Handle app-level redirects, missing data, and custom 404 pages.
category: Routing and Navigation
order: 30
---
## 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](/guides/redirect-maps).
The APIs in this guide currently use `unstable_` names and may change.
## Redirect from Server Code
Use `unstable_redirect` from `waku/router/server` in server-only code:
```tsx
// src/pages/account.tsx
import { unstable_redirect as redirect } from 'waku/router/server';
import { getCurrentUser } from '../lib/auth';
export default async function AccountPage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return
Welcome, {user.name}
;
}
```
`redirect(location, status?)` accepts a pathname and one of these status codes:
- `303`
- `307`
- `308`
The default is `307`.
Use a local href factory for typed params and search, then pass its string result to `redirect`:
```tsx
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { unstable_redirect as redirect } from 'waku/router/server';
const postHref = createRouteHref({ path: '/posts/[slug]' });
redirect(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 `redirect` applies. See [Typed Routes](/guides/typed-routes).
## Redirect After a Server Action
For form submissions and mutations, use `303` when the next page should be loaded with a `GET` request:
```tsx
// src/pages/posts/new.tsx
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { unstable_redirect as redirect } from 'waku/router/server';
import { createPost } from '../../lib/posts';
const postHref = createRouteHref({ path: '/posts/[slug]' });
export default function NewPostPage() {
return (
);
}
```
Waku renders the destination into the same response, so the client does not make a second request. That only works for a target it can render: a path on this site, without a hash. Anything else, including a target that is not a route at all or one that renders not found, is handed to the browser as a 303, which costs a full page load. The 303 is what keeps the form submission from being sent again to the destination.
To stay on the page and show the action's changes instead, see [Rerendering After Server Actions](/guides/rerender-after-actions).
## Render Not Found for Missing Data
Use `unstable_notFound` when a route matches but the backing data does not exist:
```tsx
// src/pages/posts/[slug].tsx
import type { PageProps } from 'waku/router';
import { unstable_notFound as notFound } 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) {
notFound();
}
return {post.title};
}
```
## Add a Custom 404 Page
Create `src/pages/404.tsx` to customize the not-found page:
```tsx
// src/pages/404.tsx
export default function NotFoundPage() {
return (
Not found
The page you requested does not exist.
);
}
export const getConfig = async () => {
return {
render: 'static',
} as const;
};
```
If a `/404` page exists, Waku uses it for unmatched routes and `notFound()` results. If it does not exist, Waku falls back to a minimal not-found response.
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.
## Status and Streaming Notes
When `notFound()` is thrown before the initial HTML shell is ready, a direct request server-renders the custom `/404` page and returns status 404. If it is thrown 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.