# Technical Documentation: wakujs/waku

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/wakujs/waku)

### Overview (`README.md`)

# Waku

⛩️ The minimal React framework

visit [waku.gg](https://waku.gg) or `npm create waku@latest`

[](https://github.com/wakujs/waku/actions?query=workflow%3ATest)
[](https://www.npmjs.com/package/waku)
[](https://www.npmjs.com/package/waku)
[](https://discord.gg/MrQdmzd)

<br>

## Introduction

**Waku** _(wah-ku)_ or **わく** is the minimal React framework. It's lightweight and designed for a fun developer experience, yet supports all the latest React 19 features like server components and actions. Built for marketing sites, headless commerce, and full-stack web apps, small or large. Whether Waku fits is about the architecture you want, not the size of your project: Waku keeps its framework surface minimal and composes with ecosystem libraries, while heavier frameworks own more of those concerns for you.

## Getting started

Start a new Waku project with the `create` command for your preferred package manager. It will scaffold a new project with our default [Waku starter](https://github.com/wakujs/waku/tree/main/templates/01_basic).

```sh
npm create waku@latest
```

#### Commands

- `waku dev` to start the local development server
- `waku build` to generate a production build
- `waku start` to serve the production build locally

**Node.js version requirement:** `^26.0.0` or `^24.0.0` or `^22.15.0`

For a guided path, start with the [Quick Start](https://waku.gg/guides/quick-start) guide and continue with the Learn series on [waku.gg/guides](https://waku.gg/guides), which builds a small app step by step.

## Rendering

While there's a bit of a learning curve to modern React rendering, it introduces powerful new patterns of full-stack composability that are only possible with the advent of [server components](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md).

So please don't be intimidated by the `'use client'` directive! Once you get the hang of it, you'll appreciate how awesome it is to flexibly move server-client boundaries with a single line of code as your full-stack React codebase evolves over time. It's way simpler than maintaining separate codebases for your backend and frontend.

And please don't fret about client components! Even if you only lightly optimize towards server components, your client bundle size will be smaller than that of a fully client-rendered React app.

> Future versions of Waku may provide additional opt-in APIs to abstract some of the complexity away for an improved developer experience.

#### Server components

Server components can be made async and can securely perform server-side logic and data fetching. Feel free to access the local file-system and import heavy dependencies since they aren't included in the client bundle. They have no state, interactivity, or access to browser APIs since they run _exclusively_ on the server.

```tsx
// server component
import db from 'some-db';

import { Gallery } from '../components/gallery';

export const Store = async () => {
  const products = await db.query('SELECT * FROM products');

  return <Gallery products={products} />;
};
```

#### Client components

A `'use client'` directive placed at the top of a file will create a server-client boundary when imported into a server component. All components imported below the boundary will be hydrated to run in the browser as well. They can use all traditional React features such as state, effects, and event handlers.

```tsx
// client component
'use client';

import { useState } from 'react';

export const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>Count: {count}</div>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </>
  );
};
```

#### Shared components

Simple React components that [meet all of the rules](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#sharing-code-between-server-and-client) of both server and client components can be imported into either server or client components without affecting the server-client boundary.

```tsx
// shared component
export const Headline = ({ children }) => {
  return <h3>{children}</h3>;
};
```

#### Weaving patterns

Server components can import client components and doing so will create a server-client boundary. Client components cannot import server components, but they can accept server components as props such as `children`. For example, you may want to add global context providers this way.

```tsx
// ./src/pages/_layout.tsx
import { Providers } from '../components/providers';

export default async function RootLayout({ children }) {
  return (
    <Providers>
      <main>{children}</main>
    </Providers>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```tsx
// ./src/components/providers.tsx
'use client';

import { Provider } from 'jotai';

export const Providers = ({ children }) => {
  return <Provider>{children}</Provider>;
};
```

#### Server-side rendering

Waku provides static prerendering (SSG) and server-side rendering (SSR) options for both layouts and pages including all of their server _and_ client components. Note that SSR is a distinct concept from RSC.

#### tl;dr:

Each layout and page in Waku is composed of a React component hierarchy.

It begins with a server component at the top of the tree. Then at points down the hierarchy, you'll eventually import a component that needs client component APIs. Mark this file with a `'use client'` directive at the top. When imported into a server component, it will create a server-client boundary. Below this point, all imported components are hydrated and will run in the browser as well.

Server components can be rendered below this boundary, but only via composition (e.g., `children` props). Together they form [a new "React server" layer](https://github.com/reactwg/server-components/discussions/4) that runs _before_ the traditional "React client" layer with which you're already familiar.

Client components are still server-side rendered as SSR is separate from RSC. Please see the [linked diagrams](https://github.com/reactwg/server-components/discussions/4) for a helpful visual.

#### Further reading

To learn more about the modern React architecture, we recommend [Making Sense of React Server Components](https://www.joshwcomeau.com/react/server-components/) and [The Two Reacts](https://overreacted.io/the-two-reacts/).

## Routing

Waku provides a minimal file-based "pages router" experience built for the server components era.

Its underlying [low-level API](https://github.com/wakujs/waku/blob/main/docs/create-pages.mdx) is also available for those that prefer programmatic routing. This documentation covers file-based routing since many React developers prefer it, but please feel free to try both and see which you like more!

### Overview

The directory for file-based routing in Waku projects is `./src/pages`.

Layouts and pages can be created by making a new file with two exports: a default function for the React component and a named `getConfig` function that returns a configuration object to specify the render method and other options.

Waku currently supports two rendering options:

- `'static'` for static prerendering (SSG)

- `'dynamic'` for server-side rendering (SSR)

Layouts, pages, and slices are all `static` by default, while api handlers default to `dynamic`.

For example, you can statically prerender a global header and footer in the root layout at build time, but dynamically render the rest of a home page at request time for personalized user experiences.

```tsx
// ./src/pages/_layout.tsx
import '../styles.css';

import { Providers } from '../components/providers';
import { Header } from '../components/header';
import { Footer } from '../components/footer';

// Create root layout
export default async function RootLayout({ children }) {
  return (
    <Providers>
      <Header />
      <main>{children}</main>
      <Footer />
    </Providers>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```tsx
// ./src/pages/index.tsx

// Create home page
export default async function HomePage() {
  const data = await getData();

  return (
    <>
      <h1>{data.title}</h1>
      <div>{data.content}</div>
    </>
  );
}

const getData = async () => {
  /* ... */
};

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

### Pages

Pages render a single route, segment route, or catch-all route based on the file system path (conventions below). All page components automatically receive two props related to the rendered route: `path` (string) and `query` (string).

#### Single routes

Pages can be rendered as a single route (e.g., `about.tsx` or `blog/index.tsx`).

```tsx
// ./src/pages/about.tsx

// Create about page
export default async function AboutPage() {
  return <>{/* ...*/}</>;
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```tsx
// ./src/pages/blog/index.tsx

// Create blog index page
export default async function BlogIndexPage() {
  return <>{/* ...*/}</>;
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

#### Segment routes

Segment routes (e.g., `[slug].tsx` or `[slug]/index.tsx`) are marked with brackets.

The rendered React component automatically receives a prop named by the segment (e.g., `slug`) with the value of the rendered segment (e.g., `'introducing-waku'`).

If statically prerendering a segment route at build time, a `staticPaths` array must also be provided.

```tsx
// ./src/pages/blog/[slug].tsx
import type { PageProps } from 'waku/router';

// Create blog article pages
export default async function BlogArticlePage({
  slug,
}: PageProps<'/blog/[slug]'>) {
  const data = await getData(slug);

  return <>{/* ...*/}</>;
}

const getData = async (slug) => {
  /* ... */
};

export const getConfig = async () => {
  return {
    render: 'static',
    staticPaths: ['introducing-waku', 'introducing-pages-router'],
  } as const;
};
```

```tsx
// ./src/pages/shop/[category].tsx
import type { PageProps } from 'waku/router';

// Create product category pages
export default async function ProductCategoryPage({
  category,
}: PageProps<'/shop/[category]'>) {
  const data = await getData(category);

  return <>{/* ...*/}</>;
}

const getData = async (category) => {
  /* ... */
};

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

Static paths (or other config values) can also be generated programmatically.

```tsx
// ./src/pages/blog/[slug].tsx
import type { PageProps } from 'waku/router';

// Create blog article pages
export default async function BlogArticlePage({
  slug,
}: PageProps<'/blog/[slug]'>) {
  const data = await getData(slug);

  return <>{/* ...*/}</>;
}

const getData = async (slug) => {
  /* ... */
};

export const getConfig = async () => {
  const staticPaths = await getStaticPaths();

  return {
    render: 'static',
    staticPaths,
  } as const;
};

const getStaticPaths = async () => {
  /* ... */
};
```

#### Nested segment routes

Routes can contain multiple segments (e.g., `/shop/[category]/[product]`) by creating folders with brackets as well.

```tsx
// ./src/pages/shop/[category]/[product].tsx
import type { PageProps } from 'waku/router';

// Create product category pages
export default async function ProductDetailPage({
  category,
  product,
}: PageProps<'/shop/[category]/[product]'>) {
  return <>{/* ...*/}</>;
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

For static prerendering of nested segment routes, the `staticPaths` array is instead composed of ordered arrays.

```tsx
// ./src/pages/shop/[category]/[product].tsx
import type { PageProps } from 'waku/router';

// Create product detail pages
export default async function ProductDetailPage({
  category,
  product,
}: PageProps<'/shop/[category]/[product]'>) {
  return <>{/* ...*/}</>;
}

export const getConfig = async () => {
  return {
    render: 'static',
    staticPaths: [
      ['same-category', 'some-product'],
      ['same-category', 'another-product'],
    ],
  } as const;
};
```

#### Catch-all routes

Catch-all or "wildcard" segment routes (e.g., `/app/[...catchAll]`) are marked with an ellipsis before the name and have indefinite segments.

Wildcard routes receive a prop with segment values as an ordered array. For example, the `/app/profile/settings` route would receive a `catchAll` prop with the value `['profile', 'settings']`. These values can then be used to determine what to render in the component.

```tsx
// ./src/pages/app/[...catchAll].tsx
import type { PageProps } from 'waku/router';

// Create dashboard page
export default async function DashboardPage({
  catchAll,
}: PageProps<'/app/[...catchAll]'>) {
  return <>{/* ...*/}</>;
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

#### Group routes

Group routes allow you to organize routes into logical groups without affecting the URL structure. They're created by wrapping directory names in parentheses (e.g., `(group)`). This is particularly useful for sharing layouts across multiple routes while keeping the URL clean.

For example, you might want a home page at `/` that doesn't use a shared layout, but all other routes should share a common layout. This can be achieved by grouping those routes:

```
├── (main)
│ ├── _layout.tsx
│ ├── about.tsx
│ └── contact.tsx
└── index.tsx
```

In this structure, `/about` and `/contact` will use the layout from `(main)/_layout.tsx`, but `/` (from `index.tsx`) will not.

```tsx
// ./src/pages/(main)/_layout.tsx
import { Header } from '../../components/header';
import { Footer } from '../../components/footer';

// Create shared layout for main pages
export default async function MainLayout({ children }) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```tsx
// ./src/pages/(main)/about.tsx
export default async function AboutPage() {
  return <h1>About Us</h1>;
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

Group routes can be nested to create complex layout compositions. For instance, you could have a static layout at the group level and a dynamic layout nested within:

```
(main)
├── (dynamic)
│ ├── _layout.tsx # dynamic layout
│ ├── dashboard.tsx
│ └── profile.tsx
└── _layout.tsx # static layout
```

This allows for fine-grained control over rendering modes - some work can be done at build time (`static`) while other work happens at runtime (`dynamic`).

```tsx
// ./src/pages/(main)/_layout.tsx
// Static layout - runs at build time
export default async function MainLayout({ children }) {
  return <div className="main-container">{children}</div>;
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```tsx
// ./src/pages/(main)/(dynamic)/_layout.tsx
// Dynamic layout - runs at request time
export default async function DynamicLayout({ children }) {
  const userData = await fetchUserData(); // Dynamic data fetching

  return (
    <div className="dynamic-container">
      <UserContext.Provider value={userData}>{children}</UserContext.Provider>
    </div>
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

Group routes are especially powerful for organizing complex applications where different sections need different layouts, state management, or data requirements while maintaining clean URLs.

#### Ignored routes

The following directories are ignored by the router:

- `_actions`
- `_components`
- `_hooks`

All files inside these directories are excluded from routing.

For instance, in the case below, `pages/about.tsx` is routed to `/about`, but files like `_components/header.tsx` are not routed anywhere.

```
pages/
├── about.tsx
├── _components/
│   ├── header.tsx   // 👈🏼 ignored
│   ├── footer.tsx   // 👈🏼 ignored
│   ├── ...          // 👈🏼 ignored
```

### Router paths type safety

Import `PageProps` from `waku/router` for type-safe access to route parameters (as shown in the examples above). The type provides `path`, `query`, and all segment parameters:

```ts
PageProps<'/blog/[slug]'>;
// => { path: string; slug: string; query: string }

PageProps<'/shop/[category]/[product]'>;
// => { path: string; category: string; product: string; query: string }
```

### Layouts

Layouts are created with a special `_layout.tsx` file name and wrap the entire route and its descendants. They must accept a `children` prop of type `ReactNode`. While not required, you will typically want at least a root layout.

#### Root layout

The root layout placed at `./pages/_layout.tsx` is especially useful. It can be used for setting global styles, global metadata, global providers, global data, and global components, such as a header and footer.

```tsx
// ./src/pages/_layout.tsx
import '../styles.css';

import { Providers } from '../components/providers';
import { Header } from '../components/header';
import { Footer } from '../components/footer';

// Create root layout
export default async function RootLayout({ children }) {
  return (
    <Providers>
      <link rel="icon" type="image/png" href="/images/favicon.png" />
      <meta property="og:image" content="/images/opengraph.png" />
      <Header />
      <main>{children}</main>
      <Footer />
    </Providers>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```tsx
// ./src/components/providers.tsx
'use client';

import { createStore, Provider } from 'jotai';

const store = createStore();

export const Providers = ({ children }) => {
  return <Provider store={store}>{children}</Provider>;
};
```

#### Other layouts

Layouts are also helpful in nested routes. For example, you can add a layout at `./pages/blog/_layout.tsx` to add a sidebar to both the blog index and all blog article pages.

```tsx
// ./src/pages/blog/_layout.tsx
import { Sidebar } from '../../components/sidebar';

// Create blog layout
export default async function BlogLayout({ children }) {
  return (
    <div className="flex">
      <div>{children}</div>
      <Sidebar />
    </div>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

### Root element

The attributes of `<html>`, `<head>`, or `<body>` elements can be customized with the root element API. Create a special `_root.tsx` file in the `./src/pages` directory that accepts a `children` prop of type `ReactNode`.

```tsx
// ./src/pages/_root.tsx

// Create root element
export default async function RootElement({ children }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body data-version="1.0">{children}</body>
    </html>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

### Slices

Slices are reusable components that are defined in the `src/pages/_slices` directory. They allow you to compose pages by assembling components like normal React components while specifying alternate rendering patterns.

#### Creating slices

Slices are created by placing files in the `src/pages/_slices` directory. The slice ID corresponds to the filename, and nested slices use the full path as the ID.

```
 src/pages
 ├── _slices
 │   ├── one.tsx
 │   ├── two.tsx
 │   └── nested
 │       └── three.tsx
 └── some-page.tsx
```

Each slice file exports a default React component and a `getConfig` function that specifies the render method.

```tsx
// ./src/pages/_slices/one.tsx

// Create slice component
export default function SliceOne() {
  return <p>🍕</p>;
}

export const getConfig = () => {
  return {
    render: 'static', // default is 'static'
  };
};
```

```tsx
// ./src/pages/_slices/nested/three.tsx

// Create nested slice component
export default function SliceThree() {
  return <p>🍰</p>;
}

export const getConfig = () => {
  return {
    render: 'dynamic',
  };
};
```

#### Using slices

Slices are used in pages and layouts by importing the `Slice` component from Waku and specifying the slice ID. The `slices` array in the page's `getConfig` must include all slice IDs used on that page.

```tsx
// ./src/pages/some-page.tsx
import { Slice } from 'waku';

// Create page with slices
export default function SomePage() {
  return (
    <div>
      <Slice id="one" />
      <Slice id="two" />
      <Slice id="nested/three" />
    </div>
  );
}

export const getConfig = () => {
  return {
    render: 'static',
    slices: ['one', 'two', 'nested/three'],
  };
};
```

#### Lazy slices

Lazy slices allow components to be requested independently from the page they are used on, similar to Astro's server islands feature. This is useful for components that will be dynamically rendered on otherwise static pages.

Lazy slices are marked with the `lazy` prop and can include a `fallback` component to display while loading.

```tsx
// ./src/pages/some-page.tsx
import { Slice } from 'waku';

// Create page with lazy slice
export default function SomePage() {
  return (
    <div>
      <Slice id="one" />
      <Slice id="two" lazy fallback={<p>Two is loading...</p>} />
    </div>
  );
}

export const getConfig = () => {
  return {
    render: 'static',
    slices: ['one'], // Note: 'two' is lazy, so it is not included
  };
};
```

This allows you to have a `dynamic` slice component while keeping the rest of the page static.

## Navigation

### Link

The `<Link />` component should be used for internal links. It accepts a `to` prop for the destination and handles client-side navigation through Waku's router.

`to` is a URL string. For typed params and search, use `unstable_createRouteHref` from `waku/router` to construct the string (see [Typed Routes](https://github.com/wakujs/waku/blob/main/docs/guides/typed-routes.mdx)). The same strings work with `router.push`, `router.replace`, and `router.prefetch`.

```tsx
// ./src/pages/index.tsx
import { Link } from 'waku';

export default async function HomePage() {
  return (
    <>
      <h1>Home</h1>
      <Link to="/about">About</Link>
      <Link to="/search?q=waku">Search</Link>
    </>
  );
}
```

### useRouter

The `useRouter` hook can be used to inspect the current route or perform programmatic navigation.

#### router properties

The `router` object has two properties related to the current route: `path` (string) and `query` (string).

```tsx
'use client';

import { useRouter } from 'waku';

export const Component = () => {
  const { path, query } = useRouter();

  return (
    <>
      <div>current path: {path}</div>
      <div>current query: {query}</div>
    </>
  );
};
```

#### router methods

The `router` object also contains several methods for programmatic navigation:

- `router.push(to)` - navigate to the provided URL string, including strings constructed with `unstable_createRouteHref` (see [Typed Routes](https://github.com/wakujs/waku/blob/main/docs/guides/typed-routes.mdx))

- `router.prefetch(to: string)` - prefetch the provided route

- `router.replace(to)` - replace the current history entry (same argument as `push`)

- `router.reload()` - reload the current route

- `router.back()` - navigate to the previous entry in the session history

- `router.forward()` - navigate to the next entry in the session history

```tsx
'use client';

import { useRouter } from 'waku';

export const Component = () => {
  const router = useRouter();

  return (
    <>
      <button onClick={() => router.push('/')}>Home</button>
      <button onClick={() => router.back()}>Back</button>
    </>
  );
};
```

## Error handling

Waku sets up a default error boundary at the root of your application. You can customize error handling by adding your own error boundaries anywhere, for example with the [`react-error-boundary`](https://www.npmjs.com/package/react-error-boundary) library.

When errors are thrown from server components or server functions, the errors are automatically replayed on browser. This allows the closest error boundaries to catch and handle these errors, even though they originated on the server.

```tsx
// ./src/pages/index.tsx
import { ErrorBoundary } from 'react-error-boundary';

export default async function HomePage() {
  return (
    <>
      <ErrorBoundary fallback={<div>Caught server component error!</div>}>
        <ThrowComponent />
      </ErrorBoundary>
      <ErrorBoundary fallback={<div>Caught server function error!</div>}>
        <form
          action={async () => {
            'use server';
            throw new Error('Oops!');
          }}
        >
          <button>Crash</button>
        </form>
      </ErrorBoundary>
    </>
  );
}

const ThrowComponent = async () => {
  throw new Error('Oops!');
  return <>...</>;
};
```

Error boundaries handle **unexpected errors** as a last resort safety net. For expected error conditions (like validation or network failures), handle them explicitly in your application logic.

In production, server errors are automatically obfuscated on the client to avoid revealing server internals. Detailed error messages and stack traces are only visible in development.

If you customize the root element (see [Root element](#root-element)), you should add your own error boundary there, as Waku's default root error boundary is included in the default root element.

## Metadata

Waku automatically hoists any title, meta, and link tags to the document head. That means adding meta tags is as simple as adding them to any of your layout or page components.

```tsx
// ./src/pages/_layout.tsx
export default async function RootLayout({ children }) {
  return (
    <>
      <link rel="icon" type="image/png" href="/images/favicon.png" />
      <meta property="og:image" content="/images/opengraph.png" />
      {children}
    </>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```tsx
// ./src/pages/index.tsx
export default async function HomePage() {
  return (
    <>
      <title>Waku</title>
      <meta name="description" content="The minimal React framework" />
      <h1>Waku</h1>
      <div>Hello world!</div>
    </>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

Metadata can also be generated programmatically.

```tsx
// ./src/pages/index.tsx
export default async function HomePage() {
  return (
    <>
      <Head />
      <div>{/* ...*/}</div>
    </>
  );
}

const Head = async () => {
  const metadata = await getMetadata();

  return (
    <>
      <title>{metadata.title}</title>
      <meta name="description" content={metadata.description} />
    </>
  );
};

const getMetadata = async () => {
  /* ... */
};

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

## Styling

### Global styles

Install any required dev dependencies (e.g., `npm i -D tailwindcss @tailwindcss/vite`) and set up any required configuration (e.g., `waku.config.ts`). Then create your global stylesheet (e.g., `./src/styles.css`) and import it into the root layout.

```tsx
// ./src/pages/_layout.tsx
import '../styles.css';

export default async function RootLayout({ children }) {
  return <>{children}</>;
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

```css
/* ./src/styles.css */
@import 'tailwindcss';
```

```js
// ./waku.config.ts
import { defineConfig } from 'waku/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
  },
});
```

## Static assets

Static assets such as images, fonts, stylesheets, and scripts can be placed in a special `./public` folder of the Waku project root directory. The public directory structure is served relative to the `/` base path.

```tsx
// assuming image is saved at `/public/images/logo.svg`

export const Logo = () => {
  return (
    <>
      <img src="/images/logo.svg" />
    </>
  );
};
```

## File system

Files placed in a special `./private` folder of the Waku project root directory can be securely accessed in React server components.

```tsx
export default async function HomePage() {
  const file = readFileSync('./private/README.md', 'utf8');

  return <>{/* ...*/}</>;
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

## Middleware

In the default Waku setup, files in `./src/middleware` are automatically loaded as [Hono middleware](https://hono.dev/docs/guides/middleware). Each file should default export a function that returns a `MiddlewareHandler`.

```ts
// ./src/middleware/logger.ts
import type { MiddlewareHandler } from 'hono';

const logger = (): MiddlewareHandler => {
  return async (c, next) => {
    console.log(c.req.method, c.req.path);
    await next();
  };
};

export default logger;
```

If you provide a custom `./src/waku.server.tsx`, pass middleware through the adapter options with `middlewareModules` or `middlewareFns`.

## Interceptors

Files in `./src/pages/_interceptors` are loaded as handler interceptors. Each file should default export a `HandlerInterceptor` from `waku/router/server`.

An interceptor wraps each render, in both the request and build phases, and runs inside the request scope. Use it for cross-cutting concerns around a render, such as logging or installing an `AsyncLocalStorage`.

```ts
// ./src/pages/_interceptors/logger.ts
import type { HandlerInterceptor } from 'waku/router/server';

const logger: HandlerInterceptor = async (next) => {
  console.log('render start');
  const result = await next();
  console.log('render end');
  return result;
};

export default logger;
```

## Data fetching

### Server

All of the wonderful patterns enabled by React server components are supported. For example, you can compile MDX files or perform code syntax highlighting on the server with zero impact on the client bundle size.

```tsx
// ./src/pages/blog/[slug].tsx
import type { PageProps } from 'waku/router';

import { MDX } from '../../components/mdx';
import { getArticle, getStaticPaths } from '../../lib/blog';

export default async function BlogArticlePage({
  slug,
}: PageProps<'/blog/[slug]'>) {
  const article = await getArticle(slug);

  return (
    <>
      <title>{article.frontmatter.title}</title>
      <h1>{article.frontmatter.title}</h1>
      <MDX>{article.content}</MDX>
    </>
  );
}

export const getConfig = async () => {
  const staticPaths = await getStaticPaths();

  return {
    render: 'static',
    staticPaths,
  } as const;
};
```

### Client

Data should be fetched on the server when possible for the best user experience, but all data fetching libraries such as React Query are compatible with Waku.

## Mutations

Data mutations can be performed via [server actions](https://react.dev/reference/rsc/server-actions) or API endpoints.

### API endpoints

Create API routes by making a new file in the special `./src/pages/_api` directory and exporting one or more functions named after the HTTP methods that you want it to support: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`, or `QUERY`. The name of the file determines the route it will be served from. Each function receives a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object and returns a standard [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object.

```ts
// ./src/pages/_api/contact.ts
import emailClient from 'some-email';

const client = new emailClient(process.env.EMAIL_API_TOKEN!);

export const POST = async (request: Request): Promise<Response> => {
  const body = await request.json();

  if (!body.message) {
    return Response.json({ message: 'Invalid' }, { status: 400 });
  }

  try {
    await client.sendEmail({
      From: 'noreply@example.com',
      To: 'someone@example.com',
      Subject: 'Contact form submission',
      Body: body.message,
    });

    return Response.json({ message: 'Success' }, { status: 200 });
  } catch (error) {
    return Response.json({ message: 'Failure' }, { status: 500 });
  }
};
```

Alternatively, you may export a default function as a "catch-all" handler that responds to all request methods.

```ts
// ./src/pages/_api/other-endpoint.ts
export default function handler(request: Request): Response {
  return Response.json(
    { message: 'Default handler ' + request.method },
    { status: 200 },
  );
}
```

#### Typed route parameters

For API routes with dynamic segments (e.g., `./src/pages/_api/users/[id].ts`), you can use `ApiContext` as the second parameter to get typed access to route parameters. The `params` property will be automatically typed based on the path pattern.

```ts
// ./src/pages/_api/users/[id].ts
import type { ApiContext } from 'waku/router';

export async function GET(
  _req: Request,
  { params }: ApiContext<'/users/[id]'>,
) {
  const { id } = params; // id is typed as string
  return Response.json({ id, message: `Hello user ${id}` });
}
```

This also works with multiple parameters and wildcard routes:

```ts
// ./src/pages/_api/files/[...path].ts
import type { ApiContext } from 'waku/router';

export async function GET(
  _req: Request,
  { params }: ApiContext<'/files/[...path]'>,
) {
  const { path } = params; // path is typed as string[]
  return Response.json({ segments: path });
}
```

#### Calling API routes

API routes are accessible at paths with the `_api` prefix stripped. For example a file at `./src/pages/_api/contact.ts` is available at `/contact`, and `./src/pages/_api/blog/rss.xml.ts` is available at `/blog/rss.xml`. You can call these endpoints from your client components using the standard [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) method.

```tsx
'use client';

import { useState } from 'react';

export const ContactForm = () => {
  const [message, setMessage] = useState('');
  const [status, setStatus] = useState('idle');

  const handleSubmit = async (event) => {
    event.preventDefault();
    setStatus('sending');

    try {
      const response = await fetch('/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message }),
      });

      const data = await response.json();

      if (response.status === 200) {
        setStatus('success');
        setMessage('');
      } else {
        setStatus('error');
        console.error('Error:', data.message);
      }
    } catch (error) {
      setStatus('error');
      console.error('Error:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={message}
        onChange={(event) => setMessage(event.target.value)}
        placeholder="Your message..."
        required
      />
      <button type="submit" disabled={status === 'sending'}>
        {status === 'sending' ? 'Sending...' : 'Send Message'}
      </button>
      {status === 'success' && <p>Message sent!</p>}
      {status === 'error' && <p>Failed. Please try again.</p>}
    </form>
  );
};
```

#### Configuring API routes

API routes are dynamic by default, but if you're using them to create a static resource such as an XML document, you can export a `getConfig` function that returns a config object with the render property set to `'static'`.

```ts
// ./src/pages/_api/blog/rss.xml.ts

export const GET = async () => {
  const rss = generateRSSFeed(); // your RSS generation logic

  return new Response(rss, {
    headers: { 'Content-Type': 'application/rss+xml' },
  });
};

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

### Server actions

Server actions allow you to define and securely execute server-side logic directly from your React components without the need for manually setting up API endpoints, sending `POST` requests to them with `fetch`, or managing pending states and errors.

#### Defining and protecting actions

The `'use server'` directive marks an async function as a server action. Waku automatically creates a reference to the action that can be passed as props or imported into client components, which can then call the referenced function.

When the directive is placed at the top of a function body, it will mark that specific function as an action. Alternatively, the directive can be placed at the top of a file, which will mark _all_ exported functions as actions at once.

Be careful not to add the directive where inappropriate and inadvertently create unwanted endpoints. Endpoints created by server actions are _not_ secured unless you add your own authentication and authorization logic inside the function body.

> The `'use server'` directive has no relation to the`'use client'` directive. It does **not** mark a component as a server component and should **not** be placed at the top of server components!

#### Making and consuming actions

When creating an inline server action within a server component, it can be passed as props to a client component.

```tsx
// ./src/pages/contact.tsx

import db from 'some-db';

export default async function ContactPage() {
  const sendMessage = async (message: string) => {
    'use server';
    await db.messages.create(message);
  };

  return <ContactForm sendMessage={sendMessage} />;
}
```

```tsx
// ./src/components/contact-form.tsx
'use client';

import { useState } from 'react';

export const ContactForm = ({ sendMessage }) => {
  const [message, setMessage] = useState('');

  return (
    <>
      <textarea
        value={message}
        onChange={(event) => setMessage(event.target.value)}
        rows={4}
      />
      <button onClick={() => sendMessage(message)}>Send message</button>
    </>
  );
};
```

When creating server actions in a separate file, they can be imported directly into client components.

> When using a top-level `'use server'` directive, note that _all_ exported functions will be made into API endpoints. So be careful only to export functions intended for this purpose. Add server-side logic to validate proper authentication and authorization if appropriate.

```tsx
// ./src/actions/send-message.ts
'use server';

import db from 'some-db';

export async function sendMessage(message: string) {
  await db.messages.create(message);
}
```

```tsx
// ./src/components/contact-button.tsx
'use client';

import { sendMessage } from '../actions/send-message';

export const ContactButton = () => {
  const message = `Hello world!`;

  return <button onClick={() => sendMessage(message)}>Send message</button>;
};
```

#### Invoking actions

Actions can be invoked via event handlers such as `onClick` or `onSubmit`, as in the examples above, or in a `useEffect` hook, based on whichever conditions you choose.

They can also be invoked via an `action` prop on native `<form>` elements. In this case the server action will automatically receive a parameter of `FormData` with all of the form field values, including hidden ones.

```tsx
// ./src/actions/send-message.ts
'use server';

import db from 'some-db';

export async function sendMessage(formData: FormData) {
  const message = formData.get('message');

  await db.messages.create(message);
}
```

```tsx
// ./src/components/create-todo-button.tsx
'use client';

import { sendMessage } from '../actions/send-message';

export const ContactForm = () => {
  return (
    <form action={sendMessage}>
      <textarea name="message" rows={4} />
      <input type="hidden" name="secret-message" value="This too!" />
      <button type="submit">Send message</button>
    </form>
  );
};
```

If you must pass additional arguments to a form action beyond its native form fields, you can use the `bind` method to create an extended server action with the extra arguments.

```tsx
// ./src/components/create-todo-button.tsx
'use client';

import { sendMessage } from '../actions/send-message';

export const ContactForm = ({ author = 'guest' }) => {
  const sendMessageWithAuthor = sendMessage.bind(null, author);

  return (
    <form action={sendMessageWithAuthor}>
      <textarea name="message" rows={4} />
      <button type="submit">Send message</button>
    </form>
  );
};
```

#### Enhancing actions

Server actions integrate with many other React APIs such as the [`useTransition`](https://react.dev/reference/react/useTransition) hook for handling pending states, the [`useActionState`](https://react.dev/reference/react/useActionState) hook for accessing returned values, and the [`useOptimistic`](https://react.dev/reference/react/useOptimistic) hook for performing optimistic UI updates.

See the talk [What's new in React 19?](https://www.youtube.com/watch?v=AJOGzVygGcY) to learn more.

## State management

We recommend [Jotai](https://jotai.org) for global React state management based on the atomic model's performance and scalability, but Waku is compatible with all React state management libraries such as Zustand and Valtio.

> We're exploring a deeper integration of atomic state management into Waku to achieve the performance and developer experience of signals while preserving React's declarative programming model.

## Environment variables

It's important to distinguish environment variables that must be kept secret from those that can be made public.

#### Private

By default all environment variables are considered private and are accessible only in server components, which can be rendered exclusively in a secure environment. You must still take care not to inadvertently pass the variable as props to any client components.

#### Public

A special `WAKU_PUBLIC_` prefix is required to make an environment variable public and accessible in client components. They will be present as cleartext in the production JavaScript bundle sent to users' browsers.

### Runtime agnostic (recommended)

Environment variables are available on the server via the Waku `getEnv` function and on the client via `import.meta.env`.

```tsx
// server components can access both private and public variables
import { getEnv } from 'waku';

export const ServerComponent = async () => {
  const secretKey = getEnv('SECRET_KEY');

  return <>{/* ...*/}</>;
};
```

```tsx
// client components can only access public variables
'use client';

export const ClientComponent = () => {
  const publicStatement = import.meta.env.WAKU_PUBLIC_HELLO;

  return <>{/* ...*/}</>;
};
```

### Node.js

In Node.js environments, `process.env` may also be used.

## Deployment

### Node.js (default)

After building, `waku start` runs the production server.

If you need a standalone app (for example, for Docker),
the build output lives in `dist`. It is the only folder you need to copy, then run `node dist/serve-node.js`.

### Pure SSG

The build output for SSG lives in `dist/public`. You can copy (or upload to any hosting service) the `dist/public` folder.
With Pure SSG, dynamic features (like dynamic rendering, server actions, API routes) do not work.

### Vercel

Waku projects can be deployed to Vercel with the [Vercel CLI](https://vercel.com/docs/cli) automatically.

```sh
vercel
```

#### Pure SSG with Vercel

For advanced users who want to avoid deploying functions, use the server entry file with the Vercel adapter and specify the `static` option.

`./src/waku.server.tsx`:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/vercel';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  static: true,
});
```

### Netlify

Waku projects can be deployed to Netlify with the [Netlify CLI](https://docs.netlify.com/cli/get-started/).

```sh
NETLIFY=1 npm run build
netlify deploy
```

#### Pure SSG with Netlify

For advanced users who want to avoid deploying functions, use the server entry file with the Netlify adapter and specify the `static` option.

`./src/waku.server.tsx`:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/netlify';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  static: true,
});
```

### Cloudflare Workers

Waku projects can be deployed to Cloudflare Workers with [Wrangler](https://developers.cloudflare.com/workers/wrangler/).

```sh
CLOUDFLARE=1 npm run build
wrangler deploy
```

#### Pure SSG with Cloudflare Workers

`./src/waku.server.tsx`:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/cloudflare';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  static: true,
});
```

### Deno Deploy (experimental)

`./src/waku.server.tsx`:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/deno';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')));
```

```sh
npm run build
deployctl deploy --prod dist/serve-deno.js --exclude node_modules
```

### Bun (experimental)

`./src/waku.server.tsx`:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/bun';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')));
```

```sh
npm run build
```

### AWS Lambda (experimental)

`./src/waku.server.tsx`:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/aws-lambda';

export default adapter(
  fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')),
  { streaming: false }, // optional, default is false
);
```

```sh
npm run build
```

The handler entrypoint is `dist/serve-aws-lambda.js`: see [Hono AWS Lambda Deploy Docs](https://hono.dev/getting-started/aws-lambda#_3-deploy).

### Edge

`waku/adapters/edge` adapter provides a minimal server output without deployment target specific code. For example, you can use it with [Nitro](https://nitro.build/) to handle packaging for various deployment platforms. See [waku-nitro-example](https://github.com/hi-ogawa/waku-nitro-example) for the example.

```ts
// [waku.config.ts]
import { defineConfig } from 'waku/config';

export default defineConfig({
  unstable_adapter: 'waku/adapters/edge',
});
```

## Community

Please join our friendly [GitHub discussions](https://github.com/wakujs/waku/discussions) or [Discord server](https://discord.gg/MrQdmzd) to participate in the Waku community. Hope to see you there!

## Roadmap

Waku is in active development and we're seeking additional contributors. Check out our [roadmap](https://github.com/wakujs/waku/issues/24) for more information.

## Contributing

If you would like to contribute, please see [CONTRIBUTING.md](https://github.com/wakujs/waku/blob/main/CONTRIBUTING.md)!

---

### Create Pages (`docs/create-pages.mdx`)

---
slug: create-pages
title: createPages
description: The low-level routing API.
---

## Routing (low-level API)

The entry point for programmatic routing in Waku projects is `./src/waku.server.tsx`. In that file, call `createPages(...)` and pass the result to an adapter.

`createPage`, `createLayout`, `createRoot`, `createSlice`, and `createApi` register programmatic routes and renderable entries. Pages, layouts, roots, and slices specify a React component and render method. APIs specify HTTP handlers. Waku currently supports two render options: `'static'` for static prerendering (SSG) or `'dynamic'` for server-side rendering (SSR).

For example, you can statically prerender a global header and footer in the root layout at build time, but dynamically render the rest of a home page at request time for personalized user experiences.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { RootLayout } from './templates/root-layout';
import { HomePage } from './templates/home-page';
import { Root } from './components/root';
import { Slice } from './components/slice';

const pages = createPages(
  async ({ createPage, createLayout, createRoot, createSlice, createApi }) => [
    // Create root component
    // not required, but supported for customizing
    // `<html>`, `<head>`, and `<body>` tags
    createRoot({
      render: 'static',
      component: Root,
    }),

    // Create root layout
    createLayout({
      render: 'static',
      path: '/',
      component: RootLayout,
    }),

    // Create home page
    createPage({
      render: 'dynamic',
      path: '/',
      component: HomePage,
    }),

    // Create slice
    createSlice({
      render: 'static',
      component: Slice,
      id: 'slice-1',
    }),

    // Create API endpoint
    createApi({
      render: 'static',
      path: '/health',
      method: 'GET',
      handler: async () => new Response('OK'),
    }),
  ],
);

export default adapter(pages);
```

### Pages

#### Single routes

Pages can be rendered as a single route (e.g., `/about`).

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { AboutPage } from './templates/about-page';
import { BlogIndexPage } from './templates/blog-index-page';

const pages = createPages(async ({ createPage }) => [
  // Create about page
  createPage({
    render: 'static',
    path: '/about',
    component: AboutPage,
  }),

  // Create blog index page
  createPage({
    render: 'static',
    path: '/blog',
    component: BlogIndexPage,
  }),
]);

export default adapter(pages);
```

#### Segment routes

Pages can also render a segment route (e.g., `/blog/[slug]`). The rendered React component automatically receives a prop named by the segment (e.g, `slug`) with the value of the rendered segment (e.g., `'introducing-waku'`). If statically prerendering a segment route at build time, a `staticPaths` array must also be provided.

**Note:** When using `staticPaths`, spaces in slug values are sanitized to `-`.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { BlogArticlePage } from './templates/blog-article-page';
import { ProductCategoryPage } from './templates/product-category-page';

const pages = createPages(async ({ createPage }) => [
  // Create blog article pages
  // `<BlogArticlePage>` receives `slug` prop
  createPage({
    render: 'static',
    path: '/blog/[slug]',
    staticPaths: ['introducing-waku', 'introducing-create-pages'],
    component: BlogArticlePage,
  }),

  // Create product category pages
  // `<ProductCategoryPage>` receives `category` prop
  createPage({
    render: 'dynamic',
    path: '/shop/[category]',
    component: ProductCategoryPage,
  }),
]);

export default adapter(pages);
```

Static paths (or other values) could also be generated programmatically.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { getBlogPaths } from './lib/get-blog-paths';
import { BlogArticlePage } from './templates/blog-article-page';

const pages = createPages(async ({ createPage }) => {
  const blogPaths = await getBlogPaths();

  return [
    createPage({
      render: 'static',
      path: '/blog/[slug]',
      staticPaths: blogPaths,
      component: BlogArticlePage,
    }),
  ];
});

export default adapter(pages);
```

#### Nested segment routes

Routes can contain multiple segments (e.g., `/shop/[category]/[product]`).

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { ProductDetailPage } from './templates/product-detail-page';

const pages = createPages(async ({ createPage }) => [
  // Create product detail pages
  // `<ProductDetailPage>` receives `category` and `product` props
  createPage({
    render: 'dynamic',
    path: '/shop/[category]/[product]',
    component: ProductDetailPage,
  }),
]);

export default adapter(pages);
```

For static prerendering of nested segment routes, the `staticPaths` array is instead composed of ordered arrays.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { ProductDetailPage } from './templates/product-detail-page';

const pages = createPages(async ({ createPage }) => [
  // Create product detail pages
  // `<ProductDetailPage>` receives `category` and `product` props
  createPage({
    render: 'static',
    path: '/shop/[category]/[product]',
    staticPaths: [
      ['some-category', 'some-product'],
      ['some-category', 'another-product'],
    ],
    component: ProductDetailPage,
  }),
]);

export default adapter(pages);
```

#### Catch-all routes

Catch-all or "wildcard" routes (e.g., `/app/[...catchAll]`) have indefinite segments. Wildcard routes receive a prop with segment values as an ordered array.

For example, the `/app/profile/settings` route would receive a `catchAll` prop with the value `['profile', 'settings']`. These values can then be used to determine what to render in the component.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { DashboardPage } from './templates/dashboard-page';

const pages = createPages(async ({ createPage }) => [
  // Create account dashboard
  // `<DashboardPage>` receives `catchAll` prop (string[])
  createPage({
    render: 'dynamic',
    path: '/app/[...catchAll]',
    component: DashboardPage,
  }),
]);

export default adapter(pages);
```

### Advanced page options

#### Disabling SSR

`unstable_disableSSR` disables document SSR for a page. Runtime document requests return Waku's fallback HTML shell, and the build writes that shell for a static page. The RSC payload is still available to the client router.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { AppPage } from './templates/app-page';

const pages = createPages(async ({ createPage }) => [
  createPage({
    render: 'static',
    path: '/app',
    component: AppPage,
    unstable_disableSSR: true,
  }),
]);

export default adapter(pages);
```

This option is unstable and may change.

#### Exact paths

`exactPath: true` treats the path string literally. Slug and wildcard tokens such as `[slug]` or `[...path]` are matched as literal path segments instead of dynamic route patterns.

```tsx
createPage({
  render: 'static',
  path: '/legacy/[literal]',
  exactPath: true,
  component: LegacyPage,
});
```

This is primarily intended for custom router integrations.

### Router paths type safety

Waku provides inference for the router paths when created pages are returned from the callback passed into `createPages`. The following example shows how to setup router paths type safety.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';
import type { PathsForPages } from 'waku/router';

import { RootLayout } from './templates/root-layout';
import { HomePage } from './templates/home-page';

const pages = createPages(async ({ createPage, createLayout }) => [
  createLayout({
    render: 'static',
    path: '/',
    component: RootLayout,
  }),

  createPage({
    render: 'dynamic',
    path: '/',
    component: HomePage,
  }),
]);

declare module 'waku/router' {
  interface RouteConfig {
    paths: PathsForPages<typeof pages>;
  }
}

export default adapter(pages);
```

Once this is done, string navigation targets use the inferred paths. In this case, the one valid use would be `<Link to="/" />`, but as you add more pages to the router, this type will grow to include them.

For parameterized URLs, use `unstable_createRouteHref` from `waku/router`. Its factories infer params and search from a local pattern and optional codec. This augmentation checks raw string hrefs, not factory patterns: without generated route metadata, a factory for an unknown path is accepted by navigation. See [Typed Routes](/guides/typed-routes#what-the-types-check).

#### PageProps

The `PageProps` type derives the page path and slug parameter types.

```tsx
// ./src/templates/about-page.tsx
import type { PageProps } from 'waku/router';

// PageProps<'/about/[foo]'> => { path: `/about/${string}`; foo: string; query: string; }
export const AboutPage = ({ foo }: PageProps<'/about/[foo]'>) => {
  return <>{/* ...*/}</>;
};
```

The route patterns passed to `PageProps`, `useParams_UNSTABLE`, and href factories are not checked against this manual route table. Do not add the following augmentation:

```tsx
// ./src/waku.server.tsx
declare module 'waku/router' {
  interface CreatePagesConfig {
    pages: typeof pages;
  }
}
```

A manual `createPages` table contains the page components, so using `PageProps` in one imported module and `<Link>` in another creates a circular type dependency through that table. The file-system router can safely provide this augmentation because its generated type table contains only route metadata.

### Layouts

Layouts wrap an entire route and its descendents. They must accept a `children` prop of type `ReactNode`. While not required, you will typically want at least a root layout.

#### Root layout

The root layout rendered at `path: '/'` is especially useful. It can be used for setting global styles, global metadata, global providers, global data, and global components, such as a header and footer.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { RootLayout } from './templates/root-layout';

const pages = createPages(async ({ createLayout }) => [
  // Add a global header and footer
  createLayout({
    render: 'static',
    path: '/',
    component: RootLayout,
  }),
]);

export default adapter(pages);
```

```tsx
// ./src/templates/root-layout.tsx
import '../styles.css';

import { Providers } from '../components/providers';
import { Header } from '../components/header';
import { Footer } from '../components/footer';

export const RootLayout = async ({ children }) => {
  return (
    <Providers>
      <link rel="icon" type="image/png" href="/images/favicon.png" />
      <meta property="og:image" content="/images/opengraph.png" />
      <Header />
      <main>{children}</main>
      <Footer />
    </Providers>
  );
};
```

```tsx
// ./src/components/providers.tsx
'use client';

import { createStore, Provider } from 'jotai';

const store = createStore();

export const Providers = ({ children }) => {
  return <Provider store={store}>{children}</Provider>;
};
```

#### Other layouts

Layouts are also helpful further down the tree. For example, you could add a layout at `path: '/blog'` to add a sidebar to both the blog index and all blog article pages.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { BlogLayout } from './templates/blog-layout';

const pages = createPages(async ({ createLayout }) => [
  createLayout({
    render: 'static',
    path: '/blog',
    component: BlogLayout,
  }),
]);

export default adapter(pages);
```

```tsx
// ./src/templates/blog-layout.tsx
import { Sidebar } from '../components/sidebar';

export const BlogLayout = async ({ children }) => {
  return (
    <div className="flex">
      <div>{children}</div>
      <Sidebar />
    </div>
  );
};
```

### Root component

Root component is a special component that is rendered at the root of html document. It is useful for customizing `<html>`, `<head>`, and `<body>` tags.

```tsx
// ./src/components/root.tsx

export const Root = ({ children }) => {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>{children}</body>
    </html>
  );
};
```

Add this with `createRoot` function inside `createPages`.

#### Note About `<head>`

If you only need to customize `<head>`, you can rely on React's [Support for Document Metadata](https://react.dev/blog/2024/04/25/react-19#support-for-metadata-tags) and do not need to use `createRoot`.

### Client entry point

The file `./src/waku.server.tsx` is the entry point for the server.
For the client, the entry point file is `./src/waku.client.tsx`.

The default client entry file content is the following.

```tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import { Router } from 'waku/router/client';

const rootElement = (
  <StrictMode>
    <Router />
  </StrictMode>
);

if (globalThis.__WAKU_HYDRATE__) {
  hydrateRoot(document, rootElement, defaultRootOptions);
} else {
  createRoot(document, defaultRootOptions).render(rootElement);
}
```

You can omit `./src/waku.client.tsx` unless you need to modify it.

To configure a request pipeline, pass `unstable_rscEnhancers` to `Router` here.
Router snapshots the array for its lifetime and wraps it with its built-in action
routing. See [Request enhancers](/guides/minimal-api#request-enhancers) for
composition and integration migration examples.

### APIs

APIs are created with `createApi` inside `createPages`. Unlike file-system API routes, programmatic API paths are used as written.

Static APIs support `GET` handlers and can be emitted at build time:

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

const pages = createPages(async ({ createApi }) => [
  createApi({
    render: 'static',
    path: '/rss.xml',
    method: 'GET',
    handler: async () => {
      return new Response('<rss />', {
        headers: { 'Content-Type': 'application/rss+xml' },
      });
    },
  }),
]);

export default adapter(pages);
```

Dynamic APIs use a `handlers` object keyed by HTTP method (`GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`, `QUERY`) or `all`. Named methods take precedence over `all`.

```tsx
const pages = createPages(async ({ createApi }) => [
  createApi({
    render: 'dynamic',
    path: '/api/users/[id]',
    handlers: {
      GET: async (_req, { params }) => {
        return Response.json({ id: params.id });
      },
      QUERY: async (req) => {
        const body = await req.json();
        return Response.json({ ok: true, body });
      },
      all: async () => new Response('Method not allowed', { status: 405 }),
    },
  }),
]);
```

Static APIs with dynamic segments can use `staticPaths`, similar to static pages:

```tsx
createApi({
  render: 'static',
  path: '/feeds/[category]',
  method: 'GET',
  staticPaths: ['news', 'docs'],
  handler: async (_req, { params }) => {
    return new Response(`Feed for ${params.category}`);
  },
});
```

Wildcard API params are passed as `string[]`, and named segment params are passed as `string`.

### Slices

Slices are reusable components that can be rendered `static` or `dynamic` in their own right. This allows for patterns like adding `static` components wherever they'll compose nicely inside your `dynamic` or `static` layouts & pages.

#### Creating slices

Slices are created with `createSlice` inside `createPages`. Each slice needs a unique `id`, a `component`, and a `render` mode.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { SliceOne } from './components/slice-one';
import { SliceTwo } from './components/slice-two';

const pages = createPages(async ({ createSlice }) => [
  // Create static slice
  createSlice({
    render: 'static',
    component: SliceOne,
    id: 'one',
  }),

  // Create dynamic slice
  createSlice({
    render: 'dynamic',
    component: SliceTwo,
    id: 'two',
  }),
]);

export default adapter(pages);
```

```tsx
// ./src/components/slice-one.tsx

export const SliceOne = () => {
  return <p>Static slice content</p>;
};
```

#### Using slices

Slices are used in pages and layouts by importing the `Slice` component from Waku and specifying the slice ID. The `slices` array in the page's config must include all slice IDs used on that page.

```tsx
// ./src/templates/home-page.tsx
import { Slice } from 'waku';

export const HomePage = () => {
  return (
    <div>
      <Slice id="one" />
      <Slice id="two" />
    </div>
  );
};
```

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { HomePage } from './templates/home-page';
import { SliceOne } from './components/slice-one';
import { SliceTwo } from './components/slice-two';

const pages = createPages(async ({ createPage, createSlice }) => [
  createPage({
    render: 'static',
    path: '/',
    component: HomePage,
    slices: ['one', 'two'],
  }),

  createSlice({
    render: 'static',
    component: SliceOne,
    id: 'one',
  }),

  createSlice({
    render: 'dynamic',
    component: SliceTwo,
    id: 'two',
  }),
]);

export default adapter(pages);
```

#### Lazy slices

Lazy slices allow components to be requested independently from the page they are used on, similar to Astro's server islands feature. This is useful for components that will be dynamically rendered on otherwise static pages.

Lazy slices are marked with the `lazy` prop and can include a `fallback` component to display while loading.

```tsx
// ./src/templates/home-page.tsx
import { Slice } from 'waku';

export const HomePage = () => {
  return (
    <div>
      <Slice id="one" />
      <Slice id="two" lazy fallback={<p>Loading...</p>} />
    </div>
  );
};
```

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { HomePage } from './templates/home-page';
import { SliceOne } from './components/slice-one';
import { SliceTwo } from './components/slice-two';

const pages = createPages(async ({ createPage, createSlice }) => [
  createPage({
    render: 'static',
    path: '/',
    component: HomePage,
    slices: ['one'], // Note: 'two' is lazy, so it is not included
  }),

  createSlice({
    render: 'static',
    component: SliceOne,
    id: 'one',
  }),

  createSlice({
    render: 'dynamic',
    component: SliceTwo,
    id: 'two',
  }),
]);

export default adapter(pages);
```

This allows you to have a `dynamic` slice component while keeping the rest of the page static.

#### Slug slices

Slug slices support dynamic segments in their ID, similar to dynamic route paths. This lets you create a single slice definition that handles many concrete IDs by extracting parameters from the path and passing them as props.

Slug slices use `render: 'dynamic'` and are always loaded lazily, since concrete IDs are not known at build time.

```tsx
// ./src/components/tooltip-slice.tsx

export const TooltipSlice = ({ id }: { id: string }) => {
  // fetch tooltip data based on id...
  return <p>Tooltip content for {id}</p>;
};
```

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { HomePage } from './templates/home-page';
import { TooltipSlice } from './components/tooltip-slice';

const pages = createPages(async ({ createPage, createSlice }) => [
  createPage({
    render: 'static',
    path: '/',
    component: HomePage,
  }),

  createSlice({
    render: 'dynamic',
    component: TooltipSlice,
    id: 'tooltip/[id]',
  }),
]);

export default adapter(pages);
```

```tsx
// ./src/templates/home-page.tsx
import { Slice } from 'waku';

export const HomePage = () => {
  return (
    <div>
      <Slice id="tooltip/123" lazy fallback={<p>Loading...</p>} />
      <Slice id="tooltip/456" lazy fallback={<p>Loading...</p>} />
    </div>
  );
};
```

When `<Slice id="tooltip/123" lazy />` is requested, Waku matches it against the `tooltip/[id]` pattern, extracts `{ id: "123" }`, and passes those params as props to the component.

With the file-system router, slug slices work the same way as slug pages: create a file at `src/pages/_slices/tooltip/[id].tsx` and the ID pattern is derived from the file path.

Multiple dynamic segments are also supported (e.g., `items/[category]/[id]`), and each extracted parameter is passed as a separate prop.

### Handler interceptors

A handler interceptor wraps each render, in both the request and build phases, so it runs inside the request scope where `getRequest` is available. It also wraps API route handlers. Use it to install an `AsyncLocalStorage` for the render or to set the inline-script nonce.

```ts
type HandlerInterceptor = <T>(next: () => Promise<T>) => Promise<T>;
```

Register interceptors with `createInterceptor` inside `createPages`. They compose as onion wrappers around `next`: the first registered interceptor is the outermost, so it runs first on the way in and last on the way out.

```tsx
// ./src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';

import { HomePage } from './templates/home-page';

const pages = createPages(async ({ createPage, createInterceptor }) => {
  createInterceptor(async (next) => {
    console.log('render start');
    const result = await next();
    console.log('render end');
    return result;
  });
  return [
    createPage({
      render: 'dynamic',
      path: '/',
      component: HomePage,
    }),
  ];
});

export default adapter(pages);
```

The `HandlerInterceptor` type is available from `waku/router/server`. The file-system router has an equivalent `src/pages/_interceptors/` convention, where modules are registered in sorted filename order (so the first becomes the outermost wrapper).

See [Request Context](/guides/request-context) for seeding your own store and [Configure CSP](/guides/csp) for the nonce recipe.

### Build options

`createPages` accepts an optional second argument with `unstable_skipBuild`. This callback receives a route path and can skip static build output for matching static routes or APIs.

```tsx
const pages = createPages(
  async ({ createPage }) => [
    createPage({
      render: 'static',
      path: '/preview',
      component: PreviewPage,
    }),
  ],
  {
    unstable_skipBuild: (routePath) => routePath === '/preview',
  },
);
```

This option only affects build output. It does not remove the route from runtime routing.

---

### Community Examples (`docs/community/examples.mdx`)

# Community Work

- [Waku with yarn pnp](https://github.com/bysxx/waku-with-yarn-pnp) @bysxx
- [Waku with TailwindCSS v4 & Shadcn/ui & SSR pre-hydrated theme logic](https://github.com/JesseKoldewijn/waku-tw4-shadcn-starter) @JesseKoldewijn

---

### Community Waku En Espanol (`docs/community/waku-en-espanol.mdx`)

---
slug: waku-en-espanol
title: Learn Waku in Spanish
description: Comprehensive guide to learn Waku in Spanish, with real code examples and best practices
author: Ariel GonzAgüer
date: 2026-01-25
tags: [guide, spanish, tutorial, learning]
---

# Learn Waku in Spanish

A comprehensive Spanish-language guide for learning Waku, the minimal React framework. This resource is based on the official documentation and includes practical examples, best practices, and real-world code samples.

## Resource

Visit the guide at: [aprenderwaku.netlify.app](https://aprenderwaku.netlify.app/)

## What's Included

This Spanish learning resource covers:

- **Complete Waku fundamentals** - Introduction to the minimal React framework
- **React Server Components (RSC)** - Understanding server and client components
- **File-based routing** - Pages, layouts, and navigation patterns
- **Data fetching** - Server-side and client-side data patterns
- **Server actions** - Mutations
- **Real code examples** - Practical implementations you can use
- **Best practices** - Production-ready patterns and recommendations
- **Deployment guides** - Deploying to various platforms

## Who Is This For?

This guide is perfect for:

- Spanish-speaking developers learning Waku
- React developers transitioning to server components
- Anyone looking for practical Waku examples in Spanish
- Teams working with Waku in Spanish-speaking regions

## Getting Started

The guide follows the official documentation, starting from basic concepts and progressing to advanced topics. Each section includes:

- Clear explanations in Spanish
- Code examples with comments
- Common patterns and use cases
- Tips and best practices

## Why This Resource?

- **Native Spanish content** - Not just a translation, but content written for Spanish speakers
- **Additional context** - Extra explanations and examples
- **Community perspective** - Real-world experiences and solutions
- **Accessibility** - Making Waku accessible to the Spanish-speaking developer community

## Links

- [Visit the Guide](https://aprenderwaku.netlify.app/)
- [Waku Official Docs](https://waku.gg/)
- [Waku GitHub](https://github.com/wakujs/waku)

---

_This is a community-created resource and is not officially maintained by the Waku team, but it follows the official documentation and best practices._

---

### Guides Adapter Authoring (`docs/guides/adapter-authoring.mdx`)

---
slug: adapter-authoring
title: Adapter Authoring
description: Build a custom Waku adapter around server entries, request processing, and build processing.
category: Low-level APIs
order: 30
---

## When to Write an Adapter

Most apps should use a built-in adapter such as `waku/adapters/default`, `waku/adapters/node`, `waku/adapters/cloudflare`, or a deployment-specific adapter.

Write a custom adapter only when you need to integrate Waku with a runtime or deployment target that the built-in adapters do not cover. Typical adapter responsibilities are:

- translating platform requests into Waku request processing
- serving static assets in development or build preview environments
- wiring middleware
- exposing the platform-specific default export
- injecting platform environment bindings into Waku server code
- adding deployment files after `waku build`

The APIs in this guide currently use `unstable_` names and may change.

## Mental Model

A Waku server entry has two jobs:

- `fetch` handles runtime requests.
- `build` emits static files and build metadata during `waku build`.

Application routers such as `fsRouter`, `createPages`, `unstable_defineRouter`, and Minimal API handlers implement `handleRequest` and `handleBuild`. An adapter turns those handlers into the server entry that Waku's Vite plugin and the deployment runtime can execute.

`unstable_createServerEntryAdapter` is the usual adapter-authoring helper:

```ts
import { unstable_createServerEntryAdapter as createServerEntryAdapter } from 'waku/adapter-builders';
```

It receives the app's handlers and gives your adapter two wrapped functions:

- `processRequest(req)` parses the request, calls `handleRequest`, renders RSC/HTML when needed, and returns a `Response | null`.
- `processBuild(utils)` calls `handleBuild` with Waku build helpers such as `renderRsc`, `renderHtml`, `generateFile`, and `saveBuildMetadata`.

Your adapter decides when and where to call them.

## Minimal Fetch Adapter

This is the smallest useful shape. It handles runtime requests and lets Waku's build process run normally.

```ts
// my-waku-adapter.ts
import { unstable_createServerEntryAdapter as createServerEntryAdapter } from 'waku/adapter-builders';

export default createServerEntryAdapter(({ processRequest, processBuild }) => {
  return {
    fetch: async (req: Request) => {
      const res = await processRequest(req);
      return res || new Response('Not Found', { status: 404 });
    },
    build: processBuild,
  };
});
```

Then use it from `src/waku.server.tsx`:

```tsx
import { fsRouter } from 'waku';
import adapter from './my-waku-adapter';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')));
```

This direct shape is useful for learning the contract, but most production adapters need middleware and platform-specific build output.

## Hono-Based Adapter

The built-in Waku adapters use Hono internally. That gives them a common middleware shape and lets managed-mode `src/middleware` modules run consistently.

```ts
import type { MiddlewareHandler } from 'hono';
import { Hono } from 'hono/tiny';
import { unstable_createServerEntryAdapter as createServerEntryAdapter } from 'waku/adapter-builders';
import { unstable_honoMiddleware as honoMiddleware } from 'waku/internals';

const { rscMiddleware, middlewareRunner } = honoMiddleware;

export default createServerEntryAdapter(
  (
    { processRequest, processBuild, notFoundHtml },
    options?: {
      middlewareFns?: ((opts: { app: Hono }) => MiddlewareHandler)[];
      middlewareModules?: Record<
        string,
        () => Promise<{ default: (opts: { app: Hono }) => MiddlewareHandler }>
      >;
    },
  ) => {
    const { middlewareFns = [], middlewareModules = {} } = options || {};
    const app = new Hono();

    app.notFound((c) => {
      if (notFoundHtml) {
        return c.html(notFoundHtml, 404);
      }
      return c.text('404 Not Found', 404);
    });

    for (const middlewareFn of middlewareFns) {
      app.use(middlewareFn({ app }));
    }

    app.use(middlewareRunner(middlewareModules, { app }));
    app.use(rscMiddleware({ processRequest }));

    return {
      fetch: app.fetch,
      build: processBuild,
    };
  },
);
```

`middlewareRunner(...)` runs middleware modules discovered from `src/middleware`, passing each the `{ app }` Hono instance. `rscMiddleware(...)` delegates the final request handling to Waku. Request context such as `getRequest` is established by the app's handlers around each render, so the adapter does not install it.

`waku/internals` is intentionally internal. It is useful for adapter authors, but it is not a stable application API.

## Static Assets

Built-in adapters serve generated static assets from Waku's public output directory when the runtime needs to handle them directly. The shared constant lives in `waku/internals`:

```ts
import { unstable_constants as constants } from 'waku/internals';

const { DIST_PUBLIC } = constants;
```

Use `config.distDir` with `DIST_PUBLIC` to locate the generated public files. Many adapters only add static-file middleware while `isBuild` is true, because the build-time preview server needs access to files that were just emitted.

## Adapter Builder Inputs

The callback passed to `createServerEntryAdapter` receives:

- `handlers`: the original `handleRequest` and `handleBuild` object.
- `processRequest`: a Waku request processor that returns `Response | null`.
- `processBuild`: a Waku build processor that emits generated files.
- `unstable_setAllEnv`: updates Waku's server-side environment map.
- `config`: resolved Waku config without the Vite config.
- `isBuild`: `true` while Vite is running the build environment.
- `notFoundHtml`: prerendered not-found HTML, when available.

The returned server entry can include:

- `fetch`: the runtime request handler.
- `build`: the build-time file emitter.
- `buildOptions`: data passed to build enhancers.
- `buildEnhancers`: module IDs for post-build wrappers.
- `defaultExport`: a platform-specific default export.
- other platform-specific fields consumed by your own build enhancer.

## Platform Environment Bindings

Some platforms pass environment bindings as extra arguments to `fetch` instead of using `process.env`. Use `unstable_setAllEnv` before delegating to Waku so `getEnv()` and related server code see the platform values.

```ts
export default createServerEntryAdapter(
  ({ processRequest, processBuild, unstable_setAllEnv }) => {
    return {
      fetch: async (req: Request, env: Record<string, string>) => {
        unstable_setAllEnv(env);
        const res = await processRequest(req);
        return res || new Response('Not Found', { status: 404 });
      },
      build: processBuild,
    };
  },
);
```

The Cloudflare adapter uses this pattern because Workers pass bindings as the second `fetch` argument.

## Platform Default Exports

Some runtimes need the module's default export to have a platform-specific shape. Return `defaultExport` when the deployment target should import something other than the internal Waku server entry object.

```ts
export default createServerEntryAdapter(
  (
    { processRequest, processBuild, unstable_setAllEnv },
    options?: { handlers?: Record<string, unknown> },
  ) => {
    const fetch = async (req: Request, env: Record<string, string>) => {
      unstable_setAllEnv(env);
      const res = await processRequest(req);
      return res || new Response('Not Found', { status: 404 });
    };

    return {
      fetch,
      build: processBuild,
      defaultExport: {
        ...options?.handlers,
        fetch,
      },
    };
  },
);
```

This is useful for platforms that support extra handlers next to `fetch`, such as queue or scheduled handlers.

## Build Enhancers

`processBuild` emits Waku's generated files, but deployment targets often need extra output. For example, Node needs a small `serve-node.js` entry, and serverless platforms may need manifest or config files.

Use `buildEnhancers` when an adapter needs to wrap the build step:

```ts
export default createServerEntryAdapter(
  ({ processRequest, processBuild, config }) => {
    return {
      fetch: async (req) => {
        const res = await processRequest(req);
        return res || new Response('Not Found', { status: 404 });
      },
      build: processBuild,
      buildOptions: {
        distDir: config.distDir,
      },
      buildEnhancers: ['my-waku-adapter/build-enhancer'],
    };
  },
);
```

Build enhancer module IDs must be resolvable from the project root. Use a package export for reusable adapters, or a project-root-relative path starting with `/` for app-local experiments.

A build enhancer receives the build function and returns a wrapped build function:

```ts
// build-enhancer.ts
import { writeFileSync } from 'node:fs';
import path from 'node:path';

type BuildOptions = {
  distDir: string;
};

export default async function buildEnhancer(
  build: (utils: unknown, options: BuildOptions) => Promise<void>,
) {
  return async (utils: unknown, options: BuildOptions) => {
    await build(utils, options);
    writeFileSync(
      path.join(options.distDir, 'platform-entry.js'),
      "export { default } from './server/index.js';\n",
    );
  };
}
```

Keep build enhancers focused on deployment output. Route rendering and static generation should stay in `handleBuild` or `processBuild`.

## Preview Server

Some platform build tools need to execute Waku through a local runtime during `waku build`. Use `unstable_startPreviewServer` for that pattern:

```ts
import { unstable_startPreviewServer as startPreviewServer } from 'waku/adapter-builders';
```

It returns:

```ts
type NodeMiddleware = (
  req: import('node:http').IncomingMessage,
  res: import('node:http').ServerResponse,
  next: (err?: unknown) => void,
) => void;

type PreviewServer = {
  baseUrl: string;
  middlewares: {
    use: (fn: NodeMiddleware) => void;
  };
  close: () => Promise<void>;
};
```

The Cloudflare adapter uses this when it needs the Cloudflare Vite plugin/workerd runtime to participate in static file generation.

Only use a preview server when direct `processBuild(...)` is not enough. It is more complex because your adapter must start the server, request the build endpoint, consume the result, and close the server.

## Direct Server Entries

Use `Unstable_ServerEntry` from `waku/adapter-builders` when you already have a complete server entry and do not need an adapter builder.

```ts
import type { Unstable_ServerEntry as ServerEntry } from 'waku/adapter-builders';

const entry: ServerEntry = {
  fetch: async (req) => {
    return new Response(`Request: ${new URL(req.url).pathname}`);
  },
  build: async () => {},
};

export default entry;
```

This bypasses router and Minimal API handlers. Use it only when you are intentionally owning the entire server entry contract.

## Practical Boundaries

Adapter authors should keep these boundaries clear:

- App routing belongs in `fsRouter`, `createPages`, `unstable_defineRouter`, or Minimal API handlers.
- Platform request shape, environment bindings, middleware setup, static asset serving, and deployment files belong in the adapter.
- Build output that depends on app routes belongs in `handleBuild`/`processBuild`.
- Build output that depends on the deployment platform belongs in a build enhancer.

Avoid copying large parts of Waku's built-in adapters unless you are intentionally matching that platform. Start from the smallest adapter shape that calls `processRequest` and `processBuild`, then add only the platform behavior your target needs.

---

### Guides Aws Lambda (`docs/guides/aws-lambda.mdx`)

---
slug: aws-lambda
title: Deploy Waku to AWS Lambda
description: Deploy a Waku application with the experimental AWS Lambda adapter.
category: Deployment
order: 20
tags: [Experimental]
---

## Configure the AWS Lambda Adapter

Use `waku/adapters/aws-lambda` in `src/waku.server.tsx`:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/aws-lambda';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  streaming: false,
});
```

The AWS Lambda adapter adds a generated handler module during `waku build`:

- generated file: `dist/serve-aws-lambda.js`
- exported handler: `handler`

If the deployment package root is your project root, use `dist/serve-aws-lambda.handler` as the Lambda handler. If the deployment package root is `dist`, use `serve-aws-lambda.handler`.

To enable Lambda response streaming, set `streaming: true` in the adapter options:

```ts
export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  streaming: true,
});
```

Files that your server code reads directly, such as `fs.readFile('./private/data.json')`, must be included in your deployment package.

## [Serverless Framework](https://www.serverless.com)

### Installation

Add this Serverless Framework plugin to your project:

```sh
pnpm add -D serverless-scriptable-plugin
```

### Setup

Create a `serverless.yml` with this content and change `service:` to your project name.

```yml
service: waku-aws-lambda
frameworkVersion: '3'
configValidationMode: error

provider:
  name: aws
  runtime: nodejs20.x
  architecture: arm64
  deploymentMethod: direct
  region: us-east-1
  stage: ${opt:stage, 'dev'}
  versionFunctions: false

plugins:
  - serverless-scriptable-plugin

package:
  patterns:
    - '!**/**'
    - 'private/**' # include all static files and directories from ./private directory
    - 'dist/**'

functions:
  ssr:
    handler: dist/serve-aws-lambda.handler
    events:
      - httpApi: '*'

custom:
  scriptable:
    # add custom hooks
    hooks:
      before:package:createDeploymentArtifacts:
        - pnpm exec waku build
```

This configuration will include all files from the `./private` directory in the final deployment.

### Deploy

```sh
pnpx serverless deploy
```

Output:

```
Service deployed to stack waku-aws-lambda-dev (95s)

endpoint: ANY - https://<your-application>.execute-api.us-east-1.amazonaws.com
functions:
  ssr: waku-aws-lambda-dev-ssr (325 kB)
```

You can access the frontend through the url provided as the `endpoint:`

For more configuration options and how to use a custom domain visit the Serverless framework [documentation](https://www.serverless.com/framework/docs).

## AWS CDK

### Setup

Initialize your project with the `cdk` CLI:

```sh
mkdir cdk && cd "$_"
pnpx cdk init app -l typescript --generate-only
cd ..
cp cdk/cdk.json .
```

Change the entry in `cdk.json`:

```diff
-  "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
+  "app": "infra/main.js",
```

Remove these lines:

```diff
-  "watch": {
-    "include": [
-      "**"
-    ],
-    "exclude": [
-      "README.md",
-      "cdk*.json",
-      "**/*.d.ts",
-      "**/*.js",
-      "tsconfig.json",
-      "package*.json",
-      "yarn.lock",
-      "node_modules",
-      "test"
-    ]
-  },
```

Remove the `cdk` directory:

```sh
rm -fr cdk
```

Add packages:

```sh
pnpm add -D aws-cdk
pnpm add aws-cdk-lib constructs
```

Create the infrastructure directory:

```sh
mkdir -p  infra/lib
```

Bootstrap the AWS CDK:

```sh
pnpm cdk bootstrap
```

Create `infra/main.js`:

```js
#!/usr/bin/env node
//import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { WakuStack } from './lib/waku-stack.js';

const app = new cdk.App();
new WakuStack(app, 'WakuStack', {
  /* If you don't specify 'env', this stack will be environment-agnostic.
   * Account/Region-dependent features and context lookups will not work,
   * but a single synthesized template can be deployed anywhere. */
  /* Uncomment the next line to specialize this stack for the AWS Account
   * and Region that are implied by the current CLI configuration. */
  // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
  /* Uncomment the next line if you know exactly what Account and Region you
   * want to deploy the stack to. */
  // env: { account: '123456789012', region: 'us-east-1' },
  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});
```

Create `infra/lib/waku-stack.js`:

```
/* Detailed source-code truncated for AI context efficiency. */
```

This configuration will include all files from the `./private` directory in the final deployment.

Deploy to AWS:

```sh
pnpm cdk deploy WakuStack
```

For more configuration options and how to use a custom domain, visit the AWS CDK [documentation](https://docs.aws.amazon.com/cdk/v2/guide/home.html).

## sst.dev V3

This example uses Lambda response streaming, so set `streaming: true` in the adapter options.

### Setup

1. Run `pnpm sst@latest init`

### Configuration

Use this as an example to run Waku as a Lambda function:

sst.config.ts

```ts
/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: 'waku03demo',
      removal: input?.stage === 'production' ? 'retain' : 'remove',
      home: 'aws',
    };
  },
  async run() {
    const WakuDemoApp = new sst.aws.Function('WakuDemoApp', {
      url: true,
      streaming: true,
      //timeout: "15 minutes",
      handler: 'serve-aws-lambda.handler',
      bundle: 'dist', // skip SST's esbuild and deploy the prebuilt dist/ as-is
      copyFiles: [
        {
          from: 'private',
        },
      ],
      environment: {
        NODE_ENV: 'production',
      },
    });
    return {
      api: WakuDemoApp.url,
    };
  },
});
```

### Deploy

```sh
pnpx sst deploy
```

[sst.dev documentation](https://sst.dev/docs)

---

### Guides Cloudflare (`docs/guides/cloudflare.mdx`)

---
slug: cloudflare
title: Run Waku on Cloudflare
description: How to integrate Waku with Cloudflare Workers and interact with Cloudflare bindings and other resources.
category: Deployment
order: 10
---

## Quick Start

Waku comes "out of the box" with a custom adapter for [Cloudflare Workers](https://developers.cloudflare.com/workers/).

Create your project with `npm create waku@latest -- --example https://github.com/wakujs/waku-examples/tree/main/fs-router/cloudflare` to use the starter example for Cloudflare Workers.

Then use these commands:

- `npm run dev`: start the development server
- `npm run build`: build for Cloudflare Workers
- `npx wrangler dev`: test your build locally
- `npx wrangler deploy`: deploy it to Cloudflare Workers.

## Building Waku For Cloudflare Workers

Waku integrates with Cloudflare Workers via [`@cloudflare/vite-plugin`](https://developers.cloudflare.com/workers/vite-plugin/). This plugin runs your code in the actual Cloudflare workerd runtime during both development and build, so Cloudflare bindings (D1, KV, etc.) work without additional shims.

> `@cloudflare/vite-plugin` is optional. If you don't need Cloudflare-specific features like D1, KV, or other bindings, you can deploy to Cloudflare Workers without it; just use `waku/adapters/cloudflare` in your `src/waku.server.tsx`.

Install `@cloudflare/vite-plugin` and [`wrangler`](https://www.npmjs.com/package/wrangler) as development dependencies:

```sh
npm install --save-dev @cloudflare/vite-plugin wrangler
```

Create a `src/waku.server.tsx` file that uses the Cloudflare adapter:

```ts
// ./src/waku.server.tsx
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/cloudflare';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')));
```

Add `@cloudflare/vite-plugin` to your `waku.config.ts`:

```ts
// ./waku.config.ts
import { cloudflare } from '@cloudflare/vite-plugin';
import { defineConfig } from 'waku/config';

export default defineConfig({
  vite: {
    environments: {
      rsc: {
        optimizeDeps: {
          include: ['hono/tiny'],
        },
        build: {
          rolldownOptions: {
            platform: 'neutral',
          },
        },
      },
      ssr: {
        optimizeDeps: {
          include: ['waku > rsc-html-stream/server'],
        },
        build: {
          rolldownOptions: {
            platform: 'neutral',
          },
        },
      },
    },
    plugins: [
      cloudflare({
        viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] },
        inspectorPort: false,
      }),
    ],
  },
});
```

Configure your `wrangler.jsonc` to point to the server entry:

```jsonc
// ./wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "waku-project",
  "main": "./src/waku.server",
  "compatibility_flags": ["nodejs_als"],
  "compatibility_date": "2025-11-17",
  "assets": {
    "binding": "ASSETS",
    "directory": "./dist/public",
    "html_handling": "drop-trailing-slash",
  },
  "rules": [
    {
      "type": "ESModule",
      "globs": ["**/*.js", "**/*.mjs"],
    },
  ],
  "no_bundle": true,
}
```

See [Cloudflare's documentation](https://developers.cloudflare.com/workers/wrangler/configuration/) for more information on configuring `wrangler.jsonc`.

After setting up, run `waku build` to build and `npx wrangler dev` to test locally, or `npx wrangler deploy` to deploy.

## Notes on Cloudflare's workerd Runtime

Cloudflare does not run NodeJS on their servers. Instead, they use their custom JavaScript runtime called [workerd](https://github.com/cloudflare/workerd).

By default, workerd does not support built-in NodeJS APIs, but support can be added by editing the `compatibility_flags` in your `wrangler.jsonc` file. Cloudflare does not support all APIs, but the list is growing. For more information, see [Cloudflare's documentation on NodeJS APIs](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) and [compatibility flags](https://developers.cloudflare.com/workers/configuration/compatibility-dates/#setting-compatibility-flags).

Waku attempts to stay minimal and compatible with [WinterCG servers](https://wintercg.org/). The Node AsyncLocalStorage API is currently used by Waku, so only the `nodejs_als` compatibility flag is added. If you experience errors in server-side dependencies due to missing NodeJS APIs, try changing this flag to `nodejs_compat` and rebuilding your project.

Note that the latest `nodejs_compat` mocks the Node `fs` module. Cloudflare does not allow file system access from server-side functions. See [Cloudflare's security model](https://developers.cloudflare.com/workers/reference/security-model/).

## Setting Up TypeScript

You can run `npx wrangler types` to generate a `worker-configuration.d.ts` file based on the settings in your `wrangler.jsonc`. This defines a global `Env` interface with your bindings. In the [Cloudflare example in the Waku examples repository](https://github.com/wakujs/waku-examples/tree/main/fs-router/cloudflare), a package.json script is included to run this command and update the types: `pnpm run cf-typegen`. To ensure that your types are always up-to-date, make sure to run it after any changes to your `wrangler.jsonc` config file.

## Accessing Cloudflare Bindings, Execution Context, and Request/Response Objects

Import from `cloudflare:workers` to access Cloudflare Workers bindings such as environment variables, D1 databases and KV namespaces. See [Cloudflare's documentation on Workers bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/) for more information.

For general Waku request context usage, see [Request Context](/guides/request-context).

> Note: Durable Objects cannot currently be defined in a Waku app. You can create Durable Objects in another Cloudflare Worker and connect to it via [service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) from your Waku app.

```ts
import { env, waitUntil } from 'cloudflare:workers'; // eslint-disable-line import/no-unresolved

const getData = async (userId: string) => {
  waitUntil(
    new Promise<void>((resolve) => {
      console.log('Waiting for 5 seconds');
      setTimeout(() => {
        console.log('OK, done waiting');
        resolve();
      }, 5000);
    }),
  );
  const { results } = await env.DB.prepare('SELECT * FROM user WHERE id = ?')
    .bind(userId)
    .all();
  return results;
};
```

### Dev Mode

The `@cloudflare/vite-plugin` configured in `waku.config.ts` runs your code in the Cloudflare workerd runtime during local development, so Cloudflare bindings like KV, D1, etc. are available in your server components and functions without additional setup. See [Cloudflare's Vite plugin documentation](https://developers.cloudflare.com/workers/vite-plugin/) for more details.

## Additional Handlers

Waku supports defining additional [handlers](https://developers.cloudflare.com/workers/runtime-apis/handlers/) for your worker.
Pass them to the adapter options in your waku.server.tsx file:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/cloudflare';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  handlers: {
    // Define additional Cloudflare Workers handlers here
    // https://developers.cloudflare.com/workers/runtime-apis/handlers/
    // async queue(
    //   batch: MessageBatch,
    //   _env: Env,
    //   _ctx: ExecutionContext,
    // ): Promise<void> {
    //   for (const message of batch.messages) {
    //     console.log('Received', message);
    //   }
    // },
  } satisfies ExportedHandler<Env>,
});
```

## Static vs. Dynamic Routing and Fetching Assets

When Waku builds for Cloudflare, it outputs the worker function assets into the dist/server folder and outputs static assets into the dist/public folder.

A configuration in the `wrangler.jsonc` file tells Cloudflare to route requests to that assets folder first and then fall back to handle the request with the worker.

```json
{
  "assets": {
    "binding": "ASSETS",
    "directory": "./dist/public",
    "html_handling": "drop-trailing-slash"
  }
}
```

You can also access static assets from your server-side worker code in a server component, server function or middleware. For example, if you want to fetch HTML from static assets to render:

```ts
import { env } from 'cloudflare:workers'; // eslint-disable-line import/no-unresolved

const get404Html = async () => {
  return env.ASSETS
    ? await (await env.ASSETS.fetch('https://example.com/404.html')).text()
    : '';
};
```

Note that `ASSETS.fetch` requires a fully qualified URL, but the origin is ignored. You can use `https://example.com` or any valid origin. It is just an internal request.

It is also possible to always run the worker before serving static assets. See the documentation for [`run_worker_first`](https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first).

### Custom Headers

You can set response headers from custom middleware on the Hono response (`c.res`). Since Cloudflare supports response body streaming, server components might not be able to set headers if they were already sent in the response stream.

For static assets, add a [`_headers`](https://developers.cloudflare.com/workers/static-assets/headers/#custom-headers) file to the root of your `public` folder to set custom headers for static assets.

> An example `_headers` file is included in Waku's starter template for Cloudflare Workers to prevent indexing of RSC files by search engines:

`./public/_headers`:

```txt
/RSC/*
  X-Robots-Tag: noindex
```

### Static Apps Without A Worker

It is possible to deploy a static Waku app to Cloudflare Workers. It will deploy the static assets to Cloudflare's edge network without ever invoking any worker functions.

To do this, create a `wrangler.jsonc` file with the following content:

```json
{
  "name": "waku-project",
  "compatibility_date": "2025-11-17",
  "assets": {
    "directory": "./dist/public",
    "html_handling": "drop-trailing-slash"
  }
}
```

and a `src/waku.server.tsx` file that uses the Cloudflare adapter with the `static: true` option:

```ts
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/cloudflare';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  static: true,
});
```

You must also make sure that all of your pages and layouts are defined as static by exporting a getConfig function that specifies `render: 'static'`. For example, in `./src/pages/index.tsx`:

```ts
export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

Then run `npm run build` to build the static assets into the `dist/public` folder, and deploy with `npx wrangler deploy`.

---

### Guides Csp (`docs/guides/csp.mdx`)

---
slug: csp
title: Configure CSP
description: Configure Content Security Policy headers, nonce for inline script, and overall suggestions.
category: Runtime and Middleware
order: 20
---

[Content Security Policy](https://developer.mozilla.org/docs/Web/HTTP/CSP) (CSP)
is important to guard your Waku application against various security threats such as cross-site scripting (XSS), clickjacking, and other code injection attacks.

By using CSP, developers can specify which origins are permissible for content sources, scripts, stylesheets, images, fonts, objects, media (audio, video), iframes, and more.

## Inline Script and nonce

Waku relies on inline scripts for basic functionality: loading the client entry module, streaming the initial RSC workload, and prefetching assets on navigation.

Inline script is also a source of [Cross-site scripting](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/XSS) (XSS). To mitigate this, a common practice is to set [`nonce` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) on `<script>`, together with proper [`Content-Security-Policy` response header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src).

Since Waku v1.0.0-alpha.3, introduced in [PR#1922](https://github.com/wakujs/waku/pull/1922), Waku has built-in support with `nonce`.

Waku has 2 ways to set `nonce`.

### Hono Middleware

Hono provides a [middleware](https://hono.dev/docs/middleware/builtin/secure-headers) which simplifies the setup of security headers. Set up a middleware in `src/middleware/nonce.ts` that enables Hono's [context storage](https://hono.dev/docs/middleware/builtin/context-storage) and generates the nonce:

```ts
import type { MiddlewareHandler } from 'hono';
import { every } from 'hono/combine';
import { contextStorage } from 'hono/context-storage';
import { NONCE, secureHeaders } from 'hono/secure-headers';

const nonceMiddleware = (): MiddlewareHandler =>
  every(
    contextStorage(),
    secureHeaders({
      contentSecurityPolicy: {
        scriptSrc: ["'self'", NONCE],
      },
    }),
  );

export default nonceMiddleware;
```

Behind the scenes, hono generates the `nonce`, passes it to `Content-Security-Policy` headers (you can add more here) and stores it in the `secureHeadersNonce` context. To apply that nonce to Waku's inline scripts, bridge it with a handler interceptor that reads the nonce from Hono's context and calls `unstable_setNonce`. Use `tryGetContext` rather than `getContext`, since interceptors also run at build time where there is no Hono request context.

In managed mode (no `waku.server.tsx`), drop the interceptor in `src/pages/_interceptors/nonce.ts`:

```ts
import { tryGetContext } from 'hono/context-storage';
import type { HandlerInterceptor } from 'waku/router/server';
import { unstable_setNonce as setNonce } from 'waku/router/server';

const nonceInterceptor: HandlerInterceptor = (next) => {
  const nonce = tryGetContext()?.get('secureHeadersNonce');
  if (typeof nonce === 'string') {
    setNonce(nonce);
  }
  return next();
};

export default nonceInterceptor;
```

With a custom `waku.server.tsx` using `createPages`, register the same logic through `createInterceptor`:

```ts
import { tryGetContext } from 'hono/context-storage';
import { unstable_setNonce as setNonce } from 'waku/router/server';

createPages(async ({ createPage, createInterceptor }) => {
  createInterceptor((next) => {
    const nonce = tryGetContext()?.get('secureHeadersNonce');
    if (typeof nonce === 'string') {
      setNonce(nonce);
    }
    return next();
  });
  return [
    // ...pages...
  ];
});
```

An interceptor wraps each render in both the request and build phases, so reading the nonce and calling `setNonce` runs inside the render scope where Waku picks it up.

For more on request context and handler interceptors, see [Request Context](/guides/request-context).

### Pass nonce in `waku.server.tsx`

If your adapter allows you to customize `handleRequest`, you can pass it manually:

```tsx
import adapter from 'waku/adapters/default';
import { Slot_UNSTABLE as Slot } from 'waku/minimal/client';
import App from './components/App.js';
import { encodeBase64 } from 'hono/utils/encode';

const generateNonce = () => {
  const arrayBuffer = new Uint8Array(16);
  crypto.getRandomValues(arrayBuffer);
  return 'toBase64' in arrayBuffer
    ? arrayBuffer.toBase64()
    : encodeBase64(arrayBuffer.buffer);
};

export default adapter({
  handleRequest: async (input, { renderRsc, renderHtml }) => {
    if (input.type === 'rsc') {
      return renderRsc({ App: <App /> });
    }
    if (input.type === 'http' && input.pathname === '/') {
      const nonce = generateNonce();
      const response = await renderHtml(
        await renderRsc({ App: <App /> }),
        <Slot id="App" />,
        {
          rscPath: '',
          nonce,
        },
      );

      response.headers.set(
        'Content-Security-Policy',
        `script-src 'self' 'nonce-${nonce}';`,
      );

      return response;
    }
    return null;
  },
  handleBuild: async () => {},
});
```

We set a minimal CSP header here. In practice, CSP should be as minimal or strict as possible. We recommend hono middleware approach as it provides a [good default value](https://hono.dev/docs/middleware/builtin/secure-headers#supported-options).

### Limitation

In the case of SSG, a nonce is not secure because it must be randomly generated for every response. Since SSG produces static HTML, this is not possible. If your security requirements prioritize a nonce over static HTML benefits, consider disabling HTML pre-rendering, or use [SSR Stream Interception](/guides/stream-intercept) if your host platform provides a similar middleware mechanism (e.g. Netlify's Edge Function).

For this reason `unstable_setNonce` applies to dynamic (request-time) rendering only and has no effect on statically generated HTML. It must be called from within a render scope such as a handler interceptor; called elsewhere it is a no-op. It currently uses an `unstable_` name and may change.

---

### Guides Custom Router (`docs/guides/custom-router.mdx`)

---
slug: custom-router
title: Custom Router
description: Resolve routes directly while using Waku's router client.
category: Low-level APIs
order: 20
---

## When to Use a Custom Router

Use `unstable_defineRouter` when you want to bring your own route matching: plain strings, regular expressions, a CMS, or another route manifest. It pairs with `waku/router/client`; Waku handles navigation, server actions, redirects, and RSC request encoding.

For programmatic page and layout declarations, use [`createPages`](/create-pages). For a different client protocol or lower-level request handling, use the [Minimal API](/guides/minimal-api).

## Server Entry

`resolve` receives a normalized pathname and the query string without `?`. Return a route, an HTTP handler, or `null` for not found. All resolver callbacks return promises.

```tsx
// src/waku.server.tsx
import adapter from 'waku/adapters/default';
import { Children_UNSTABLE as Children } from 'waku/minimal/client';
import { unstable_defineRouter as defineRouter } from 'waku/router/server';
import Home from './components/home';
import Post from './components/post';

const root = {
  immutable: true,
  render: () => (
    <html>
      <body>
        <Children />
      </body>
    </html>
  ),
};

export default adapter(
  defineRouter({
    async resolve(pathname, query) {
      if (pathname === '/api/health') {
        return async () => Response.json({ ok: true });
      }
      if (pathname === '/') {
        return {
          elements: {
            root,
            route: { immutable: true, render: () => <Home /> },
          },
        };
      }
      const match = /^\/posts\/([^/]+)$/.exec(pathname);
      if (match) {
        return {
          elements: {
            root,
            route: { render: () => <Post id={match[1]!} query={query} /> },
          },
        };
      }
      return null;
    },
    async getBuildPaths() {
      return ['/'];
    },
  }),
);
```

The `root` source renders the document and places the active route with `Children_UNSTABLE`. The `route` source renders the current route. Add other element sources under IDs you own and compose them with `Slot_UNSTABLE` when independently reusable content is useful.

Each source uses Minimal's `Unstable_ElementSource`:

- `render` produces its content.
- `immutable: true` lets the client reuse content for that slot ID indefinitely.
- `getEtag` optionally computes a validator for mutable content before rendering it.

An immutable slot must have the same content for its lifetime. The `root` ID is shared across routes, while `route` is mapped internally to a path-specific slot. Additional IDs are shared wherever they appear, so use distinct IDs for different content.

The server caches immutable named elements. It reuses immutable `route` content from build output, but does not cache new route content by requested pathname at runtime. Give a route source a `cacheKey` to share its cached content across pathnames, such as a reusable route shell. Sources with the same key must render the same content; use a bounded set of keys rather than arbitrary requested URLs.

Additional IDs must not start with `_`, a capital letter, or `route:`; these names are reserved for Minimal and Router metadata.

Set `noSsr: true` on a route to return Waku's client-rendered document fallback; its RSC response remains available. Resolving `/404` supplies a custom not-found route. Its availability is checked once per router instance; the content is resolved on each visit. Supply `getHas404: async () => boolean` when your manifest can answer this without resolving a route, or when a catch-all also matches `/404`. See [Redirects and Not Found](/guides/redirects-and-not-found) for streaming behavior.

For typed search, parse `query` with an application-owned codec inside `resolve`. Share that independent codec with a local `unstable_createRouteHref` factory and the client search hooks; `defineRouter` requires no codec map or browser registration. Navigation and redirects consume the resulting URL strings. See [Typed Routes](/guides/typed-routes) for examples and [migration steps](/guides/typed-routes#migration).

## Client Entry

Use the normal router client, not a custom Minimal client:

```tsx
// src/waku.client.tsx
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import { ErrorBoundary, Router } from 'waku/router/client';

const app = (
  <ErrorBoundary>
    <Router />
  </ErrorBoundary>
);
if ((globalThis as any).__WAKU_HYDRATE__) {
  hydrateRoot(document, app, defaultRootOptions);
} else {
  createRoot(document, defaultRootOptions).render(app);
}
```

## HTTP Handlers

Return an async function from `resolve` for an API endpoint. The function receives the real request, including its method, headers, and body, with the pathname relative to Waku's base path. It runs only for HTTP requests, not when the client tries to fetch a route payload.

Route params are caller-owned: match and capture them in `resolve`, then close over them in the handler or element source. Use `getRequest` from `waku/router/server` to inspect the request during resolution or rendering.

The request body can be read only once. If resolution needs to inspect it before returning an HTTP handler, read `getRequest().clone()` so the handler can still read the original body.

## Build Paths

The optional `getBuildPaths` returns pathname strings, without queries or fragments. These use the same `resolve` callback as runtime requests.

- Routes with only immutable sources emit RSC and HTML files.
- Mutable routes cache only their immutable sources; mutable content is rendered at runtime.
- HTTP handlers emit their response bodies as static files.
- Immutable routes with `noSsr` emit RSC and a default client-rendered HTML document.

For explicit build planning, return `{ pathname, route?, prerender?, prefetchPattern? }` targets. `route` supplies build-specific sources or an HTTP handler rather than calling `resolve`. `prerender: false` caches only immutable sources without emitting a page, even when every source is immutable. `prefetchPattern` is a regular expression matching paths that share the target's client modules.

Omit a path to skip its build output. Omit `getBuildPaths` entirely for runtime-only routes. Custom routers do not need `PathSpec` or component source-file metadata.

Include `/404` in `getBuildPaths` to emit a static `404.html`.

## Independently Requested Elements

The optional `resolveElement` returns an element source or `null` for a requested slot ID. Existing lazy `Slice` components from `waku/router/client` request IDs of the form `slice:<id>`.

```tsx
// src/waku.server.tsx
async resolveElement(id) {
  if (id === 'slice:summary') {
    return { immutable: true, render: () => <Summary /> };
  }
  return null;
},
async getBuildElementIds() {
  return ['slice:summary'];
},
```

`getBuildElementIds` lists immutable elements to emit as standalone RSC files, including lazy slices needed on a static deployment. It works independently of `getBuildPaths`. Currently only `slice:<id>` IDs are supported. Listed elements must resolve and be immutable; otherwise the build fails. Unlisted elements require a runtime server when fetched independently.

Return `{ id, source }` instead of an ID string when the build should use an explicit source rather than `resolveElement`.

Include an element directly in a route's `elements` when it should arrive in the route payload. There are no separate page, layout, or slice declarations in `defineRouter`; those are `createPages` authoring concepts.

## Boundaries

`defineRouter` owns the protocol expected by `waku/router/client`: reserved metadata, root and route slots, action rerenders, redirects, 404 handling, SSR, and build output. Custom routers supply resolution and element sources, without importing internal IDs or encoding helpers from `waku/router/client-core`.

For changes to Router itself or another client binding, see [Router Protocol](/guides/router-protocol).

`createPages` uses this same public API for runtime and builds. It owns page/layout matching, configuration metadata, and source-file pruning. `fsRouter` uses only `createPages`' public authoring API to translate route modules into those declarations.

The API is unstable and may change.

---

### Guides Docker (`docs/guides/docker.mdx`)

---
slug: docker
title: Dockerise a Waku app
description: How to package a Waku app into a Docker image.
category: Deployment
order: 30
---

## Build a Docker Image

Bundle your Waku app into a Docker image for portability and deployment in container-based environments.

### Prerequisites

Make sure Docker is installed on your machine. This guide assumes an npm-based Waku app. If your project uses pnpm or another package manager, use the matching lockfile and install commands in the Dockerfile.

### Dockerfile

Create a `Dockerfile` in your project root:

```dockerfile
FROM node:22-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:22-alpine AS runner

WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=builder /app/dist ./dist

EXPOSE 8080

CMD ["npm", "run", "start"]
```

This Dockerfile uses Docker's [multi-stage build](https://docs.docker.com/build/building/multi-stage/) pattern:

- the `builder` stage installs all dependencies and runs `npm run build`
- the `runner` stage installs production dependencies and copies only the built `dist` output
- the container starts the app with `npm run start`

A health check command can be optionally included at the end of the Dockerfile. An example would be:
`HEALTHCHECK --interval=30s CMD wget -qO- http://localhost:8080 || exit 1`

### .dockerignore file

Use an ignore file to avoid copying unnecessary/undesirable files into the image. For example:

```
/node_modules
.git
dist
.vscode
```

This way `node_modules` files will not be copied in the above Dockerfile setup.

### Compose File

To streamline local container startup, create a compose file:

```yaml
services:
  waku-app:
    build:
      context: .
    image: waku-app
    environment:
      NODE_ENV: production
    ports:
      - '8080:8080'
```

Specify the name of the service, and within it add the following:

- build context ( _cwd_ ) where the build process should take place
- image name so that the correct image is used to create the container
- environment variables
- port forwarding so the app is accessible on the host machine (e.g. in your browser)

### Build the container

Finally, build and start the container:

```sh
docker compose up --build
```

The app should be available at [http://localhost:8080](http://localhost:8080).

---

### Guides Metadata (`docs/guides/metadata.mdx`)

---
slug: metadata
title: Metadata
description: How title, description, and Open Graph tags resolve across layouts and pages.
category: Routing and Navigation
order: 45
---

Waku renders metadata as ordinary JSX. React hoists `title`, `meta`, and `link`
elements into the document head, so a page declares them wherever it likes:

```tsx
export default function AboutPage() {
  return (
    <div>
      <title>About</title>
      <meta name="description" content="Who we are." />
      <h1>About</h1>
    </div>
  );
}
```

## Pages override layouts

React does not deduplicate hoisted tags, so a layout and a page that both
declare a `title` would otherwise emit two of them. Waku resolves this while
streaming the HTML: when the same key is declared more than once in the head,
the **last** declaration wins.

A layout that declares its metadata before it renders `{children}` comes first,
so the page's declaration comes last. Defaults in a layout, overridden per page,
work as expected:

```tsx
// src/pages/blog/_layout.tsx
export default function BlogLayout({ children }: { children: ReactNode }) {
  return (
    <div>
      <title>Blog</title>
      <meta property="og:site_name" content="My Site" />
      {children}
    </div>
  );
}

// src/pages/blog/hello.tsx: this page's title wins, og:site_name is inherited
export default function HelloPage() {
  return (
    <div>
      <title>Hello</title>
      <h1>Hello</h1>
    </div>
  );
}
```

The keys that are merged are `title`, `meta[name="description"]`, and the Open
Graph properties that hold a single value: `og:title`, `og:type`, `og:url`,
`og:description`, `og:determiner`, `og:site_name` and `og:locale`. Each is
merged on its own, so a layout can set `og:site_name` while a page sets
`og:title`. A `name` is matched in any ASCII case, and a `property` exactly as
written.

This matters most for consumers that do not run JavaScript. Search engine and
social-card crawlers read the served HTML, where without merging the layout's
tags would come first and win.

## Everything else is your responsibility

Tags outside that set are emitted as written, and declaring one twice produces
two tags. Most notably:

- **`viewport` and `charset`** are not merged. React re-adds on hydration any
  tag missing from the served HTML, appending it after the survivor, which
  would flip which `viewport` applies; `charset` is resolved by its _first_
  occurrence, so a later one never takes effect anyway. To change either,
  replace the root component rather than declaring a second tag, as shown below.
- **Repeated Open Graph properties** such as `og:image`, `og:video` and
  `og:locale:alternate` are arrays in OGP, and their order carries meaning, so
  they are emitted as written. A layout that declares `og:image` is not
  replaced by a page that declares one.
- **Slices** declare their metadata wherever the `<Slice>` is rendered, so
  whether a slice or the page wins depends on which comes first. Keep metadata
  out of reusable slices.
- **Metadata inside a `<Suspense>` boundary** is emitted after the head has been
  flushed and cannot be merged into it. An `async` page component is fine. Only
  an explicit boundary between the page and its metadata defers it.
- **A `<meta>` that names itself twice**, such as with both `name` and
  `property`, turns the merge off for every page it is on. Give each name a tag
  of its own.
- **A head holding anything but metadata** is served as rendered. A `<template>`
  or an inline `<svg>` has a `<title>` of its own that is not the document's, so
  a root component that puts one in `<head>` turns the merge off for that page.

## Overriding charset and viewport

Waku's defaults live in the root component. Adding `src/pages/_root.tsx`
replaces it, and with it the entire document head:

```tsx
// src/pages/_root.tsx
export default function Root({ children }: { children: ReactNode }) {
  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>{children}</body>
    </html>
  );
}
```

An app that defines its own root, or that renders the document itself through
the [minimal API](/guides/minimal-api), owns these tags and must declare the
ones it wants.

---

### Guides Migrating From Nextjs (`docs/guides/migrating-from-nextjs.mdx`)

---
slug: migrating-from-nextjs
title: Migrating from Next.js
description: What each Next.js App Router API becomes in Waku, and what to do about the ones with no counterpart.
category: Getting Started
order: 60
---

## Before you start

Waku and the Next.js App Router share a foundation — React Server Components,
`'use client'`, server actions, `<Suspense>` streaming — so most of an app moves
across unchanged. Components, styles, forms, validation, data access and business
logic are not the work. The work is the framework surface around them: routing
conventions, metadata, caching, cookies, environment variables, and the handful
of Next.js APIs that Waku deliberately does not have.

This guide is written from four migrations of real Next.js apps, which live in
the [waku-examples](https://github.com/wakujs/waku-examples) repository and are
referenced throughout:

| Example                                                                                                  | Migrated from                                 | What it exercises                                      |
| -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------ |
| [`nextjs-blog-starter`](https://github.com/wakujs/waku-examples/tree/main/fs-router/nextjs-blog-starter) | `vercel/next.js` `examples/blog-starter`      | static generation, metadata, markdown, CSS modules     |
| [`nextjs-dashboard`](https://github.com/wakujs/waku-examples/tree/main/fs-router/nextjs-dashboard)       | `vercel/next-learn` `dashboard/final-example` | auth, a database, server actions, streaming, search    |
| [`nextjs-commerce`](https://github.com/wakujs/waku-examples/tree/main/fs-router/nextjs-commerce)         | `vercel/commerce`                             | caching, cookies, optimistic UI, sitemap and OG images |
| [`nextjs-photo-modal`](https://github.com/wakujs/waku-examples/tree/main/fs-router/nextjs-photo-modal)   | `vercel/nextgram`                             | parallel and intercepting routes                       |

## The shape of the project

```
app/layout.tsx            →  src/pages/_root.tsx  +  src/pages/_layout.tsx
app/page.tsx              →  src/pages/index.tsx
app/about/page.tsx        →  src/pages/about.tsx
app/blog/[slug]/page.tsx  →  src/pages/blog/[slug].tsx
app/shop/[...rest]/page   →  src/pages/shop/[...rest].tsx
app/(marketing)/…         →  src/pages/(marketing)/…
app/api/hello/route.ts    →  src/pages/_api/api/hello.ts   (_api is stripped from the URL)
middleware.ts             →  src/middleware/*.ts   (Hono middleware — read on)
next.config.js            →  waku.config.ts
```

Two conventions have no Next.js equivalent and are worth knowing early:
`src/pages/_slices/` holds page fragments with their own rendering mode, and
`src/pages/_interceptors/` wraps each render. Three directory names are
ignored by the router so that files can be co-located inside `src/pages`:
`_actions`, `_components` and `_hooks`. Other `_`-prefixed names are not
special, so put `lib/` and `utils/` outside `src/pages` as before.

Catch-alls need one adjustment. Waku has `[...rest]` but no `[[...rest]]`, and
its catch-all does not match the base path: `shop/[...rest].tsx` serves
`/shop/a` and `/shop/a/b`, not `/shop`. An optional catch-all therefore becomes
two files, `shop/index.tsx` and `shop/[...rest].tsx`. Carrying the Next.js
filename across on its own leaves `/shop` with no route.

### The document shell moves

Next.js renders `<html>` and `<body>` from the root layout. Waku splits that: the
document shell is `src/pages/_root.tsx` and `_layout.tsx` renders inside it.

```tsx
// src/pages/_root.tsx
import type { ReactNode } from 'react';
import '../styles.css';

export default function RootElement({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>{children}</body>
    </html>
  );
}

export const getConfig = async () => ({ render: 'static' }) as const;
```

Next.js adds the charset and viewport tags for you. A `_root.tsx` replaces
Waku's default document head, so it declares them itself.

### Every page declares how it renders

This is the one addition Waku asks for on every route. Next.js infers static or
dynamic from what a page touches; Waku wants it stated:

```tsx
export const getConfig = async () => ({ render: 'static' }) as const;
// or
export const getConfig = async () => ({ render: 'dynamic' }) as const;
```

A dynamic segment that renders statically also lists its paths, which is where
`generateStaticParams()` ends up:

```tsx
export const getConfig = async () => {
  const posts = await getAllPosts();
  return { render: 'static', staticPaths: posts.map((p) => p.slug) } as const;
};
```

### Params and search params arrive as props

```tsx
// Next.js
export default async function Page(props: {
  params: Promise<{ slug: string }>;
  searchParams?: Promise<{ q?: string }>;
}) {
  const { slug } = await props.params;
  const { q } = (await props.searchParams) ?? {};
}

// Waku
import type { PageProps } from 'waku/router';

export default async function Page({ slug, query }: PageProps<'/blog/[slug]'>) {
  const q = new URLSearchParams(query).get('q') ?? '';
}

// A page that reads `query` has to be dynamic: a static page is rendered at
// build time, with no request, and receives no query at all.
export const getConfig = async () => ({ render: 'dynamic' }) as const;
```

`query` is the raw query string, not a parsed object. A static page never sees
one — it is rendered once at build time — so any page that filters or paginates
from search params must be `render: 'dynamic'`.

Attaching a [search codec](/guides/typed-routes) in `getConfig` does not change
`query`; it adds a second prop, `search`, parsed and typed for that route. Reach
for it instead of `new URLSearchParams(query)` when you want validation or
coercion.

## Metadata

There is no `metadata` export and no `generateMetadata()`. Render the tags; React
hoists any `<title>`, `<meta>` and `<link>` into the document head, so
"generated" metadata is just data you already fetched:

```tsx
export default async function ProductPage({
  handle,
}: PageProps<'/product/[handle]'>) {
  const product = await getProduct(handle);
  return (
    <>
      <title>{product.title}</title>
      <meta name="description" content={product.description} />
      {/* ... */}
    </>
  );
}
```

As in Next.js, a page's tags override its layout's. For `title`, `description`
and the Open Graph properties that hold one value, the last declaration wins, so
a layout that declares its defaults before rendering `{children}` is overridden
by its pages. Three things do not carry over: there is no `title.template`, so
write the full title on each page; repeated properties such as `og:image` are
emitted as written rather than replaced; and `charset` and `viewport` belong in
`_root.tsx`. [Metadata](/guides/metadata) has the full rules.

## Routing APIs

| Next.js                                       | Waku                                                                                                                                                                                                                                                                                                                                                                                        |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `import Link from 'next/link'`, `<Link href>` | `import { Link } from 'waku'`, `<Link to>`                                                                                                                                                                                                                                                                                                                                                  |
| `useRouter()` from `next/navigation`          | `useRouter()` from `waku`                                                                                                                                                                                                                                                                                                                                                                   |
| `usePathname()`                               | `useRouter().path`                                                                                                                                                                                                                                                                                                                                                                          |
| `useSearchParams()`                           | `new URLSearchParams(useRouter().query)`                                                                                                                                                                                                                                                                                                                                                    |
| `redirect()`                                  | `unstable_redirect()` from `waku/router/server`; pass `303` from a server action so a no-JavaScript form POST is not re-sent to the destination                                                                                                                                                                                                                                             |
| `notFound()`                                  | `unstable_notFound()` from `waku/router/server`                                                                                                                                                                                                                                                                                                                                             |
| `revalidatePath()`                            | from a server action, `unstable_rerenderRoute()` for the route the action came from, or `unstable_rerenderRoute(path, query)` for another one; on the client, `useRouter().reload()`; from a route handler there is no equivalent — `unstable_rerenderRoute` throws `Rerender is not available.` outside an action — see [Rerendering After Server Actions](/guides/rerender-after-actions) |

`useSearchParams()` in Next.js opts a page into client rendering and is usually
wrapped in `<Suspense>`; Waku's `useRouter()` carries no such requirement, so
those boundaries can go.

### Typed routes reject broad strings

Once `waku router typegen` has run, `<Link to>` and `router.push()` accept known
raw hrefs or strings constructed from known route patterns. Use a literal,
a [local href factory](/guides/typed-routes), or a matching template-literal type:

```tsx
import { unstable_createRouteHref as createRouteHref } from 'waku/router';

const blogPost = createRouteHref({ path: '/blog/[slug]' });

<Link to="/about">About</Link>;
<Link to={blogPost({ params: { slug } })}>{title}</Link>;
const href: `/search?${string}` = `/search?${params}`;
```

Factory results carry type-only pattern provenance, not proof that a slug is
among the generated static paths. Keep their inferred type: widening a result
to `string` discards that evidence.

For a reusable link wrapper, type its `to` prop as
`ComponentProps<typeof Link>['to']`. This preserves generated route checks; it
does not accept arbitrary strings. For a runtime-only destination such as a login
`callbackUrl`, validate it against an application allowlist before asserting
`href as ComponentProps<typeof Link>['to']`. An assertion performs no runtime
validation.
`nextjs-dashboard` follows a callback only to a same-origin path under
`/dashboard`.

## Files that were conventions

| Next.js                      | Waku                                                                                                                                                               |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `loading.tsx`                | a `<Suspense fallback>` where you want it                                                                                                                          |
| `error.tsx`                  | an error boundary you place, e.g. `react-error-boundary`                                                                                                           |
| `not-found.tsx`, per segment | one `src/pages/404.tsx` for the app                                                                                                                                |
| `template.tsx`               | no equivalent; remount with a `key`                                                                                                                                |
| `default.tsx`                | no equivalent (see parallel routes below)                                                                                                                          |
| `sitemap.ts`, `robots.ts`    | `_api/sitemap.xml.ts`, `_api/robots.txt.ts` — only the final extension is stripped, so the name carries the one the crawler asks for                               |
| `opengraph-image.tsx`        | an API route; `satori` + `@resvg/resvg-js` for PNG                                                                                                                 |
| `route.ts`                   | `src/pages/_api/**/*.ts` exporting `GET`, `POST`, …; the `_api` segment is stripped from the URL, so `app/api/x/route.ts` becomes `_api/api/x.ts` to keep `/api/x` |

Losing per-segment `not-found.tsx` is a real reduction: `notFound()` anywhere in
the app renders the same `404.tsx`.

## Caching, or the lack of it

Delete it. There is no `fetch` cache, no `unstable_cache`, no `use cache`, no
`cacheTag`/`cacheLife`, no ISR and no `revalidateTag`. Static pages are built once
and dynamic pages run on every request, so nothing is stale and nothing needs
invalidating. Most `revalidateTag`/`revalidatePath` calls simply disappear.

Check one thing per route before deleting them: a Next.js route that used ISR
(`export const revalidate = 60`) or was refreshed with `revalidatePath()` after
a content change was static _with a refresh path_. A static Waku page has no
refresh path — it is frozen until the next build, and no cache can change that,
because the route code never runs again. So either trigger a rebuild in the flow
that used to call `revalidatePath()`, or make the route `render: 'dynamic'`. Only
once it is dynamic does caching enter the picture: wrap the expensive work in an
explicit [`waku-cache`](/guides/explicit-caching) and invalidate that in the same
flow. Either way, removing the call should not turn "fresh within a minute" into
"fresh at the next deploy".

Two exceptions:

- An action that stays on the page and expects the screen to update needs
  `unstable_rerenderRoute()` — an action that ends in `redirect()` does not.
  Called with no arguments it renders the route the action came from, query
  included, which is also what stops a `useOptimistic` value from snapping back
  when the action settles; pass `(pathname, query)` only to render a different
  route. A re-render carries the route's own elements, not a lazy `<Slice>`: a
  mounted slice keeps what it fetched, so a mutation that changes slice data
  needs the slice remounted with a `key` or refetched on its own. See
  [Rerendering After Server Actions](/guides/rerender-after-actions).
- If you measure a need for caching, add it explicitly. See
  [Explicit Caching](/guides/explicit-caching).

## Cookies, sessions and route protection

Two Next.js habits do not survive the move, and both fail silently rather than
loudly:

- **`middleware.ts` is not an authorization boundary.** A client-side navigation
  requests `/RSC/R/<path>.txt`, not the page path, so a middleware matching
  `/dashboard` on `c.req.path` sees the first page load and nothing after it,
  and serves the protected payload to anyone who clicks a link.
  `unstable_parseRouterRequest` closes that particular gap, but an action
  dispatched from the client carries no route at all, so no path rule covers it.
  And a layout is not the boundary either: Waku renders a route's layouts and
  page as independent slots, so a layout that redirects still lets the page
  render its data. Put the authorization check in the data access layer and the
  mutations, and the redirect in the layout.
- **There is no `cookies().set()`.** Server code reads request headers; only
  middleware owns the response. A sign-in action writes its cookie through a
  request-scoped jar that middleware empties onto the response — and that jar
  must be readable too, because `redirect()` renders the destination inside the
  same request, where the just-set cookie is otherwise invisible.

Both are worked through, with the code, in the
[Authentication](/guides/authentication) guide. `next-auth` v5 specifically is
built on Next.js internals and does not port; that guide covers what replaces it.

## Parallel and intercepting routes

Waku has neither, and no `default.tsx`. The common use — a modal over the page
you came from, a standalone page on a direct load — is reachable through **layout
persistence**: a navigation swaps the page element and keeps the layout mounted.
Move the "background" UI into the layout, and let the route render only the
overlay. `nextjs-photo-modal` does exactly this and matches the original on all
four behaviours.

What the app has to supply itself is the soft-versus-hard navigation distinction
that an intercepting route encodes. A module-level flag set by an effect in the
root layout, read during render, is enough.

## Things Next.js provides that Waku does not

- **`next/image`.** Use `<img>`, or an image CDN. There is no built-in resizing,
  format negotiation or layout-shift prevention.
- **`next/font`.** Use `@fontsource/*` packages, which self-host the same fonts,
  and declare the family in CSS.
- **`next/form`.** A plain `<form>`; submitting does a full page load.
- **Integrated auth, i18n, analytics and image processing.** These are ecosystem
  choices in Waku by design — see [Use Cases](/guides/use-cases).

## Build and tooling details

- Waku projects are ESM. A `postcss.config.js` using `module.exports` must become
  `export default` (or be renamed `.cjs`).
- `NEXT_PUBLIC_*` variables become `WAKU_PUBLIC_*`, read in client code as
  `import.meta.env.WAKU_PUBLIC_*`; every other variable stays on the server.
  Read those with `getEnv()` from `waku` rather than `process.env`, which not
  every runtime provides: code copied from `lib/` that reads `process.env` can
  pass on Node.js and fail after deploying elsewhere.
- Vite needs `resolve.tsconfigPaths: true` to resolve `paths` and `baseUrl` from
  `tsconfig.json`; without it, `waku dev` cannot find the aliased imports. Set it
  under `vite.resolve` in `waku.config.ts` rather than mirroring each alias in
  `vite.resolve.alias`.
- A dependency that loads runtime assets next to itself (a WASM database, `sharp`,
  a generated Prisma client) must stay out of the server bundle:
  `vite.environments.rsc.resolve.external`.
- Tailwind v4 with `@tailwindcss/vite` works unchanged, and so do CSS modules.
  Tailwind v3 with PostCSS keeps working too, but its `content` globs are a list
  of paths: a config scanning `./app/**/*` finds nothing once the routes live in
  `src/pages`, and the build succeeds with those classes missing from the CSS.
  Point `content` at `./src/**/*` when you move them.

## A migration order that works

1. Scaffold a Waku project and copy `components/`, `lib/` and styles across.
2. Rename `NEXT_PUBLIC_*` variables to `WAKU_PUBLIC_*`, read on the client
   through `import.meta.env`, and switch server reads from `process.env` to
   `getEnv()`.
3. Move pages and layouts into `src/pages`, adding `getConfig` to each.
4. Replace `metadata` exports with rendered tags in the same layouts and pages.
5. Swap `next/link` and `next/navigation` for `waku`. On a large app, a six-line
   compatibility module keeps that step to one import line per file —
   `nextjs-commerce` does this and `nextjs-dashboard` does it the direct way.
6. Delete the caching layer.
7. Port route handlers to `_api/`, and `sitemap`/`robots`/OG images with them.
8. Move authorization out of middleware and into the data layer, with the
   redirect in a layout, and rebuild cookie writes around a jar — the
   [Authentication](/guides/authentication) guide has both.
9. Handle the remainder: images, fonts, parallel routes.

---

### Guides Minimal Api (`docs/guides/minimal-api.mdx`)

---
slug: minimal-api
title: Minimal API
description: Low-level Waku primitives for library authors and custom integrations.
category: Low-level APIs
order: 10
---

## When to Use the Minimal API

The minimal API is the lowest-level public surface for building on top of Waku. It is intended for:

- library authors
- custom runtimes and integrations
- advanced users who need direct control over routing, request dispatch, and build output

If you are building an application, use `waku/router` instead. The minimal API deliberately does not provide:

- config-based routing / filesystem routing
- automatic route-to-component mapping
- automatic prerender planning
- page conventions such as `layout.tsx`, `page.tsx`, or `404.tsx`

If you need programmatic routing while keeping Waku's router behavior, see the [`createPages` reference](/create-pages) or [Custom Router](/guides/custom-router). If you are implementing a deployment/runtime adapter, start with [Adapter Authoring](/guides/adapter-authoring).

Waku Router's use of these primitives is described in [Router Protocol](/guides/router-protocol). Its route IDs and navigation policy belong to Router, not Minimal.

Exports marked `unstable_*` or `*_UNSTABLE` in this document may still change.

### API Surface

The Minimal API consists of server handler contracts and client rendering primitives:

| Entry point           | Surface                                                                                      | Use when                                                              |
| --------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `waku/minimal/server` | `Unstable_Handlers`                                                                          | Defining `handleRequest` and `handleBuild` for an adapter.            |
| `waku/minimal/server` | `Unstable_HandleRequest`, `Unstable_HandleBuild`                                             | Typing individual handlers.                                           |
| `waku/minimal/server` | `Unstable_RenderRsc`, `Unstable_RenderHtml`, `Unstable_RenderHtmlFallback`, `Unstable_Etags` | Typing rendering utilities and validators.                            |
| `waku/minimal/server` | `unstable_createCustomError`, `unstable_getErrorInfo`                                        | Creating and reading status and redirect errors.                      |
| `waku/minimal/client` | `Root_UNSTABLE`                                                                              | Installing the minimal client runtime.                                |
| `waku/minimal/client` | `Slot_UNSTABLE`                                                                              | Rendering a server element by RSC ID.                                 |
| `waku/minimal/client` | `Children_UNSTABLE`                                                                          | Placing client-side `Slot` children inside a server-rendered element. |
| `waku/minimal/client` | `useFetchRsc_UNSTABLE`                                                                       | Fetching and decoding an RSC payload.                                 |
| `waku/minimal/client` | `useMergeElements_UNSTABLE`                                                                  | Merging an RSC payload into the current element map.                  |
| `waku/minimal/client` | `useElementsPromise_UNSTABLE`                                                                | Reading the current element map.                                      |
| `waku/minimal/client` | `useRegisterRscEnhancer_UNSTABLE`                                                            | Deprecated effect-based registration, retained through rc.4.          |
| `waku/minimal/client` | `useRegisterRscReloadListener_UNSTABLE`                                                      | Choosing the RSC input to refresh during development reloads.         |
| `waku/minimal/client` | `unstable_combineElements`                                                                   | Combining element maps without losing their etags.                    |
| `waku/minimal/client` | `unstable_getErrorInfo`                                                                      | Reading status, redirect and network-error information.               |
| `waku/minimal/client` | `unstable_formatRscUrl`                                                                      | Constructing an RSC payload URL.                                      |

Examples below often import these as shorter names.

`waku/adapter-builders` exports `Unstable_ServerEntry` for adapter authors
who own a complete server entry. Waku's bootstrap and HTML renderer use private
implementation modules.

Advanced composition APIs remain available to any Minimal-based framework:

- `unstable_combineElements(a, b, { filter })` selects keys from `b`
  while preserving their validators.
- `unstable_isImmutableElement(elements, slotId)` inspects a slot's immutable
  status without exposing its validator.
- `useMergeElements_UNSTABLE` accepts a merge callback for custom updates to
  the Root's queued element promise.
- `unstable_buildElements`, `Unstable_ElementSource`, and
  `Unstable_BuiltElements` build payloads that omit slots the client already
  holds, using the etags supplied to `handleRequest`.
- `unstable_parseRequest(request)` inspects HTTP, RSC, and server-call URLs
  without consuming their bodies. `unstable_formatRscUrl(rscPath, baseUrl)`
  constructs a payload URL using the configured base path and RSC endpoint.
  The formatter is available from both `waku/minimal/server` and
  `waku/minimal/client`; it preserves the base URL's query and hash.
  These support middleware in Minimal-based frameworks; request inspection
  does not validate actions or authorize access.

### Mental Model

At this level, Waku gives you rendering primitives and very little policy.

- `handleRequest` decides how each incoming request is handled.
- `renderRsc` renders a React Server Components payload as a `ReadableStream`.
- `renderHtml` renders the HTML shell that boots the client.
- `Root` fetches and owns the current RSC payload on the client.
- `Slot` renders a named element from the RSC payload.
- `handleBuild` decides which files are emitted during `waku build`.

Two terms are important:

- `RSC ID`: the key of an element returned by `renderRsc`.
- `rscPath`: an application-defined string that identifies which RSC payload to fetch. Waku treats it as opaque.

If the server returns:

```tsx
return renderRsc({
  App: <App />,
  Sidebar: <Sidebar />,
});
```

then the client can render those elements with:

```tsx
<Root>
  <Slot id="App" />
  <Slot id="Sidebar" />
</Root>
```

### End-To-End Example

A minimal SSR setup has two entry points:

- `src/waku.server.tsx`
- `src/waku.client.tsx`

`src/waku.server.tsx`:

```tsx
import type { Unstable_Handlers as Handlers } from 'waku/minimal/server';
import adapter from 'waku/adapters/default';
import { Slot_UNSTABLE as Slot } from 'waku/minimal/client';
import App from './components/App.js';

const handlers: Handlers = {
  handleRequest: async (input, { renderRsc, renderHtml }) => {
    if (input.type === 'rsc') {
      return renderRsc({ App: <App name={input.rscPath || 'Waku'} /> });
    }
    if (input.type === 'http' && input.pathname === '/') {
      const rscPath = '';
      return renderHtml(
        await renderRsc({ App: <App name="Waku" /> }),
        <Slot id="App" />,
        { rscPath },
      );
    }
    return null;
  },

  handleBuild: async ({
    renderRsc,
    renderHtml,
    rscPath2pathname,
    generateFile,
  }) => {
    const rscPath = '';
    const stream = await renderRsc({ App: <App name="Waku" /> });
    const [rscStream, htmlStream] = stream.tee();
    await generateFile(rscPath2pathname(rscPath), rscStream);
    const html = await renderHtml(htmlStream, <Slot id="App" />, { rscPath });
    await generateFile('index.html', html.body!);
  },
};

export default adapter(handlers);
```

`src/waku.client.tsx`:

```tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import {
  Root_UNSTABLE as Root,
  Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';

const rootElement = (
  <StrictMode>
    <Root>
      <Slot id="App" />
    </Root>
  </StrictMode>
);

if ((globalThis as any).__WAKU_HYDRATE__) {
  hydrateRoot(document, rootElement);
} else {
  createRoot(document).render(rootElement);
}
```

Passing a handler object directly to `adapter(...)` also infers these types.

### Request Flow

In the example above, a request to `/` looks like this:

1. The adapter receives `GET /` and calls `handleRequest` with `input.type === 'http'`.
2. `handleRequest` renders an RSC payload with `renderRsc(...)`.
3. `handleRequest` passes that payload to `renderHtml(...)` together with an HTML tree containing `<Slot id="App" />`.
4. The browser loads `src/waku.client.tsx`.
5. `<Root>` fetches the RSC payload for `rscPath === ''`.
6. `<Slot id="App" />` renders the server element stored under the `App` key.

### Server API

#### Handler Objects

Define `handleRequest` and `handleBuild` in the object passed to an adapter.
Use `Handlers` when defining the object separately:

```tsx
import type { Unstable_Handlers as Handlers } from 'waku/minimal/server';

const handlers: Handlers = {
  handleRequest: async () => null,
  handleBuild: async () => {},
};
```

Use it when you want to:

- define handlers separately from the adapter call
- compose handlers before exporting them
- keep type checking close to the handler object

Or let the adapter infer the handler types:

```tsx
import adapter from 'waku/adapters/default';

export default adapter({
  handleRequest: async () => null,
  handleBuild: async () => {},
});
```

#### `handleRequest(input, utils)`

`handleRequest` is the runtime dispatcher. It receives every request that reaches the minimal server.

The `input` argument is one of the following shapes:

| `input.type` | When it is used                                                                          | Extra fields           |
| ------------ | ---------------------------------------------------------------------------------------- | ---------------------- |
| `rsc`        | RSC payload fetches initiated by `Root` or `fetchRsc`                                    | `rscPath`, `rscParams` |
| `call`       | Server function calls                                                                    | `fn`, `args`           |
| `http`       | Ordinary HTTP requests such as document requests, custom endpoints, and form submissions | `tryAction?`           |

Every input also includes:

- `pathname`: pathname from the request URL
- `req`: the original `Request` object

`input.req` is the request for this handler. The Minimal API does not provide an
ambient `getRequest()` (that is a router feature); pass `input.req`
where you need it, or wrap the body in your own `AsyncLocalStorage` to reach it
from deeper components and server functions.

Typical handling patterns:

`rsc` requests usually return an RSC payload:

```tsx
if (input.type === 'rsc') {
  return renderRsc({
    App: <App name={input.rscPath || 'Waku'} />,
  });
}
```

`call` requests usually execute the server function and return its result with the `value` option:

```tsx
if (input.type === 'call') {
  const value = await input.fn(...input.args);
  return renderRsc({}, { value });
}
```

If a server function should also update the rendered server elements, return the updated elements and pass the function result with `value`:

```tsx
if (input.type === 'call') {
  const value = await input.fn(...input.args);
  return renderRsc({ App: <App name="Updated" /> }, { value });
}
```

On multipart `POST` requests, which may be no-JS server action submissions,
the input carries `tryAction`. Calling it consumes the request body
and resolves `{ action: true, formState }` for a decoded server action, or
`{ action: false, formData }` with the parsed form data when the body
contains no server action reference (a plain HTML form or a crawler) —
route that to your ordinary `POST` handling. `tryAction` is memoized, so
calling it again returns the same result, and it rejects cross-origin
requests only when they carry an action reference: an ordinary
cross-origin form post is delivered as form data. Requests without
`tryAction` cannot be action submissions, and their body stays
untouched unless you read it:

```tsx
if (input.type === 'http' && input.pathname === '/') {
  let formState;
  if (input.tryAction) {
    const result = await input.tryAction();
    if (result.action) {
      formState = result.formState;
    } else {
      // an ordinary multipart form: treat it like any other POST
      await handlePost(result.formData);
    }
  }
  return renderHtml(
    await renderRsc({ App: <App name="Waku" /> }),
    <Slot id="App" />,
    {
      rscPath: '',
      formState,
    },
  );
}
```

`http` requests are where you implement document rendering and custom endpoints:

```tsx
if (input.type === 'http' && input.pathname === '/') {
  return renderHtml(
    await renderRsc({ App: <App name="Waku" /> }),
    <Slot id="App" />,
    { rscPath: '' },
  );
}

if (input.type === 'http' && input.pathname === '/api/hello') {
  return new Response('world');
}
```

The `utils` argument provides these helpers:

| Utility                                     | Purpose                                                                                                                    |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `renderRsc(elements, options?)`             | Render an RSC payload as a `ReadableStream`. Object keys become RSC IDs; use `options.value` for a server function result. |
| `renderHtml(elementsStream, html, options)` | Render the full HTML response that boots the client.                                                                       |
| `renderHtmlFallback(options?)`              | Return the client-rendered HTML shell, without rendering an RSC tree.                                                      |
| `loadBuildMetadata(key)`                    | Read metadata saved during `handleBuild`.                                                                                  |

`handleRequest` may return any of the following:

| Return value          | Meaning                                                                                                        |
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
| `ReadableStream`      | Waku wraps it in `new Response(stream)`.                                                                       |
| `Response`            | Returned as-is.                                                                                                |
| `'fallback'`          | Ask Waku to serve its fallback HTML response.                                                                  |
| `null` or `undefined` | No direct response. For `/`, Waku still falls back to HTML. For other paths, the adapter receives no response. |

Important details:

- `renderHtml` expects an options object such as `{ rscPath: '' }`.
- `renderHtmlFallback({ nonce })` sets the CSP nonce on the client bootstrap script without rendering a route. Use the nonce from your response's CSP header for request-time fallback HTML, including dynamic routes with document SSR disabled. Returning `'fallback'` uses the default shell without a nonce. See [Configure CSP](/guides/csp).
- `renderHtml` and `renderHtmlFallback` accept `unstable_extraScriptContent` for additional bootstrap code. `generateDefaultHtml(fileName, options)` accepts the fallback options for build-time bootstrap code; use request-specific nonces only with request-time HTML, not static files.
- RSC IDs starting with `_` are reserved for Waku internals.
- `rscPath` and `rscParams` are opaque application data. Waku does not assign them semantics.
- Each `fetchRsc` call issues a new request. A router-like abstraction can own prefetching and response reuse.

#### Status And Redirect Errors

Use `createCustomError` to attach status or redirect information to an error
that survives Flight serialization:

```ts
import { unstable_createCustomError as createCustomError } from 'waku/minimal/server';

throw createCustomError('Not found', { status: 404 });
```

Throwing a status error directly from `handleRequest` produces an HTTP
response with that status. A redirect uses a `location` and a redirect status
such as `307`. Document requests become HTTP redirects, while RSC and server
function redirects return a readable payload with status `200` and surface
the redirect on the client. Payload consumers can inspect it and choose how
to navigate:

```ts
import { unstable_getErrorInfo as getErrorInfo } from 'waku/minimal/client';

const handleError = (error: unknown) => {
  const info = getErrorInfo(error);
  if (info?.location) {
    window.location.assign(info.location);
  }
};
```

`getErrorInfo` returns `null` for errors without valid Waku information. It is
also available from `waku/minimal/server` for server-side error handling.

#### `handleBuild(utils)`

`handleBuild` runs during `waku build`. It does not return a manifest or instruction list. It is responsible for emitting any static files you want in the build output.

The build utilities are:

| Utility                                     | Purpose                                                                                              |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `renderRsc(elements, options?)`             | Render an RSC payload stream. Use `options.value` for a server function result.                      |
| `renderHtml(elementsStream, html, options)` | Render HTML from an RSC stream and client shell.                                                     |
| `rscPath2pathname(rscPath)`                 | Convert an RSC path into the correct output pathname for the RSC payload.                            |
| `saveBuildMetadata(key, value)`             | Persist metadata that will later be available through `loadBuildMetadata(...)` in `handleRequest`.   |
| `generateFile(fileName, body)`              | Write a file to `dist/public`. Accepts `ReadableStream` or `string`.                                 |
| `generateDefaultHtml(fileName, options?)`   | Write the default client-rendered HTML shell to `dist/public`, optionally with extra bootstrap code. |

The most common patterns are:

Dynamic SSR only:

```tsx
handleBuild: async () => {},
```

Prerender one HTML page and one RSC payload:

```tsx
handleBuild: async ({
  renderRsc,
  renderHtml,
  rscPath2pathname,
  generateFile,
}) => {
  const rscPath = '';
  const stream = await renderRsc({ App: <App name="Waku" /> });
  const [rscStream, htmlStream] = stream.tee();

  await generateFile(rscPath2pathname(rscPath), rscStream);

  const html = await renderHtml(htmlStream, <Slot id="App" />, { rscPath });
  await generateFile('index.html', html.body!);
},
```

`renderHtml(...)` consumes its stream. If you need the same RSC payload both as a standalone file and as input to `renderHtml(...)`, use `ReadableStream.prototype.tee()` as shown above.

Generate fallback HTML only, for example in an SPA build:

```tsx
handleBuild: async ({ generateDefaultHtml }) => {
  await generateDefaultHtml('index.html');
},
```

Persist build metadata and read it at request time:

```tsx
const BUILD_METADATA_KEY = 'metadata-key';

handleBuild: async ({ saveBuildMetadata }) => {
  await saveBuildMetadata(BUILD_METADATA_KEY, 'metadata-value');
},

handleRequest: async (input, { renderRsc, loadBuildMetadata }) => {
  if (input.type === 'rsc') {
    return renderRsc({
      App: (
        <App metadata={(await loadBuildMetadata(BUILD_METADATA_KEY)) || 'Empty'} />
      ),
    });
  }
  return null;
},
```

Render during build:

```tsx
handleBuild: async ({ renderRsc, generateFile, rscPath2pathname }) => {
  const body = await renderRsc({ App: <App name="Waku" /> });
  await generateFile(rscPath2pathname(''), body);
},
```

At build time there is no real request. If your render reads request-scoped data through your own `AsyncLocalStorage`, wrap the render and seed it with a synthetic `Request`.

### Client API

The minimal client API lives in `waku/minimal/client`.

#### `Root`

`Root` is the top-level provider for the minimal client runtime.

```tsx
import { Root_UNSTABLE as Root } from 'waku/minimal/client';
```

Props:

- `initialRscPath?: string`
- `initialRscParams?: unknown`
- `rscEnhancers?: readonly Unstable_RscEnhancer[]`: a stable array defining the mount-time [request pipeline](#request-enhancers).
- `children: ReactNode`

Important behavior:

- `initialRscPath` defaults to `''`.
- `Root` is required for `Slot`, `Children`, `useElementsPromise`, `useMergeElements`,
  `useRegisterRscEnhancer`, and `useRegisterRscReloadListener`. Outside a
  `Root`, `useFetchRsc` still works but runs no enhancers.
- Each `Root` owns its client store. Bare server action calls use the `Root`
  that was most recently mounted when the request starts.
- `Root` injects a `generator` meta tag. Charset and viewport are yours to
  declare.
- For SSR or SSG, use `hydrateRoot(document, rootElement)` when `globalThis.__WAKU_HYDRATE__` is set. Hydration requires the streamed RSC payload supplied with Waku's rendered HTML; use `createRoot` for a fallback shell without it.
- For purely client-side rendering, use `createRoot(document).render(rootElement)`.

A standard bootstrap looks like this:

```tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import {
  Root_UNSTABLE as Root,
  Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';

const rootElement = (
  <StrictMode>
    <Root>
      <Slot id="App" />
    </Root>
  </StrictMode>
);

if ((globalThis as any).__WAKU_HYDRATE__) {
  hydrateRoot(document, rootElement);
} else {
  createRoot(document).render(rootElement);
}
```

#### `Slot`

`Slot` renders a server element by RSC ID.

```tsx
import { Slot_UNSTABLE as Slot } from 'waku/minimal/client';
```

```tsx
<Root>
  <Slot id="App" />
</Root>
```

Rules:

- `id` must match a key returned from `renderRsc(...)`.
- Missing keys throw `Invalid element: <id>`.
- `undefined` is not allowed. If an element is intentionally empty, return `null`.
- `Slot` must be rendered under `Root`.

#### `Children`

`Children` lets a server element render the client children passed to `Slot`.

```tsx
import { Children_UNSTABLE as Children } from 'waku/minimal/client';
```

Server:

```tsx
return renderRsc({
  App: (
    <App>
      <Children />
    </App>
  ),
});
```

Client:

```tsx
<Slot id="App">
  <h3>A client element</h3>
</Slot>
```

This is useful for nested layouts and composition patterns where the server tree decides where client-provided children should appear.

#### Reading Elements

`useElementsPromise` returns the enclosing Root's current element-map promise.
Read it with React's `use`; the component suspends while the record is pending:

```tsx
import { use } from 'react';
import { useElementsPromise_UNSTABLE as useElementsPromise } from 'waku/minimal/client';

const ElementIds = () => {
  const elements = use(useElementsPromise());
  return <p>{Object.keys(elements).join(', ')}</p>;
};
```

The record is read-only. Use `combineElements` to construct a new map while
preserving its etags, then pass it to `mergeElements` to update the Root.

#### Refetching

The Minimal API exposes fetching and merging separately. Define a hook for the behavior your application needs:

```tsx
import { useCallback, useTransition } from 'react';
import {
  useFetchRsc_UNSTABLE as useFetchRsc,
  useMergeElements_UNSTABLE as useMergeElements,
} from 'waku/minimal/client';

const useRefetch = () => {
  const fetchRsc = useFetchRsc();
  const mergeElements = useMergeElements();
  return useCallback(
    (rscPath: string, rscParams?: unknown) =>
      mergeElements(fetchRsc(rscPath, rscParams)),
    [fetchRsc, mergeElements],
  );
};

const Counter = () => {
  const [isPending, startTransition] = useTransition();
  const refetch = useRefetch();

  const handleClick = (count: number) => {
    startTransition(async () => {
      await refetch('InnerApp=' + count);
    });
  };

  return (
    <button onClick={() => handleClick(1)} disabled={isPending}>
      Refetch
    </button>
  );
};
```

Important behavior:

- `refetch(...)` requests a new RSC payload for the given `rscPath` and optional `rscParams`.
- The returned payload is merged into the current element map by key.
- Each slot's etag stays with the element map Minimal made it in. Passing a map as `unstable_base` to `fetchRsc` sends its etags, so the server can skip the slots it holds. A map made by `combineElements` keeps them; a copy made with a spread or `Object.assign` does not.
- If you return only `InnerApp`, previously rendered elements such as `App` stay mounted.
- If the refetch fails, the existing element map stays in place.

With `unstable_base`, `fetchRsc` sends the base's validators and returns only
the response entries. Omitted slots are not filled from the base. Passing this
patch to `mergeElements` preserves the Root's queued values and removals.
For a standalone snapshot, combine the retained base and response explicitly:

```tsx
import { unstable_combineElements as combineElements } from 'waku/minimal/client';

const patch = await fetchRsc(rscPath, rscParams, {
  unstable_base: retainedElements,
});
const snapshot = combineElements(retainedElements, patch);
```

Do not merge this request-time snapshot into a Root that may have queued other
updates. Merge the patch with its latest elements.

#### Custom Merges

The optional second argument to `mergeElements` chooses the Root's next element
promise. It receives the latest queued promise, including updates that have not
rendered yet, and the incoming payload promise:

Use `startTransition` when merging pending work to retain visible content.
An urgent merge can show the nearest Suspense fallback if an earlier queued
elements promise is still pending.

```tsx
mergeElements(response, (prevPromise, patchPromise) =>
  Promise.all([prevPromise, patchPromise]).then(([prev, patch]) =>
    combineElements(prev, combineElements(patch, clientElements)),
  ),
);
```

Return `previous` directly to leave the Root unchanged without introducing a
pending promise. The callback must be pure; React can replay it with a different
queued predecessor. Minimal reuses its result when the predecessor is the same.

The incoming promise passed to the callback recovers a failed payload to an
empty record. The promise returned by `mergeElements` is the original payload
promise, so callers can handle its rejection. It does not wait for the custom
merge or for React to render the update. To apply client entries only on success,
transform the response before merging:

```tsx
mergeElements(
  response.then((received) => combineElements(received, clientElements)),
);
```

Use `combineElements` when retaining server entries so their etags follow their
values. Minimal does not impose an eager rendering or stale-while-revalidate
policy; Waku Router implements instant navigation with the merge callback.

#### Development Reloads

Development RSC reloads refetch the Root's initial `rscPath` and `rscParams` by
default. Fetching and merging a different input does not change that target.

`useRegisterRscReloadListener_UNSTABLE` returns a registrar for the enclosing
Root. Register a callback that fetches and merges the current input with
`{ replace: true }` to refresh that input after an edit. Without `replace`,
listeners run alongside the Root's refetch. The registrar returns an unregister
function for effect cleanup and is a no-op in production.

#### Request enhancers

Pass a fixed array of `Unstable_RscEnhancer` functions to `Root_UNSTABLE.rscEnhancers`. An enhancer can rewrite inputs, wrap the supplied transport, or transform decoded results. The last array entry is outermost: `[A, B]` sends requests through `B → A → server` and returns results through `server → A → B`.

Configure the array in your [client entry](/create-pages#client-entry-point):

```tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import {
  Root_UNSTABLE as Root,
  Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';
import type { Unstable_RscEnhancer } from 'waku/minimal/client';

const createLocaleEnhancer =
  (getLocale: () => string): Unstable_RscEnhancer =>
  (next) =>
  (path, params, options) =>
    next(path, params, {
      ...options,
      fetch: (input, init) => {
        const headers = new Headers(
          init?.headers ??
            (input instanceof Request ? input.headers : undefined),
        );
        headers.set('accept-language', getLocale());
        return options.fetch(input, { ...init, headers });
      },
    });

const enhancers = [createLocaleEnhancer(() => navigator.language)];
const app = (
  <StrictMode>
    <Root rscEnhancers={enhancers}>
      <Slot id="App" />
    </Root>
  </StrictMode>
);
if (globalThis.__WAKU_HYDRATE__) {
  hydrateRoot(document, app, defaultRootOptions);
} else {
  createRoot(document, defaultRootOptions).render(app);
}
```

- Treat the array as immutable and keep its reference stable across renders, including retries before the first commit. Create it before rendering React, as above: `useState` and `useMemo` inside an initially suspending component can be discarded on retry. Root copies the array for its mounted pipeline; remount with a new array to change it. Enhancers can read live state on every request without changing their membership.
- The configured pipeline is available before descendant layout effects. `options.type` is `rsc` for a payload fetch and `call` for a server function call. Forward `options`, including the supplied `fetch` and abort signal.
- Results contain `{ elements, value }`, where `value` is a server function's return value. Elements exclude Minimal's reserved keys. Use `unstable_combineElements` to change an element map while preserving its etags; a spread loses validators and prevents the server from skipping retained slots.
- Client bootstrap fetches run the configured enhancers during render. Descendants can suspend on the result, showing an enclosing Suspense fallback. The bootstrap cache deduplicates Suspense retries by the initial path, params reference, and enhancer-array reference; separate Roots with the same cache identity may share a request. Use distinct stable enhancer arrays when independent bootstrap requests are required. If a bootstrap request fails before Root commits, resetting an enclosing error boundary with the same cache identity may reuse the rejected promise.
- A streamed hydration payload bypasses enhancers: its request began before the client Root existed. Enhancers also run for subsequent Root-bound fetches, server actions received by that Root, and its development reloads. A bare server action goes to the Root mounted last, which need not be the one containing its caller.

For the file-based Router, create the same stable array before rendering React and pass it as `<Router unstable_rscEnhancers={enhancers} />` in `src/waku.client.tsx`. These enhancers form the inner part of Router's pipeline; its private HTTP redirect and action-routing enhancers wrap them. They can delay or transform results before Router handles them. A decoded-result cache in this inner list does not retain metadata reconciled by Router. Minimal's array has no private Router layers, so custom routers own their full composition.

##### Migrating effect-based integrations

In rc.4, `useRegisterRscEnhancer_UNSTABLE` remains available as a deprecated one-argument registrar returning cleanup. Legacy registrations are Root-scoped and always inside configured enhancers; later registrations wrap earlier registrations. Each request snapshots its sequence, so cleanup affects future requests only. Effect registration retains its timing limitations. There is no numeric priority argument.

Move pipeline installation to the client entry and keep subscriptions, atom discovery, and HMR listeners in companion components. For example, a future `waku-jotai` integration factory could expose a per-app store, a stable enhancer, and a Provider sharing that store:

```tsx
// src/waku.client.tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import { Router } from 'waku/router/client';
import { createJotaiIntegration } from 'waku-jotai/router/client';

const jotai = createJotaiIntegration();
const enhancers = [jotai.rscEnhancer];
const app = (
  <StrictMode>
    <jotai.Provider>
      <Router unstable_rscEnhancers={enhancers} />
    </jotai.Provider>
  </StrictMode>
);
if (globalThis.__WAKU_HYDRATE__) {
  hydrateRoot(document, app, defaultRootOptions);
} else {
  createRoot(document, defaultRootOptions).render(app);
}
```

This is a proposed ecosystem API: current `waku-jotai` does not export `createJotaiIntegration`. Its existing hook-based integration remains supported in rc.4. Minimal applications would pass the same integration's enhancer to Root's `rscEnhancers` prop.

The companion must publish discovered atom IDs to the runtime captured by the enhancer, using the same Jotai store as application components. Each request reads current values. Installation does not guarantee that asynchronous atom discovery has finished: unknown paths must pass through unchanged. Router prefetch keys also do not include atom values, so prefetched results can become stale when values change; declarative installation does not solve cache invalidation.

The registration hook is scheduled for removal in rc.5, after a migrated `waku-jotai` release and migration guidance are available. Other hook consumers must migrate before upgrading to rc.5.

### Common Patterns

#### Dynamic SSR without prerendering

Use `renderHtml(...)` for document requests and leave `handleBuild` empty:

```tsx
export default adapter({
  handleRequest: async (input, { renderRsc, renderHtml }) => {
    if (input.type === 'rsc') {
      return renderRsc({ App: <App name={input.rscPath || 'Waku'} /> });
    }
    if (input.type === 'http' && input.pathname === '/') {
      return renderHtml(
        await renderRsc({ App: <App name="Waku" /> }),
        <Slot id="App" />,
        { rscPath: '' },
      );
    }
    return null;
  },
  handleBuild: async () => {},
});
```

#### Custom API endpoints

Use `input.type === 'http'` and return a `Response` directly:

```tsx
if (input.type === 'http' && input.pathname === '/api/hello') {
  return new Response('world');
}
```

#### Server functions

Server functions typically return their result with the `value` option, and may optionally return updated server elements in the same payload:

```tsx
if (input.type === 'call') {
  const value = await input.fn(...input.args);
  return renderRsc({ App: <App name="Updated" /> }, { value });
}
```

#### Server actions with progressive enhancement

Server actions often return HTML rather than a bare RSC payload so that the same flow works with or without JavaScript:

```tsx
if (input.type === 'http' && input.pathname === '/') {
  const result = input.tryAction ? await input.tryAction() : undefined;
  const formState = result?.action ? result.formState : undefined;
  return renderHtml(
    await renderRsc({ App: <App name="Waku" /> }),
    <Slot id="App" />,
    {
      rscPath: '',
      formState,
    },
  );
}
```

### Pitfalls

- `renderHtml(...)` consumes its stream. Call `tee()` if you also need to emit that same payload as a file.
- `Slot` IDs must exactly match the keys returned by `renderRsc(...)`.
- `undefined` is invalid for slot values. Use `null` for an intentionally empty element.
- `rscPath2pathname(...)` should be used instead of hardcoding RSC payload file names.
- `handleBuild` emits nothing unless you explicitly call `generateFile(...)` or `generateDefaultHtml(...)`.
- Returning `'fallback'` is not the same as returning `null`. `'fallback'` explicitly asks Waku to generate the fallback HTML response.
- The client bootstrap for SSR uses `hydrateRoot(document, ...)`, not `createRoot(document.getElementById('root')!)`.
- This API is intentionally low-level. If you find yourself rebuilding routing conventions, `waku/router` is probably the better fit.

### Adapter Authors

Adapter authors own server entries, request processing, and build processing.
`waku/adapter-builders` exports `Unstable_ServerEntry` for entries authored
directly. Minimal applications normally pass their handlers to an adapter.

For adapter implementation details, see [Adapter Authoring](/guides/adapter-authoring).

### Examples

- [Minimal SSR and prerendered root route](https://github.com/wakujs/waku-examples/tree/main/minimal-api/basic)
- [Using `Children` to compose client content into a server tree](https://github.com/wakujs/waku-examples/tree/main/minimal-api/promise)
- [Server functions returning a value and updated elements](https://github.com/wakujs/waku-examples/tree/main/minimal-api/functions)
- [Nested slots, `useRefetch`, `runWithRequest`, and fallback HTML generation](https://github.com/wakujs/waku-examples/tree/main/minimal-api/nesting)
- [Server actions and `formState`](https://github.com/wakujs/waku-examples/tree/main/minimal-api/form)
- [Custom API endpoints alongside minimal SSR](https://github.com/wakujs/waku-examples/tree/main/minimal-api/api)
- [SPA-style build using `generateDefaultHtml`](https://github.com/wakujs/waku-examples/tree/main/minimal-api/spa)

---

### Guides Monorepo (`docs/guides/monorepo.mdx`)

---
slug: monorepo
title: Waku in a Monorepo Setup
description: Avoid duplicate React installs and common workspace issues.
category: Advanced Setup
order: 10
---

## Avoid Duplicate React Installs

The most common Waku monorepo problem is loading more than one copy of React. It can happen when a workspace package installs its own `react` dependency instead of using the app's copy.

Typical symptoms include errors like:

```txt
TypeError: Cannot read properties of null (reading 'use')
```

or React hook errors that only happen when importing a component from another workspace package.

For local workspace packages, prefer declaring React packages as peer dependencies. For example, a shared UI package can keep React available for local development without bundling its own runtime copy:

```json
{
  "peerDependencies": {
    "react": "latest",
    "react-dom": "latest"
  },
  "devDependencies": {
    "react": "latest",
    "react-dom": "latest"
  }
}
```

The Waku app should keep the actual runtime dependencies:

```json
{
  "dependencies": {
    "react": "latest",
    "react-dom": "latest",
    "react-server-dom-webpack": "latest",
    "waku": "latest"
  }
}
```

## Dedupe React in Vite

If your package manager still resolves multiple React copies, configure Vite through `waku.config.ts`:

```ts
import { defineConfig } from 'waku/config';

export default defineConfig({
  vite: {
    resolve: {
      dedupe: ['react', 'react-dom'],
    },
  },
});
```

This asks Vite to resolve `react` and `react-dom` from a single location when bundling linked workspace packages.

Waku depends on React, React DOM, and React Server DOM Webpack versions that are meant to work together. In a workspace, keep those versions aligned across apps and packages before debugging Waku-specific behavior.

## Shared Packages

Shared packages imported by a Waku app should publish or expose code that Vite can process. For TypeScript or JSX source packages, make sure the package's `exports` point to files your app can import, and avoid importing server-only modules from client components.

If a shared package has a file that uses React client APIs such as `useState` or `useEffect`, that file still needs a `'use client'` directive when it can be imported from a server component.

See [issue #676](https://github.com/wakujs/waku/issues/676) for the original duplicate React report.

---

### Guides Navigation Prefetching (`docs/guides/navigation-prefetching.mdx`)

---
slug: navigation-prefetching
title: Navigation and Prefetching
description: How Waku navigates between routes, caches static RSC payloads, prefetches likely next pages, and reveals cached shells instantly.
category: Routing and Navigation
order: 10
tags: [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:

```tsx
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.

```tsx
'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 URL strings as `router.push()`. Use a local href factory for typed params and search:

```tsx
import { unstable_createRouteHref as createRouteHref } from 'waku/router';

const post = createRouteHref({ path: '/posts/[slug]' });

router.prefetch(post({ params: { slug } }));
```

For navigating to dynamic routes with typed params, see [Typed Routes](/guides/typed-routes).

## Link Prefetching

`<Link>` also has experimental prefetch helpers for common interaction patterns:

```tsx
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>`:

```tsx
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:

```tsx
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`:

```tsx
'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 `<ViewTransition>` therefore does not animate that commit. When the shell is not cached, navigation waits for the response and commits the destination in a transition; `useNavigationStatus_UNSTABLE()` reports `pending` for that wait. When the cache commit is available, the shell itself is the pending state.

When an instant navigation paints a cached shell, the dynamic slots the client already had lose their etags, including slots from earlier pages and slots that opted in with [`unstable_getEtag`](/guides/explicit-caching#etags-skipping-unchanged-payloads). The next regular navigation or server action that includes such a slot has the server render and send it again, and the slot gets its etag back. Prefetches are not affected, because they send the etags of the prefetched response. Instant navigation suits dynamic parts that change often or are cheap to render. If your app relies on etags to skip costly dynamic slots, pair them with `cache.rsc` so the server replays the cached subtree instead of rendering it again, or do not use `unstable_instant`.

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

```tsx
'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>
  );
};
```

```tsx
'use client';

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

export const PendingLink = ({ children, ...props }: LinkProps) => (
  <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:

```tsx
'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: rendering can still suspend, and a render-time redirect or 404 can start another navigation after the response is accepted. Observe the route fields above when you need the destination that was committed. To learn that a newer navigation cancelled yours while its response was pending, pass the experimental `unstable_onSuperseded` option to `push` or `replace`. For an instant navigation, its shell may already have painted. An accepted response is not cancelled merely because its components have not finished rendering.

## View Transitions

Waku commits a navigation inside a React transition, so animating one needs nothing from the router: wrap the part of the tree you want animated in React's `<ViewTransition>` and React drives the browser View Transitions API for you.

```tsx
'use client';

import { ViewTransition } from 'react';
import type { ReactNode } from 'react';

export const AnimatedPage = ({ children }: { children: ReactNode }) => (
  <ViewTransition>{children}</ViewTransition>
);
```

Call `addTransitionType` alongside a `router.push()` to vary the animation per navigation. A browser without the View Transitions API just applies the update without animating it.

The prefetch and instant-navigation props in this guide are experimental and may change.

---

### Guides No Ssr (`docs/guides/no-ssr.mdx`)

---
slug: no-ssr
title: No SSR and Static Fallbacks
description: Disable document SSR when a route should render from Waku's fallback HTML shell.
category: Routing and Navigation
order: 40
tags: [Experimental]
---

## When to Disable Document SSR

By default, Waku renders an HTML document for page requests. This gives the browser useful HTML before client-side JavaScript loads.

Disabling document SSR changes that behavior. The server returns Waku's fallback HTML shell, and the client router fetches the RSC payload to render the route in the browser.

This can be useful for:

- authenticated app areas where HTML content is not useful before client state loads
- browser-only experiences where server-rendered document content would be misleading
- routes where you intentionally prefer a static shell over per-request document rendering

It is usually a poor fit for public content pages, marketing pages, documentation, or any route where SEO and initial HTML content matter.

## Disable SSR for a Page

With the file-system router, return `unstable_disableSSR` from the page's `getConfig`:

```tsx
// src/pages/app.tsx
export default async function AppPage() {
  return <h1>App</h1>;
}

export const getConfig = async () => {
  return {
    render: 'static',
    unstable_disableSSR: true,
  } as const;
};
```

With `createPages`, use `unstable_disableSSR` on a page:

```tsx
// src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';
import { AppPage } from './templates/app-page';

const pages = createPages(async ({ createPage }) => [
  createPage({
    render: 'static',
    path: '/app',
    component: AppPage,
    unstable_disableSSR: true,
  }),
]);

export default adapter(pages);
```

`unstable_disableSSR` currently uses an `unstable_` name and may change. See the [`createPages` reference](/create-pages#disabling-ssr) for the API-level details.

## Disable SSR from a Custom Server Entry

If you own `src/waku.server.tsx`, you can return `'fallback'` for document requests. This is useful when using the file-system router but deciding at the server-entry level that some or all document requests should skip SSR.

The simplest whole-app version looks like this:

```tsx
// src/waku.server.tsx
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/default';

const router = fsRouter(import.meta.glob('./pages/**/*.tsx'));

export default adapter({
  handleRequest: async (input, utils) => {
    if (input.type === 'http') {
      return 'fallback';
    }
    return router.handleRequest(input, utils);
  },
  handleBuild: (utils) => {
    return router.handleBuild(utils);
  },
});
```

You can also make this conditional:

```tsx
handleRequest: async (input, utils) => {
  if (input.type === 'http' && input.pathname.startsWith('/app')) {
    return 'fallback';
  }
  return router.handleRequest(input, utils);
};
```

In production, a static page is served from the HTML the build prerendered for it, before `handleRequest` runs, so `'fallback'` only reaches dynamic pages. Give a static page [`unstable_disableSSR`](#disable-ssr-for-a-page) as well, and the build writes the fallback shell for it instead.

## What the Fallback Contains

The fallback is an HTML shell that can boot the Waku client. It is not the same as a fully rendered page.

When a route uses the fallback:

- the initial document does not contain that route's rendered content
- the client still needs JavaScript to render the route
- the RSC payload is still requested and rendered by the client router

## No SSR vs Static Rendering

Static rendering and no SSR solve different problems.

Static rendering prerenders route output at build time. The generated HTML can contain the page content.

No SSR skips document rendering and serves a fallback shell. It can reduce server document work, but it also removes the initial page content from the HTML document.

Disabling SSR does not turn every Waku feature into pure static output. Dynamic server behavior still needs a runtime that can serve the relevant RSC requests and APIs.

## Verify the Result

After building or starting the app, inspect the page source for a route with SSR disabled. You should see the Waku shell rather than the route's full rendered content. Then load the page normally in the browser and verify that the client renders the route after JavaScript starts.

---

### Guides Performance Tracks (`docs/guides/performance-tracks.mdx`)

---
slug: performance-tracks
title: Server Components Performance Tracks
description: View React's Server Components performance tracks for a Waku app in Chrome DevTools, and what to check when component spans do not appear.
category: Advanced Setup
order: 30
---

## What They Are

React can emit [Server Components performance tracks](https://react.dev/reference/dev-tools/react-performance-tracks) — timing for how long each server component took to render, shown as a dedicated **Server Components** track in the Chrome DevTools Performance panel. Waku wires this up in the dev server, so there is nothing to configure.

## Viewing the Track

1. Run your app in development with `waku dev`.
2. Open the page in Chrome and open DevTools.
3. Go to the **Performance** panel and click **Record and reload**.
4. Once the page has finished rendering, stop the recording.
5. Find the **Server Components** track. Each server component appears as a named entry; on React 19.3 or newer the entries also carry render durations, and nested `await`s show up as a staircase.

Both the initial SSR render and later client navigations contribute entries.

## If Component Spans Do Not Appear

This feature relies on React development internals, which Waku patches so that its RSC payload keeps the debug information React needs to build the track. That patch targets a specific React version, so mismatched versions are the usual cause of missing spans:

- The transform matches React's development build by an exact source string, so it is tied to the React version. It is currently verified against `react`, `react-dom`, and `react-server-dom-webpack` at `19.2.8` (Waku's own dependency, checked by an automated test). Use that version for all three packages; a different React release is the usual cause of missing spans.
- On React 19.2.x each server component shows as a named marker in the track; the render **durations** (the staircase spans) require React 19.3 or newer.

This is a development-only, best-effort aid; it has no effect on production builds.

---

### Guides React Compiler (`docs/guides/react-compiler.mdx`)

---
slug: react-compiler
title: React Compiler in Waku
description: How new Waku projects enable React Compiler, and what to check when upgrading an older app.
category: Advanced Setup
order: 20
---

## Default Template Setup

New Waku projects created with `npm create waku@latest` already enable React Compiler in `waku.config.ts`.

The default TypeScript template includes:

```ts
import babel from '@rolldown/plugin-babel';
import tailwindcss from '@tailwindcss/vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import { defineConfig } from 'waku/config';

export default defineConfig({
  vite: {
    plugins: [
      tailwindcss(),
      react(),
      babel({ presets: [reactCompilerPreset()] }),
    ],
  },
});
```

If you created a new project from the current template, there is nothing else to install.

## Upgrading an Existing Project

Older Waku projects may not have React Compiler configured. To enable it, install the Vite React plugin and React Compiler Babel plugin:

```bash
npm install -D @vitejs/plugin-react babel-plugin-react-compiler @rolldown/plugin-babel
```

Then add the React Compiler preset to your Waku config:

```ts
import babel from '@rolldown/plugin-babel';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import { defineConfig } from 'waku/config';

export default defineConfig({
  vite: {
    plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
  },
});
```

If your project already has other Vite plugins, keep them in the same `plugins` array.

## Learn More

For React-specific behavior, compatibility, diagnostics, and configuration options, see the [official React Compiler docs](https://react.dev/learn/react-compiler).

---

### Guides Redirect Maps (`docs/guides/redirect-maps.mdx`)

---
slug: redirect-maps
title: Redirect Maps
description: How to configure redirect maps in front of a Waku server.
category: Routing and Navigation
order: 20
tags: [Experimental]
---

## External Redirect Maps

This guide is for redirect maps that sit in front of the Waku server, such as middleware, reverse proxies, CDNs, or hosting-platform redirect rules.

When a Waku app uses the client router, a navigation usually fetches both:

- the browser route, such as `/old`
- the RSC payload for the same route, at a separate url

Both have to be redirected. If you redirect only `/old`, a direct visit works while client-side navigation still lands on the old route.

Inside the app, `unstable_parseRouterRequest` reads either url as the route it addresses, and `unstable_formatRouterRequest` builds the matching url for the destination, so middleware never has to spell the RSC url out. Outside the app — a CDN or a hosting platform's redirect rules — there is no way to call into Waku, so those rules do have to name the url. With Waku's default config:

- `basePath` is `/`
- `rscBase` is `RSC`
- the route `/old` maps to the RSC path `/RSC/R/old.txt`

> The `/RSC/` file naming convention is [subject to change](https://github.com/wakujs/waku/discussions/929#discussioncomment-10825975) in future versions of Waku. Prefer the middleware approach below, which does not depend on it.

If you customize `basePath` or `rscBase`, adjust any hand-written rules accordingly.

For application-level redirects implemented inside Waku routes or server actions, see [Redirects and Not Found](/guides/redirects-and-not-found) instead. That is a separate use case from the redirect-map approach described here.

### Redirect via middleware

Create a new middleware file in `src/middleware/`. Waku automatically discovers and loads middleware from this directory.

```typescript
// ./src/middleware/redirects.ts
import type { MiddlewareHandler } from 'hono';
import {
  unstable_formatRouterRequest as formatRouterRequest,
  unstable_parseRouterRequest as parseRouterRequest,
} from 'waku/router/server';

const redirects: Record<string, string> = {
  '/old': '/new',
  // ... add more redirects here
};

const redirectsMiddleware = (): MiddlewareHandler => async (c, next) => {
  // Reads the browser route and the RSC payload request the same way.
  const parsed = parseRouterRequest(c.req.raw);
  const destination =
    parsed?.type === 'route' ? redirects[parsed.path] : undefined;
  // Rebuilds the same kind of url, so client-side navigation gets a payload
  // and a direct visit gets the page. `null` means this request cannot be
  // rewritten without losing state, so leave it alone.
  const location = destination
    ? formatRouterRequest(c.req.raw, destination)
    : null;
  if (location) {
    c.res = new Response(null, {
      status: 302,
      headers: { location: location.toString() },
    });
    return;
  }

  return await next();
};

export default redirectsMiddleware;
```

Matching on `parsed.path` covers both requests with one entry per route, and keeps the redirect map readable as a list of routes.

> In a production build the adapter serves statically generated files before middleware runs, so a middleware redirect does not fire for a route rendered with `render: 'static'`. Redirect those from the hosting environment instead, or make the route dynamic.

Note the `type` check: an action dispatched from the client is addressed by function id and carries no route, so a path rule can never match it. A progressively enhanced form submitted without JavaScript is the other way around — it posts to the route's own url, so it parses as `'route'` and a rule for that path does apply to it. Either way, a path rule is a redirect, not an authorization boundary; see [Authentication](/guides/integrations/authentication) for where to put checks that must not be bypassed.

### Redirect via hosting environment

A redirect map outside the app cannot call into Waku, so these rules name both urls explicitly and depend on the `/RSC/` convention noted above.

This very much depends on the hosting environment you are using. For example, on [Vercel](https://vercel.com/docs/projects/project-configuration#redirects) you can use the `vercel.json` file to define redirects.

```json
{
  "redirects": [
    { "source": "/old", "destination": "/new", "permanent": true },
    {
      "source": "/RSC/R/old.txt",
      "destination": "/RSC/R/new.txt",
      "permanent": true
    }
  ]
}
```

[Netlify](https://docs.netlify.com/routing/redirects/#syntax-for-the-redirects-file) and [Cloudflare Pages](https://developers.cloudflare.com/pages/configuration/redirects/) will respect a `_redirects` file that you can place in the `public` folder:

```
/old /new 301
/RSC/R/old.txt /RSC/R/new.txt 301
```

For app-level redirects inside page code or server actions, use [Redirects and Not Found](/guides/redirects-and-not-found) instead of external redirect maps.

---

### Guides Redirects And Not Found (`docs/guides/redirects-and-not-found.mdx`)

---
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 <h1>Welcome, {user.name}</h1>;
}
```

`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 (
    <form
      action={async (formData) => {
        'use server';

        const post = await createPost({
          title: String(formData.get('title') || ''),
        });

        redirect(postHref({ params: { slug: post.slug } }), 303);
      }}
    >
      <input name="title" />
      <button>Create post</button>
    </form>
  );
}
```

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 <article>{post.title}</article>;
}
```

## 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 (
    <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 `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.

---

### Guides Request Context (`docs/guides/request-context.mdx`)

---
slug: request-context
title: Request Context
description: Read request headers and pass request-scoped data to server components and server functions.
category: Runtime and Middleware
order: 10
---

## When to Use Request Context

Waku provides request-scoped context for server code. Use it when a server component or server function needs information from the current request. Middleware runs before the render scope is established, so middleware reads the request from its Hono context instead.

## Read the Current Request

Use `getRequest` in server-only code to access the current `Request`:

```tsx
import { getRequest } from 'waku/router/server';

export default function Page() {
  const userAgent = getRequest().headers.get('user-agent') ?? 'unknown';

  return <p>User agent: {userAgent}</p>;
}

export const getConfig = async () => ({ render: 'dynamic' }) as const;
```

Header names are case-insensitive, and `headers.get()` returns `null` for a header the request does not have.

`getRequest()` throws if request context is not available. Call it during request handling, not in module scope.

`getRequest().url` is the URL the request was sent to. After a client navigation, that is Waku's RSC URL (such as `/RSC/R/about.txt`), not the page URL. A page receives its params and `query` as props; elsewhere, read the route with `unstable_parseRouterRequest(getRequest())`. A server function called from the client sends its own request, which `unstable_parseRouterRequest` reports only as `{ type: 'action' }`, so pass the function any route data it needs as arguments.

`getRequest()` returns the original incoming request. Inside an API route handler, the handler's own `req` argument may have a rewritten URL (the route path with any prefix stripped), so `req.url` and `getRequest().url` can differ. Use the handler argument for routing-specific values and for the body, which it shares with `getRequest()`.

`getRequest` is exported from `waku/router/server` and is available wherever the router runs your code: renders (request and build), server functions, API route handlers, interceptors, and a custom router's `resolve`. In the Minimal API, use `input.req` from `handleRequest` instead, and bring your own `AsyncLocalStorage` if you need it deeper in the tree.

Components rendered as `static` are prerendered at build time, so do not read `getRequest`, headers, or interceptor-seeded state in a static page, layout, or slice; those values would be baked into the prerendered output. Read request-scoped data only in `dynamic` rendering. File-system pages, layouts, and slices are static by default, so the page examples in this guide export `getConfig` with `render: 'dynamic'`. Only the file-system router reads `getConfig`; with `createPages`, pass `render: 'dynamic'` to `createPage` instead. (During a static route build `getRequest().url` is the route's path, but a static slice build uses a placeholder root request that does not identify the slice.)

## Pass Data with an Interceptor

Waku's context is request-only. To share derived, request-scoped data with server components and server functions, bring your own `AsyncLocalStorage` and seed it from a handler interceptor. An interceptor wraps each render in both the request and build phases, so `getRequest` is available inside it.

Define your own store:

```ts
// src/lib/request-data.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export type RequestData = {
  country?: string;
};

const store = new AsyncLocalStorage<RequestData>();

export const getRequestData = (): RequestData => store.getStore() ?? {};

export const runWithRequestData = <T>(
  data: RequestData,
  fn: () => Promise<T>,
) => store.run(data, fn);
```

In managed mode (no `waku.server.tsx`), seed the store from an interceptor in `src/pages/_interceptors/request-data.ts`:

```ts
import type { HandlerInterceptor } from 'waku/router/server';
import { getRequest } from 'waku/router/server';
import { runWithRequestData } from '../../lib/request-data.js';

const requestDataInterceptor: HandlerInterceptor = (next) => {
  const country = getRequest().headers.get('cf-ipcountry') ?? 'unknown';
  return runWithRequestData({ country }, next);
};

export default requestDataInterceptor;
```

With a custom `waku.server.tsx` using `createPages`, register the same logic through `createInterceptor`:

```ts
import { getRequest } from 'waku/router/server';
import { runWithRequestData } from './lib/request-data.js';

createPages(async ({ createPage, createInterceptor }) => {
  createInterceptor((next) => {
    const country = getRequest().headers.get('cf-ipcountry') ?? 'unknown';
    return runWithRequestData({ country }, next);
  });
  return [
    // ...pages...
  ];
});
```

Then read that data from server code:

```tsx
import { getRequestData } from '../lib/request-data.js';

export default function Page() {
  const { country } = getRequestData();

  return <p>Country: {country ?? 'unknown'}</p>;
}

export const getConfig = async () => ({ render: 'dynamic' }) as const;
```

If the value is just a request header, you can also read it from `getRequest().headers` in the component without a store. Reach for an interceptor when you want to compute the value once per request and share it across the render.

## Cookies

Waku does not currently provide a dedicated cookie API. Read request cookies from `getRequest().headers` in server code, and use middleware to set response cookies after rendering.

Read a request cookie from server code with the `cookie` package:

```tsx
import * as cookie from 'cookie';
import { getRequest } from 'waku/router/server';

export default function Page() {
  const cookies = cookie.parseCookie(getRequest().headers.get('cookie') ?? '');

  return <p>Session: {cookies.sessionId ?? 'none'}</p>;
}

export const getConfig = async () => ({ render: 'dynamic' }) as const;
```

Set a response cookie from middleware, which owns the response:

```ts
// src/middleware/session.ts
import * as cookie from 'cookie';
import type { MiddlewareHandler } from 'hono';

const sessionMiddleware = (): MiddlewareHandler => {
  return async (c, next) => {
    const cookies = cookie.parseCookie(c.req.header('cookie') || '');
    const sessionId = cookies.sessionId;

    await next();

    if (c.res && sessionId) {
      const headers = new Headers(c.res.headers);
      headers.append(
        'set-cookie',
        cookie.stringifySetCookie({
          name: 'sessionId',
          value: sessionId,
          httpOnly: true,
          path: '/',
          sameSite: 'lax',
          secure: true,
        }),
      );
      c.res = new Response(c.res.body, {
        status: c.res.status,
        statusText: c.res.statusText,
        headers,
      });
    }
  };
};

export default sessionMiddleware;
```

Keep any request-scoped store small and request-specific. Do not store values that should outlive the request.

## Cloudflare Bindings

On Cloudflare Workers, import bindings from `cloudflare:workers`. Use Waku request context for request data, and Cloudflare's runtime APIs for environment bindings, D1, KV, and `waitUntil`.

```tsx
import { env } from 'cloudflare:workers'; // eslint-disable-line import/no-unresolved
import { getRequest } from 'waku/router/server';

export default async function Page() {
  const country = getRequest().headers.get('cf-ipcountry');
  const store = country
    ? await env.DB.prepare('SELECT * FROM store WHERE country = ?')
        .bind(country)
        .first()
    : null;

  return <pre>{JSON.stringify(store, null, 2)}</pre>;
}

export const getConfig = async () => ({ render: 'dynamic' }) as const;
```

See [Run Waku on Cloudflare](/guides/cloudflare) for the full Cloudflare setup.

---

### Guides Rerender After Actions (`docs/guides/rerender-after-actions.mdx`)

---
slug: rerender-after-actions
title: Rerendering After Server Actions
description: Show a server action's changes without leaving the page, so useOptimistic does not snap back.
category: Routing and Navigation
order: 35
---

## An Action Renders Nothing by Default

When a server action settles, Waku returns its value and leaves the page as it was last rendered. After a mutation, that means the page shows stale data. It also undoes `useOptimistic`: React drops the optimistic value when the action settles, expecting the page to show the change by then, so the update appears and then disappears.

The API in this guide currently uses an `unstable_` name and may change.

## Rerender the Page the Action Came From

Call `unstable_rerenderRoute` with no arguments. Waku renders the route the action was called from into the action's response, so the page and the optimistic value settle on the same data.

```tsx
// src/pages/cart.tsx
import { unstable_rerenderRoute as rerenderRoute } from 'waku/router/server';
import { AddToCart } from '../components/add-to-cart';
import { addItem, getCart } from '../lib/cart';

export default async function CartPage() {
  const cart = await getCart();

  return (
    <AddToCart
      count={cart.items.length}
      addItem={async (formData: FormData) => {
        'use server';

        await addItem(String(formData.get('id')));
        rerenderRoute();
      }}
    />
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

```tsx
// src/components/add-to-cart.tsx
'use client';

import { useOptimistic } from 'react';

export const AddToCart = ({
  count,
  addItem,
}: {
  count: number;
  addItem: (formData: FormData) => Promise<void>;
}) => {
  const [optimisticCount, setOptimisticCount] = useOptimistic(count);

  return (
    <form
      action={async (formData) => {
        setOptimisticCount(optimisticCount + 1);
        await addItem(formData);
      }}
    >
      <p>{optimisticCount} items</p>
      <input type="hidden" name="id" value="42" />
      <button>Add to cart</button>
    </form>
  );
};
```

The page has to render dynamically for the rerender to read the new data.

If the user moves to another page while the action runs, Waku drops the rerender instead of taking them back to the page they left. The action still returns its value.

## Render a Specific Route

Pass a path, and optionally a query, to render that route instead:

```tsx
rerenderRoute('/cart', 'tab=saved');
```

If that is not the route on screen, the router moves to it. To send the user to another page after a mutation, a redirect is usually the better fit; see [Redirects and Not Found](/guides/redirects-and-not-found).

## Forms Without JavaScript

Without JavaScript, the form posts to the page and Waku renders that page after the action runs, so the document already includes the change. Calling `unstable_rerenderRoute()` there still works; it renders the same page.

## Avoid Rerendering From an Effect

Do not call `unstable_rerenderRoute()` in an action that a client component calls from an effect with the action in its dependencies:

```tsx
useEffect(() => {
  void track();
}, [track]);
```

Every rerender gives the client a new `track` function, which runs the effect again and calls the action again, in a loop. This is also why Waku does not rerender after every action by default.

## Router Requirement

The no-argument form needs a router on the client that tells the server which route the action came from. Waku's router does, and a router built on `waku/router/client-core` does by installing an `unstable_createActionRouting()` controller's enhancer on Root and connecting it with `useActionRouting_UNSTABLE(controller, callbacks)`. In an action that did not come through such a router, `unstable_rerenderRoute()` throws.

---

### Guides Router Fetch Strategy (`docs/guides/router-fetch-strategy.mdx`)

---
slug: router-fetch-strategy
title: Navigation and Prefetching Has Moved
description: The router fetch strategy guide has moved to Navigation and Prefetching.
hidden: true
---

## Use the New Guide

This guide has moved to [Navigation and Prefetching](/guides/navigation-prefetching).

---

### Guides Router Protocol (`docs/guides/router-protocol.mdx`)

---
slug: router-protocol
title: Router Protocol
description: The contract between defineRouter and the router client.
category: Low-level APIs
order: 25
---

## Scope

`unstable_defineRouter` and `waku/router/client` implement the two ends of this
protocol. A custom route resolver does not need to implement it: supply element
sources to `defineRouter`, as shown in [Custom Router](/guides/custom-router).

Read this guide when changing Router itself or implementing another client binding
with `waku/router/client-core`. A router with a different protocol can use the
[Minimal API](/guides/minimal-api) directly.

Minimal owns Flight transport, element storage, and etags. It does not interpret
Router's IDs or decide which URL to display. Router uses public Minimal APIs.
Its control entries are reserved by Router; Minimal treats them as application data.

## Requests and Slots

These are different identities:

- A URL has a pathname, query, and client-owned fragment.
- An RSC input identifies a route or an independently requested slice.
- A slot ID identifies content inside an element map.

Use `unstable_encodeRoutePath(pathname)` and `unstable_createRscParams(query)`
from `waku/router/client-core` for route requests. Fragments are not sent to the
server. Minimal handles the HTTP endpoint and its wire encoding.

| Slot ID                  | Meaning                                                            |
| ------------------------ | ------------------------------------------------------------------ |
| `root`                   | Document composition; its ID is shared across routes.              |
| `route:<pathname>`       | Route composition scoped to one pathname.                          |
| Other resolver-owned IDs | Content composed with `Slot`; IDs are shared wherever they appear. |
| `slice:<id>`             | A slice, bundled in a route or fetched independently.              |

The resolver calls its source `route`; `defineRouter` maps it to the path-scoped
slot. An immutable slot must have the same content for its lifetime. Path-scoping
lets immutable route compositions coexist without claiming that different URLs
have the same content. Merges can retain previously visited routes' slots.

## Response Metadata

Route responses also carry these entries. They are control data, not components
to render with `Slot`.

| Entry       | Contract                                                                                                                                           |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ROUTE`     | `[pathname, query]` of the route the server rendered, not necessarily the requested route.                                                         |
| `IS_STATIC` | Every source of that route is immutable, including sources omitted by matching etags. It does not mean HTML was emitted at build time.             |
| `HAS404`    | A custom `/404` route is available. Present when true.                                                                                             |
| `IS_ORIGIN` | An action used the no-argument `rerenderRoute()` form to refresh its request-time origin. The action enhancer consumes this marker before merging. |

Use `unstable_getRouteFromElements`, `unstable_isStaticFromElements`, and
`unstable_has404FromElements` from `waku/router/client-core` to read route
metadata. Independently fetched slices and actions that do not rerender a route
need not contain it. Neither the action return value nor Minimal's etags are
Router metadata.

These contracts describe server responses. The standard client also writes
`ROUTE` and `IS_STATIC` for cached and instant paints, so the live element map is
not necessarily a settled server response. The binding owns navigation intent,
errors, and history, and reconciles them with elements at commit boundaries.

An immutable route composition is a reusable shell, not proof that every slot it
renders is immutable. A shell can contain a mutable page. The client cache therefore
distinguishes `hasCachedShell` from `canReuseStaticRoute`.

## Partial Responses

Minimal can send the etags of a supplied `unstable_base` with a fetch. The server
omits slots whose validators match; fetching returns only response entries.
A response is consequently not guaranteed to be a complete renderable tree.
Callers retain omitted slots or combine a standalone snapshot explicitly.

Route metadata describes the resolved route even when its slots are omitted. For
example, a dynamic page whose etag matches is omitted, but `IS_STATIC` remains
false. Looking only at the returned slots would give the wrong reuse decision.

Use Minimal's `unstable_combineElements` when combining element maps. Object
spreads discard the validators associated with their values.

## Requested, Rendered, and Browser URLs

| Situation               | Requested route  | Rendered route | Browser URL after commit |
| ----------------------- | ---------------- | -------------- | ------------------------ |
| Ordinary navigation     | `/post?q=one`    | `/post?q=one`  | `/post?q=one`            |
| Eager internal redirect | `/old`           | `/new`         | `/new`                   |
| Custom not-found route  | `/missing?q=one` | `/404?q=one`   | `/missing?q=one`         |

An early internal redirect can be resolved on the server into the destination's
elements in the same response. `ROUTE` tells the client which destination landed.
Not every redirect can be resolved this way: document redirects leave the router,
and redirects thrown by streamed slots are handled by the client error boundary.

Similarly, a fetch can succeed before a streamed slot throws not-found. The
client uses `HAS404` to decide whether to follow `/404` or show its generic
not-found fallback. Follow decisions share loop detection and a navigation budget.
See [Redirects and Not Found](/guides/redirects-and-not-found) for SSR and streaming.

Fragments remain client-owned. Static route reuse also preserves the browser's
query instead of treating build-time route metadata as a query-changing redirect.
Reading `ROUTE` is therefore not enough to decide what to write to history.

For an HTTP redirect to a different static route, Router's private RSC enhancer
reads the destination query from the final response URL and corrects the decoded
route metadata for navigation and prefetch. It wraps the configured app enhancers,
so it observes the response before their fetch wrappers transform it. Custom
client bindings can implement the same reconciliation with Minimal's RSC enhancer
API.

A decoded-result cache in the configured app enhancers stores results before
Router reconciles them. Cache hits that bypass the network cannot recover redirect
queries from the response URL. When an enhancer fetches multiple responses, Router
uses the last response it sees; it does not identify which response supplied the
returned elements.

Once a static route is cached, navigation can reuse it without a request. Redirect
rules that must run for each query change require a dynamic route.

On failure, retained elements can describe an earlier route. The attempted URL
and error state belong to the client binding, not the server metadata.

## Navigation Hrefs and Search

`Link`, `push`, `replace`, `prefetch`, and `unstable_redirect` consume URL strings.
`unstable_createRouteHref` from `waku/router` binds a local pattern and optional
search codec to a function returning an app-base-free primitive string. Its
type-only brand records the normalized route pattern, not membership in a
finite static build. Construction requires no codec registry, provider, or
additional Flight metadata. See [Typed Routes](/guides/typed-routes).

Search interpretation belongs to the code supplying the codec: `createPages`
parses incoming page queries, and client search hooks parse the current query
using an explicit factory or `{ path, searchCodec }` descriptor. A custom
`defineRouter` resolver receives the query string and chooses how to parse it.
Generated `SearchCodecsConfig` types describe `PageProps.search`, not navigation.
Outbound construction calls `serialize` without reparsing its result; the
destination must independently validate its incoming query.

## Loading Without a Cache

`unstable_load` from `waku/router/client-core` accepts a fetch callback. It follows
fetch-time redirects and custom 404s, enforces the follow budget, and stops waiting
when cancelled. It does not require the Router cache or merge elements.

This hook binds the loader to the enclosing Minimal `Root` without a Router cache:

```tsx
import { useFetchRsc_UNSTABLE } from 'waku/minimal/client';
import {
  type Unstable_FetchRoute,
  type Unstable_LoadOptions,
  type Unstable_RouteProps,
  unstable_createRscParams,
  unstable_encodeRoutePath,
  unstable_load,
} from 'waku/router/client-core';

function useLoadRoute() {
  const fetchRsc = useFetchRsc_UNSTABLE();
  const fetchRoute: Unstable_FetchRoute = ({ route }, signal) =>
    fetchRsc(
      unstable_encodeRoutePath(route.path),
      unstable_createRscParams(route.query),
      { signal },
    );
  return (requested: Unstable_RouteProps, options: Unstable_LoadOptions) =>
    unstable_load(fetchRoute, requested, options);
}
```

Call `useLoadRoute()` while rendering your binding, then call its returned
function from a navigation handler:

```ts
const outcome = await loadRoute(requested, {
  signal: controller.signal,
  has404,
  settled,
  url: requestedUrl,
  follows,
});
```

Here `settled` identifies the route whose composition the binding retains for
reuse, which can be accepted before it paints. `follows` is zero for a new
navigation or the preceding count for a render-time follow. The callback receives
each attempt's route, intended browser URL, and follow count, plus the cancellation
signal. It can supply `unstable_base` to `fetchRsc` for etag reuse when the binding
retains the corresponding elements.

| Outcome    | Binding responsibility                                                     |
| ---------- | -------------------------------------------------------------------------- |
| `loaded`   | Reconcile response metadata and queue its elements with navigation intent. |
| `reused`   | Retain existing content and queue the requested route metadata and intent. |
| `external` | Leave the document for `url`, applying the binding's history policy.       |
| `failed`   | Present the error at the attempted URL.                                    |
| `aborted`  | Discard the result.                                                        |

A callback returns `undefined` only to reuse usable content already in this Root;
missing routes must reject with a not-found error. A hash-only fetch redirect back
to the settled RSC route also returns `reused`, avoiding an identical fetch.
`outcome.route` describes the final attempt, not necessarily the server-rendered
destination: reconcile `ROUTE` using the URL rules above. Streamed-slot errors
must continue the same follow budget through the binding's error boundary.
Each boundary dispatches a caught error's follow once; React effect replay does
not start another request. Separate boundaries handle a shared error
independently.

Bindings that want Router's cache policy call `useRouterCache_UNSTABLE()` during
rendering. Its `cache.load(requested, { ...options, base: elements })` wraps the
same loader with static reuse, prefetch, and optional response adoption. A
cache-free binding does not need to construct a cache-shaped adapter.
Network results contain response patches; omitted entries are not filled from
the request-time base. Cached prefetches return their stored element maps.
Bindings that read a complete snapshot can materialize a loaded result:

```ts
import { unstable_combineElements as combineElements } from 'waku/minimal/client';

const outcome = await cache.load(requested, { ...options, base });
if (outcome.type === 'loaded') {
  const snapshot = combineElements(base, outcome.elements);
}
```

Queue the response patch, not this request-time snapshot, when preserving later
action or client updates. `unstable_buildMergePatch` implements a snapshot-conflict
policy for custom bindings; the standard binding queues response patches directly.

If an instant paint has already merged an adopted response, the binding must not
merge it again. A follow fetch produces a new response requiring an ordinary
commit.

## Client Commits

The standard client keeps elements and navigation intent in one Minimal Root
update queue. The intent is stored under the client-only `ROUTER_STATE_ID` symbol;
it is neither a server response field nor another slot. Minimal merges it without
interpreting it.

| State                                     | Owner                      | Purpose                                                           |
| ----------------------------------------- | -------------------------- | ----------------------------------------------------------------- |
| Elements and attached Router state        | Minimal Root's React state | Select the content and URL intent that render together.           |
| Requested or accepted route               | Router ref                 | Cancel pending work and route action results until UI catches up. |
| Applied snapshot identity and destination | Router's layout effect     | Write history and scroll once for each committed intent.          |
| Fetch error                               | Router's React state       | Render the failure and clear it when another request starts.      |

Each request carries forward accepted RSC elements as its cache baseline,
which can be ahead of the last painted route. Accepted instant responses retain
their prefetch fallback and reuse the eager paint's navigation intent without
repeating history and scrolling. The eager merge represents its queued input and
prefetch fallback as pinned values or response-backed placeholders. A private
instant-state wrapper records represented slot IDs for each element snapshot,
while retaining the same navigation intent identity through React replays.
Acceptance backfills only unrepresented response entries and replaces the
wrapper with ordinary Router state. A later queued removal of a represented
slot stays removed even if the response returns it. Until that state commits,
their cache baseline makes no etag
claims because it can differ from the latest queued values. Static-route
eligibility is learned when the accepted instant state commits.
A metadata-only action dispatch makes no element-validator claims for its
destination. This baseline does not track every queued action or client merge;
response patches merge with the latest queued elements through Minimal's
update queue.
After a failure, refetch decisions use the retained request origin, not an
instant preview whose response may have been cancelled. Action origins use
the retained elements' route metadata as described below. Failed commits do
not learn static routes from retained preview metadata. Before replacing the
queued Router state, a failure resolves its retained origin into route metadata,
preserving static-page queries and instant-preview origins.

An ordinary navigation queues its pending elements promise when fetching starts.
Later action and client merges follow it in React's update order. `fetchRsc`
returns the decoded response patch without filling omitted entries from its
request-time base. Returned entries replace earlier values, including equal
primitive values; omitted entries retain queued values and removals. Prefetching
combines its retained base with the response to provide
reusable element maps; instant merging fills omitted slots from its prefetch
base. A rejected instant response preserves the previous queued values rather
than replacing them with prefetch values. Instant fetch cancellation stops
waiting even when transport ignores the signal.
The destination's route slot and protocol metadata are reapplied when the
response arrives so an action from the previous route cannot restore its
composition over the destination.
Redirect and 404 follows after an instant paint queue their pending response
patch before later action and client updates, using the ordinary navigation
merge and destination finalization. Response metadata identifies the resolved
route and is applied independently of slot conflicts. Reusing a cached static
route restores its static classification.
At merge time, a missing destination route slot can be filled from the retained
cache, including an accepted instant response's prefetch fallback. An existing
queued value for that slot and unrelated updates or removals are preserved.

Fetching and committing are different boundaries. An ordinary fetch can finish
while a client component is suspended; its navigation promise does not wait for
that component. Successful ordinary navigations apply history and scrolling
after the snapshot commits. Eligible instant navigations can commit a cached
shell while fetching continues. Fetch failures and external redirects write
history without waiting for a successful content commit; failures render an
error rather than a slot that was never fetched. The render-error boundary has
its own reset conditions, separate from clearing the Router's fetch error.

Router's `useMergeInstantElements` owns the eager rendering policy. It uses
Minimal's merge callback to retain pinned slots, wait on the response for other
slots, and include cached immutable slots and client navigation intent. The
callback starts from the latest queued elements, so it cannot overwrite an
earlier queued update with a rendered snapshot. Settlement adds new slots and
updates response-owned overlay keys only while that instant merge still owns
the queued promise. It settles in a transition so adding entries does not hide
the painted shell behind the Root's Suspense fallback. Minimal owns updater
replay safety, not SWR policy.

Cached and same-RSC-route commits queue before the first `await`, retaining the
caller's React priority. Transition callbacks run immediately, so wrapping an
uncached navigation does not postpone superseding the previous request.

Superseding a pending request resolves its queued elements update to an empty
patch. Once the response is accepted, its elements remain in the update chain,
even if rendering is suspended. A later navigation appends a new pending promise;
React controls which intermediate renders commit. No committed snapshot is copied
back into the queue, and unrelated updates retain their original order.

Ordinary element merges retain the attached state object by reference. The
layout effect recognizes that identity and does not repeat its scroll or history
intent. An external `history.replaceState` query/hash change is consequently not
undone by an unrelated element merge. A server redirect can change the destination
for the same intent; that correction replaces history rather than pushing again.

RSC hot reload selects an accepted destination before retiring the request. If
only a pending fetch exists, it refreshes the visible route. This keeps an
accepted but suspended navigation from being replaced by the previous page.

## Action Intent

A controller from `unstable_createActionRouting` sends the settled route at action request time in
`X-Waku-Action-Location`. This header is client-to-server information.
After a failed navigation, the action origin follows the retained elements'
route metadata, which can be ahead of the request's cache baseline after an
instant paint. The failed destination's URL remains in history.

- `rerenderRoute()` refreshes that origin and marks the response `IS_ORIGIN`.
  If the user has navigated away or a conflicting navigation is pending, the client
  discards its elements but retains the action's return value.
- `rerenderRoute(destination)` and action redirects express navigation intent.
  Their destination must not be discarded merely because the user left the origin.

Create one controller and a stable enhancer array for each Root before rendering
React. Install its `enhancer` last in Root's `rscEnhancers`, and call
`useActionRouting_UNSTABLE(controller, callbacks)` in the
client binding beneath that Root. The hook connects the Root cache and current
navigation callbacks in a layout effect; it does not install the enhancer.
Callbacks can change without replacing the pipeline. Start mount-time actions
in `useEffect`: actions before connection fail rather than inventing an origin.

```tsx
const actionRouting = unstable_createActionRouting();
const rscEnhancers = [...inputEnhancers, actionRouting.enhancer];
const app = (
  <Root rscEnhancers={rscEnhancers}>
    <Binding actionRouting={actionRouting} />
  </Root>
);
```

The binding calls:

```tsx
useActionRouting_UNSTABLE(actionRouting, {
  getSettledRoute,
  getPendingRoute,
  onRouteChange,
});
```

See [Rerender After Actions](/guides/rerender-after-actions).

## Reading the Implementation

Paths below are relative to `packages/waku/src/router`.

| Responsibility                                                | File                                                               |
| ------------------------------------------------------------- | ------------------------------------------------------------------ |
| Protocol IDs, RSC inputs, slot IDs, and metadata readers      | `isomorphic-utils/router-protocol.ts`                              |
| Route resolution to elements and route metadata               | `define-router-utils/route-entries.ts`                             |
| Request handling, redirects, not-found, and action responses  | `define-router-utils/request-handler.tsx`                          |
| Client action intent and stale-origin protection              | `client-core-utils/action-routing.ts`                              |
| Fetch-time follows and render-time follow policy              | `client-core-utils/load.ts` and `client-core-utils/error-route.ts` |
| Element/route/history commit in the standard binding          | `client-utils/navigation.ts` and `client-utils/router-state.ts`    |
| Cached eager paints and response adoption                     | `client-utils/instant-navigation.ts`                               |
| Pinned slots, waiting values, and instant response settlement | `client-utils/instant-elements.ts`                                 |
| Rendered error handling and boundary resets                   | `client-utils/render-error.tsx` and `client.tsx`                   |

`packages/waku/tests/router-protocol.test.tsx` exercises server responses through
Minimal's client and Router's public helpers, substituting only Flight
serialization. The executable alternative binding in
`e2e/fixtures/nav-api-spike/src/nav-binding.tsx` uses public Minimal and client-core
APIs; its browser tests exercise the real transport.

---

### Guides Static Deployments (`docs/guides/static-deployments.mdx`)

---
slug: static-deployments
title: Static Deployments
description: Build a pure static Waku app and publish dist/public without a server runtime.
category: Deployment
order: 5
---

## When Static Deployment Fits

A static deployment serves only the files generated during `waku build`. There is no Waku server, worker, Lambda function, or other request-time runtime after deploy.

Use a static deployment for apps where every public route can be known and rendered at build time:

- marketing sites
- documentation sites
- blogs with generated static paths
- static resources such as RSS feeds, sitemaps, and JSON files

Do not use a static-only deployment if the app needs request-time behavior:

- dynamic routes without `staticPaths`
- dynamic API routes
- server actions
- per-request cookies, headers, authentication, or personalization
- middleware that must run for deployed requests
- redirects or rewrites that must be decided at request time

If you need those features, deploy Waku with a server-capable adapter instead. Static pages can still be part of a server-capable deployment.

## Make Routes Static

Pages, layouts, and slices are static by default in the file-system router. For routes that matter to a static deployment, it is still useful to be explicit:

```tsx
// src/pages/index.tsx
export default async function HomePage() {
  return <main>...</main>;
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

Dynamic segments need `staticPaths` so Waku knows which concrete URLs to emit:

```tsx
// src/pages/blog/[slug].tsx
export default async function BlogPost({ slug }: { slug: string }) {
  return <article>...</article>;
}

export const getConfig = async () => {
  return {
    render: 'static',
    staticPaths: ['introducing-waku', 'deploying-waku'],
  } as const;
};
```

For nested segments, use arrays in the same order as the route parameters:

```tsx
// src/pages/docs/[section]/[slug].tsx
export const getConfig = async () => {
  return {
    render: 'static',
    staticPaths: [
      ['guides', 'quick-start'],
      ['reference', 'configuration'],
    ],
  } as const;
};
```

API routes are dynamic by default. If an API route produces a build-time resource, mark it static too:

```ts
// src/pages/_api/rss.xml.ts
export const GET = async () => {
  return new Response(generateRss(), {
    headers: { 'content-type': 'application/rss+xml' },
  });
};

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

## Build Output

Run the normal production build:

```sh
pnpm exec waku build
```

Static files are written to `dist/public`. Publish that entire directory:

- generated HTML files
- generated RSC payloads under Waku's RSC path
- Vite client assets
- files copied from `public`
- static API route output

Do not publish only the HTML and asset files. The generated RSC payloads are part of the static app and are used during client-side navigation.

## Static Hosts

For a plain static host, configure the publish directory as:

```txt
dist/public
```

The host should serve generated files as they exist on disk. Avoid using a single-page-app fallback to serve `index.html` for every path. If a route is not emitted during the build, either add it to `staticPaths` or use a server-capable deployment.

If the host lets you configure response headers, keep Waku's generated RSC files deployable and cache immutable client assets aggressively. For public RSC payloads, a common choice is to prevent search indexing:

```txt
/RSC/*
  X-Robots-Tag: noindex
```

## Adapter Static Mode

Some platform adapters can generate platform-specific static deployment output. Use their `static` option when you want that platform integration without a serverless function:

```ts
// src/waku.server.tsx
import { fsRouter } from 'waku';
import adapter from 'waku/adapters/vercel';

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
  static: true,
});
```

Use the adapter for your platform:

| Platform   | Adapter                    | Static output behavior                                        |
| ---------- | -------------------------- | ------------------------------------------------------------- |
| Vercel     | `waku/adapters/vercel`     | copies `dist/public` to `.vercel/output/static`               |
| Netlify    | `waku/adapters/netlify`    | uses `dist/public` as the publish directory                   |
| Cloudflare | `waku/adapters/cloudflare` | configures Wrangler static assets to serve from `dist/public` |

The static option only changes the deployment output. It does not make dynamic Waku features work without a runtime.

## Static Rendering Is Not No SSR

Static rendering prerenders route output at build time and writes that output to `dist/public`. The generated HTML can contain the page's rendered content.

No SSR serves a fallback shell and lets the browser render the route after JavaScript starts. It is useful for different tradeoffs and does not make an app deployable without a server runtime. For that pattern, see [No SSR and Static Fallbacks](/guides/no-ssr).

---

### Guides Stream Intercept (`docs/guides/stream-intercept.mdx`)

---
slug: stream-intercept
title: SSR Stream Interception
description: How to intercept SSR stream to inject content before it is sent to the client.
category: Runtime and Middleware
order: 30
tags: [Experimental]
---

## Additional Content Injection in SSR

In some cases you may want to inject additional content into the SSR stream before it's sent to the client. This can be useful for injecting properties onto the document's root element (e.g., the `lang` attribute or a class name based on the user's request headers).

This guide is intentionally narrow. Use it when you specifically need to transform the HTML stream. For request-scoped data and regular middleware, see [Request Context](/guides/request-context).

### Step 1: Create a custom middleware

Create a new file in `src/middleware/`. Waku automatically discovers and loads middleware from this directory.

```ts
// ./src/middleware/stream-interceptor.ts
import type { MiddlewareHandler } from 'hono';

const getPreferredLanguage = (acceptLanguage: string | undefined) => {
  return acceptLanguage?.split(',')[0]?.trim() || 'en';
};

const streamInterceptor = (): MiddlewareHandler => {
  return async (c, next) => {
    await next();

    const contentType = c.res.headers.get('content-type');
    const isDocument = contentType?.includes('text/html');
    const lang = getPreferredLanguage(c.req.header('accept-language'));

    if (isDocument && c.res.body) {
      const newBody = c.res.body.pipeThrough(
        new TransformStream({
          transform(chunk, controller) {
            const text = new TextDecoder().decode(chunk);
            const newText = text.replace('<html>', `<html lang="${lang}">`);
            controller.enqueue(new TextEncoder().encode(newText));
          },
        }),
      );

      c.res = new Response(newBody, {
        status: c.res.status,
        headers: c.res.headers,
      });
    }
  };
};

export default streamInterceptor;
```

### Step 2: Verify the changes

After restarting the server, inspect the HTML source of your page. You should see the `lang` attribute on the `<html>` element.

For more on request-scoped data, see [Request Context](/guides/request-context).

---

### Guides Typed Routes (`docs/guides/typed-routes.mdx`)

---
slug: typed-routes
title: Typed Routes
description: Build route URLs with locally inferred params and search codecs.
category: Routing and Navigation
order: 11
tags: [Experimental]
---

## Overview

Waku navigation accepts URL strings. Use `unstable_createRouteHref` from `waku/router` to construct them with typed path params and optional search. Factories are optional: `<Link to="/about">` and `router.push('/about')` work without one.

The APIs in this guide are unstable and may change. For upgrading existing typed navigation, see [Migration](#migration).

## Build Route Hrefs

Define a factory in a module that both Server and Client Components can import:

```ts
// ./src/lib/routes.ts
import { unstable_createRouteHref as createRouteHref } from 'waku/router';

export const post = createRouteHref({ path: '/posts/[slug]' });
export const docs = createRouteHref({ path: '/docs/[...path]' });
export const home = createRouteHref({ path: '/' });
```

Call it to encode params and optionally append a fragment:

```tsx
import { Link } from 'waku';
import { docs, home, post } from '../lib/routes';

export default function Navigation() {
  return (
    <nav>
      <Link to={home()}>Home</Link>
      <Link to={post({ params: { slug: 'hello world' }, hash: 'top' })}>
        Post
      </Link>
      <Link to={docs({ params: { path: ['guides', 'routing'] } })}>Docs</Link>
    </nav>
  );
}
```

- `params` is required for patterns with slugs. Named params are strings; catch-alls accept readonly arrays of nonempty strings. An empty array is allowed when the pattern matches the resulting path.
- A factory without params allows a call such as `home()`.
- Route groups are stripped from the URL. `hash` accepts a fragment with or without the leading `#`.
- Completed `.` and `..` path segments are rejected because URL normalization would change the path.
- The result is a primitive string, so it can cross a Server/Client Component boundary. Import factories and codecs where they are used; their functions cannot be passed as ordinary Client Component props.
- Hrefs exclude the application's base path. Waku's `Link`, navigation methods, and `unstable_redirect` apply the base. A native `<a href>` needs a URL that includes the base.

Client navigation uses the same result:

```tsx
'use client';

import { useRouter } from 'waku';
import { post } from '../lib/routes';

export const PostButton = ({ slug }: { slug: string }) => {
  const router = useRouter();
  const href = post({ params: { slug } });
  return <button onClick={() => router.push(href)}>Open post</button>;
};
```

`router.replace(href)`, `router.prefetch(href)`, and server-side `unstable_redirect(href)` also accept factory results. For same-page navigation, bare `?query` and `#hash` strings are resolved against the current URL.

### What the types check

Generated routes distinguish raw hrefs from constructed hrefs. Raw strings are checked against the configured concrete href types. A factory result carries a type-only brand recording its normalized route pattern; navigation checks that pattern against the generated route patterns.

Bind factories to registered patterns, not concrete build URLs: for `/articles/[slug]`, use that pattern with `{ params: { slug: 'intro' } }`, or pass the raw string `'/articles/intro'` directly to navigation. Navigation rejects a factory result for `/articles/intro` unless that exact pattern is registered. Factory results are assignable to `string`, but do not have literal URL types.

Parameter names are part of the pattern: `/articles/[id]` does not match a registered `/articles/[slug]`.

For a static `/posts/[slug]` route, a factory checks the parameter shape, not whether a slug is among the generated static paths. Constructing an unbuilt slug can therefore succeed and navigate to not found. The brand has no runtime representation and does not register a route.

Without generated route patterns, factory arguments are inferred locally but the pattern's existence is unchecked. A manual `RouteConfig.paths` augmentation constrains raw hrefs only; see [createPages type safety](/create-pages#router-paths-type-safety). Widening a factory result to `string` loses its type evidence, so it may be rejected by navigation in a strictly typed app.

## Reading Route Params

`useParams_UNSTABLE` reads decoded params from the current route. It returns `null` when the pathname does not match:

```tsx
'use client';

import { useParams_UNSTABLE as useParams } from 'waku/router/client';

export const PostTitle = () => {
  const params = useParams({ from: '/posts/[slug]' });
  if (!params) {
    return null;
  }
  return <h1>{params.slug}</h1>;
};
```

Named params are strings and catch-alls are string arrays. Page components already receive params through props; this hook is useful for shared Client Components.

## Search Params

A search codec is a plain object with `parse` and `serialize`. Keep it in a module shared by the page and its href factory:

```ts
// ./src/lib/search.ts
import type { Unstable_SearchCodec } from 'waku/router';

type ProductsSearch = { q: string; page: number };

export const productSearchCodec = {
  parse: (query: string): ProductsSearch => {
    const params = new URLSearchParams(query);
    const page = Number(params.get('page') ?? '1');
    if (!Number.isInteger(page) || page < 1) {
      throw new Error('Invalid page');
    }
    return { q: params.get('q') ?? '', page };
  },
  serialize: ({ q, page }: ProductsSearch): string => {
    if (!Number.isInteger(page) || page < 1) {
      throw new Error('Invalid page');
    }
    return new URLSearchParams({ q, page: String(page) }).toString();
  },
} satisfies Unstable_SearchCodec<ProductsSearch>;
```

`parse` receives the incoming query without `?`, applies defaults and validation, and returns the typed search value. The server turns a parsing failure into a 400. Client hooks also parse the current query; their errors propagate to the caller or render boundary.

`serialize` owns outbound validation and returns an encoded query without `?`. Its errors propagate when constructing an href or updating search; calling a factory during render can therefore fail the component that renders the link. In development and production, Waku does not parse the serialized output to validate it. If a codec accepts invalid input during serialization, the destination may reject the resulting query.

### Construct URLs with search

```ts
// ./src/lib/routes.ts
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { productSearchCodec } from './search';

export const product = createRouteHref({
  path: '/products/[id]',
  search: productSearchCodec,
});
```

```tsx
import { Link } from 'waku';
import { product } from '../lib/routes';

export default function ProductLink() {
  return (
    <Link
      to={product({
        params: { id: '42' },
        search: { q: 'waku', page: 2 },
      })}
    >
      Product
    </Link>
  );
}
```

The factory infers search from its codec without a provider, codec ID, or runtime registry. It exposes `.path` and `.searchCodec` for the search hooks. A factory without a codec accepts no typed `search` argument; use a URL string for ad-hoc queries.

`search` is optional. Omitting it emits no query and calls neither codec function. The destination still parses the incoming empty query and can reject it if its codec requires search values.

### Attach the codec to a page

With the file-system router, import the independent codec in `getConfig`. The generated `SearchCodecsConfig` supplies the type of `PageProps.search`; navigation infers types from the factory itself.

```tsx
// ./src/pages/products/[id].tsx
import type { PageProps } from 'waku/router';
import { productSearchCodec } from '../../lib/search';

export default function ProductPage({
  id,
  search,
}: PageProps<'/products/[id]'>) {
  return (
    <p>
      Product {id}: {search.q} (page {search.page})
    </p>
  );
}

export const getConfig = async () =>
  ({
    render: 'dynamic',
    unstable_searchCodec: productSearchCodec,
  }) as const;
```

With `createPages`, attach the same codec and declare its page-prop mapping:

```tsx
// ./src/waku.server.tsx
import { productSearchCodec } from './lib/search';

declare module 'waku/router' {
  interface SearchCodecsConfig {
    '/products/[id]': typeof productSearchCodec;
  }
}

createPage({
  render: 'dynamic',
  path: '/products/[id]',
  component: ProductPage,
  unstable_searchCodec: productSearchCodec,
});
```

The factory does not compare its codec with the page configuration; share one independent export to keep them consistent and keep server configuration independent of URL construction. A custom `defineRouter` resolver can parse its query with this same codec directly; it needs no codec registration.

### Read and update search on the client

```tsx
// ./src/components/pager.tsx
'use client';

import {
  useSearch_UNSTABLE as useSearch,
  useSetSearch_UNSTABLE as useSetSearch,
} from 'waku/router/client';
import { product } from '../lib/routes';

export const Pager = () => {
  const search = useSearch(product);
  const setSearch = useSetSearch(product);
  if (!search) {
    return null;
  }
  return (
    <button onClick={() => setSearch((prev) => ({ page: prev.page + 1 }))}>
      page {search.page}
    </button>
  );
};
```

Both hooks also accept a `{ path, searchCodec }` descriptor. They infer search from the supplied codec, not from `SearchCodecsConfig`. Generated route types reject unknown patterns, ignoring route groups. Pass descriptors inline or preserve a named descriptor's literal path with `as const`.

- `useSearch` returns the parsed search, or `null` for a different pathname.
- The setter is a no-op for a different pathname. Otherwise, it parses the current query, shallow-merges a partial value or updater result, serializes, and navigates.
- Updates preserve the pathname and fragment, default to push and no scrolling, and accept `{ history: 'replace', scroll: true }` as the second argument.
- Parsing, serialization, and navigation errors reject the setter's promise. There is no parse check of its newly serialized output.
- Parser defaults participate in the merge. Have the serializer omit defaults if you do not want them written into the URL.

### Using a validation library

A schema library can implement both codec operations. For example, with [Zod](https://zod.dev):

```ts
// ./src/lib/search.ts
import { z } from 'zod';
import type { Unstable_SearchCodec } from 'waku/router';

const schema = z.object({
  q: z.string().default(''),
  page: z.coerce.number().int().min(1).default(1),
});

type ProductsSearch = z.infer<typeof schema>;

export const productSearchCodec = {
  parse: (query: string): ProductsSearch =>
    schema.parse(Object.fromEntries(new URLSearchParams(query))),
  serialize: (search: ProductsSearch): string => {
    const { q, page } = schema.parse(search);
    return new URLSearchParams({ q, page: String(page) }).toString();
  },
} satisfies Unstable_SearchCodec<ProductsSearch>;
```

The schema validates inbound queries and outbound values independently. Query-state libraries such as [nuqs](https://nuqs.dev) can also supply parsers and serializers through an isomorphic module such as `nuqs/server`; return a query without its leading `?` and implement any required outbound validation in `serialize`. Waku's hooks do not require the library's client provider.

## Migration

The typed-navigation API changes are breaking, including for unstable exports.

1. Replace structured `to` targets with a local factory call. Pass its string result to `Link`, `push`, `replace`, `prefetch`, or `unstable_redirect`:

   ```tsx
   import { unstable_createRouteHref as createRouteHref } from 'waku/router';

   const post = createRouteHref({ path: '/posts/[slug]' });

   <Link to={post({ params: { slug } })}>Post</Link>;
   router.push(post({ params: { slug } }));
   ```

   This replaces `to={{ to: '/posts/[slug]', params: { slug } }}` and `router.push({ to: '/posts/[slug]', params: { slug } })`. Plain string targets do not change. For link wrappers, use `LinkProps` without a route-pattern type argument.

2. Remove `SearchCodecsProvider_UNSTABLE` and codec `id` fields. Import the codec into the factory via `search: codec`, and into the page configuration via `unstable_searchCodec: codec`.

3. Replace `useSearch({ from: '/products/[id]' })` and `useSetSearch({ from: '/products/[id]' })` with `useSearch(product)` and `useSetSearch(product)`, or pass `{ path: '/products/[id]', searchCodec }`. `useParams({ from })` is unchanged.

4. Remove `getSearchCodecs` from `unstable_defineRouter`. Keep the shared codec wherever the resolver parses incoming queries. Generated or manual `SearchCodecsConfig` mappings remain for `PageProps.search`, not client navigation.

5. Replace `unstable_buildRouteHref` from `waku/router/client-core` with `unstable_createRouteHref` from `waku/router`. The factory takes the codec directly and needs no resolver callback. The provider helpers `unstable_isCodec` and `Unstable_AnyCodec` are removed; use the `Unstable_SearchCodec<Search>` contract for a known search type.

---

### Guides Getting Started Comparison (`docs/guides/getting-started/comparison.mdx`)

---
slug: comparison
title: Comparison
description: How Waku compares architecturally with other frameworks.
category: Getting Started
order: 40
---

## How to read this page

Frameworks are best compared by their durable architectural choices, not by feature checklists that change every release. All of the frameworks below are excellent at what they are designed for; the question is which model fits your project. Details reviewed in July 2026 against each framework's documentation. Always check the linked docs for the current state.

## At a glance

|                                   | Waku | [Next.js](https://nextjs.org/docs) | [Astro](https://docs.astro.build) | [React Router](https://reactrouter.com) | [TanStack Start](https://tanstack.com/start/latest) |
| --------------------------------- | ---- | ---------------------------------- | --------------------------------- | --------------------------------------- | --------------------------------------------------- |
| React Server Components           | ✅   | ✅                                 | ➖                                | 🧪                                      | 🧪                                                  |
| Static pages with dynamic regions | ✅   | ✅                                 | ✅                                | ➖                                      | ➖                                                  |
| Framework-managed caching         | ➖   | ✅                                 | ➖                                | ➖                                      | ➖                                                  |
| UI libraries beyond React         | ➖   | ➖                                 | ✅                                | ➖                                      | ➖                                                  |
| Deployment adapters               | ✅   | 🧪                                 | ✅                                | ✅                                      | ✅                                                  |

✅ supported, 🧪 experimental, ➖ not part of the design. React Router is compared in its framework mode; TanStack Start is powered by [TanStack Router](https://tanstack.com/router/latest). The sections below carry the substance the checkmarks cannot.

## Next.js

Next.js optimizes for an integrated application platform: the broadest feature set in the React ecosystem, with the framework owning much of the application lifecycle, including a caching and revalidation model that controls rendering from whole routes down to individual functions. Waku optimizes for the opposite trade: a small framework-owned surface with direct execution semantics, no implicit cache to reason about, and ecosystem libraries for the concerns the framework doesn't own. If you want an integrated platform, Next.js is a strong choice; if you prefer to keep more of those decisions in your own hands, Waku may be the better fit.

## Astro

Astro is optimized for content-first, multi-framework sites: static-first delivery, minimal browser JavaScript, content collections for local data, and islands from any UI library. Its server islands overlap conceptually with Waku's slices. The difference is that Waku is React-native throughout: server components, client components, layouts, pages, and slices belong to one React mental model, so a site that starts mostly static can grow meaningful server-driven application behavior without changing its UI model or adding a second component format.

## React Router

React Router is built for progressive adoption (declarative, data, and framework modes) with mature loader/action conventions, per-route SSR, prerendering, and SPA modes, and a web-standards request/response model. Its React Server Components support is currently experimental. The architectural distinction: React Router begins with routing and layers framework behavior on top; Waku begins with the React server-component model and supplies the routing needed to compose it. Data flows through route loaders there, and through component composition here.

## TanStack Start

TanStack Start, powered by TanStack Router, offers end-to-end type-safe routing, server functions, full-document SSR with streaming, and an explicit, router-centered programming model across multiple deployment targets. It shares Waku's taste for explicitness and portability. The distinction is which abstraction sits at the center: if you want an elaborate type-safe router and server-function model as the dominant abstraction, TanStack may fit you better. Waku is the stronger fit when the abstraction you want is React itself: server components as the architecture, with routing in a supporting role.

## Next Step

[Use Cases](/guides/use-cases) describes the application shapes Waku fits best, and the ones where another framework is the better call.

---

### Guides Getting Started Introduction (`docs/guides/getting-started/introduction.mdx`)

---
slug: introduction
title: What is Waku?
description: The minimal React framework built around React Server Components.
category: Getting Started
order: 20
---

## The minimal React framework

**Waku** _(wah-ku)_ or **わく** means "frame" in Japanese, as in framework. It is the minimal React framework, built around React Server Components and server actions.

Minimal is not just about size. It means fewer hidden execution semantics: you can read a Waku route file and predict when its server code runs. Pages are prerendered at build time by default, pages you declare dynamic execute on every request, and Waku does not place an implicit cache in front of your rendering or data fetching.

## What you get

- **File-based routing.** Files in `src/pages` become routes. Layouts, dynamic segments, catch-all routes, and API routes are all part of the same convention.
- **Static and dynamic rendering in one app.** Each page, layout, and slice (an independently rendered fragment of a page) declares its own rendering mode, so a prerendered marketing page and a per-request dashboard live side by side.
- **Server and client components.** Fetch data with `await` directly in server components. Add `'use client'` where you need interactivity.
- **All React.** Waku adds no parallel programming model or component format. Skills learned in Waku are React skills, and React patterns from elsewhere work in Waku.
- **Deployment adapters.** The same application deploys to Node.js, Vercel, Netlify, Cloudflare, AWS Lambda, Deno, or Bun without changing how you write React.

## A route at a glance

```tsx
// ./src/pages/index.tsx
export default async function HomePage() {
  const posts = await getPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>{post.title}</li>
      ))}
    </ul>
  );
}
```

This is a complete Waku page: a server component that fetches its own data. It is prerendered at build time by default; adding a `getConfig` export with `render: 'dynamic'` makes it execute on every request instead.

## Next Step

Curious why Waku is designed this way? Read the [Philosophy](/guides/philosophy).

---

### Guides Getting Started Philosophy (`docs/guides/getting-started/philosophy.mdx`)

---
slug: philosophy
title: Philosophy
description: The principles behind Waku's design.
category: Getting Started
order: 30
---

## Minimal is the feature

Waku keeps its API surface small enough to hold in your head: files in `src/pages` become routes, `getConfig` declares how a route renders, `'use client'` marks the interactive boundary, and server actions handle mutations. Everything else is React.

A small surface is not a limitation. It means less framework-specific knowledge between you and your application, and fewer places where behavior needs explaining.

## Explicit over implicit

Waku's rendering model is a set of declarations, not heuristics:

- **Static is the default.** Pages and layouts are prerendered at build time unless you declare them dynamic. Static output is a build artifact: it stays the same until the next build replaces it. A rebuild is not "cache invalidation"; it is producing a new artifact.
- **Dynamic means every request.** A route declared `render: 'dynamic'` executes on each request and sees current data.
- **No implicit caching.** Waku does not put a cache in front of dynamic rendering or data fetching. What you fetch is what you render. There is no framework revalidation model to learn, because there is nothing to invalidate until you deliberately add a cache yourself.

In short: **static at build time, fresh at request time, cache explicitly.**

The result is a simple render model. Code runs in one of three places, and every piece of the app clearly belongs to one of them:

1. **At build time.** Static pages and layouts execute once during the production build.
2. **On the server, per request.** Dynamic pages and layouts, server actions, and API routes.
3. **In the browser.** Client components (which also render once on the server to produce the initial HTML).

Because the model is small, you rarely have to ask "when does this code actually run?" The answer is in the file you are looking at.

## Extensible without compiler magic

Waku's capabilities grow through libraries, not compiler plugins. The compilers in a Waku app belong to React and Vite; Waku itself avoids adding compile-time magic, so extending it means writing ordinary code.

The best evidence is Waku itself: `waku/router`, with its pages, layouts, and file conventions, is a library built on top of the Waku core. Extensions can take the same shape at any scale: a published package, a private shared library, or a module inside your repository. Because they are ordinary code, they compose, version, and debug like ordinary code.

## Next Step

See how these choices play out against other frameworks in the [Comparison](/guides/comparison).

---

### Guides Getting Started Quick Start (`docs/guides/getting-started/quick-start.mdx`)

---
slug: quick-start
title: Quick Start
description: Scaffold a new Waku project and run it locally in five minutes.
category: Getting Started
order: 10
---

## Prerequisites

- [Node.js](https://nodejs.org) `^26.0.0`, `^24.0.0`, or `^22.15.0`
- Any package manager (the commands below use `npm`)

## Scaffold a project

Run the following command in your terminal:

```sh
npm create waku@latest
```

Follow the CLI prompts. The default project name is `waku-project`. Then install dependencies and start the development server:

```sh
cd waku-project
npm install
npm run dev
```

Open [http://localhost:3000](http://localhost:3000) in your browser. You should see a small demo app with an interactive counter.

## Project structure

The starter contains the following files:

```text
waku-project/
├── public/            # static assets served as-is
├── src/
│   ├── components/    # reusable React components
│   ├── middleware/    # optional server middleware
│   ├── pages/         # file-based routes
│   │   ├── _layout.tsx
│   │   ├── about.tsx
│   │   └── index.tsx
│   ├── global.d.ts
│   └── styles.css
├── package.json
├── tsconfig.json
└── waku.config.ts
```

- `src/pages` is the router: each file becomes a route, and `_layout.tsx` wraps the pages next to and below it.
- `src/components` holds regular React components that pages import.
- `src/middleware` contains optional [Hono](https://hono.dev) middleware that the default setup picks up automatically. The starter ships one that removes trailing slashes. In production, statically generated pages are served before middleware runs, so it only applies to dynamic routes; see [Redirect Maps](/guides/redirect-maps).
- `public` files are copied to the site root unchanged, such as images and fonts.
- `waku.config.ts` configures Waku and Vite. The starter enables Tailwind CSS and React Compiler plugins.

While the development server is running, Waku also generates `src/pages.gen.ts` containing route types for type-safe links. It is regenerated automatically whenever your pages change, so you never edit it by hand.

## Make your first edit

Open `src/pages/index.tsx`:

```tsx
// ./src/pages/index.tsx (excerpt)
export default async function HomePage() {
  const data = await getData();

  return (
    <div>
      <title>{data.title}</title>
      <h1 className="text-4xl font-bold tracking-tight">{data.headline}</h1>
      <p>{data.body}</p>
      <Counter />
      <Link to="/about" className="mt-4 inline-block underline">
        About page
      </Link>
    </div>
  );
}
```

The content comes from a `getData` function defined below the component. Change its `body` text and save. The browser updates instantly without a full reload.

## Commands

- `npm run dev` starts the development server at [http://localhost:3000](http://localhost:3000)
- `npm run build` creates a production build in `dist`
- `npm run start` serves the production build at [http://localhost:8080](http://localhost:8080)

## Next Step

Read [What is Waku?](/guides/introduction) for a tour of what the framework offers.

---

### Guides Getting Started Use Cases (`docs/guides/getting-started/use-cases.mdx`)

---
slug: use-cases
title: Use Cases
description: Where Waku fits well, and where another framework may serve you better.
category: Getting Started
order: 50
---

## What fit means here

Whether Waku fits is a question about the architecture you want (how much of the application the framework should own), not about the size of your company or your traffic. Waku's rendering granularity is per page, per layout, and per slice, so the shapes below are about how much of your UI is stable versus request-dependent, and who you want managing the difference.

## Strong fits

- **Content, marketing, documentation, and editorial sites** with some dynamic or personalized surfaces. The stable majority is prerendered at build time; the dynamic parts execute per request.
- **Headless commerce.** Catalog and editorial pages are static; inventory, pricing, cart, account, and recommendation surfaces are dynamic and always fresh, with no cache invalidation choreography between your commerce backend and your frontend framework.
- **Full-stack React products** that want React Server Components and server actions without adopting a framework-owned data lifecycle. You choose your database client, auth approach, and (if ever needed) caching.
- **Teams that value deployment portability.** Agencies shipping to whatever host each client uses, and products that don't want architecture coupled to a platform.
- **Framework and platform authors** who need a thin React Server Components substrate to build on, rather than a competing opinion stack.

## Weaker fits

Waku is intentionally not everything. Consider a more batteries-included framework if:

- You want the framework to own **integrated solutions** for authentication, image processing, internationalization, analytics, and similar concerns, rather than assembling ecosystem libraries.
- Your application's core requirement is a **framework-managed distributed cache and revalidation platform**. Waku treats caching as an explicit, application-owned optimization, not a built-in subsystem.
- You want a **single vendor's platform** to manage the entire application lifecycle from framework through hosting.

None of these are failures of scale; Waku serves small sites and large ones. They are differences in how much framework you want.

## Next Step

Ready to build? The Learn series begins with [The Mental Model](/guides/concept).

---

### Guides Integrations Authentication (`docs/guides/integrations/authentication.mdx`)

---
slug: authentication
title: Authentication
description: Read a session, protect routes, and set cookies from server actions with the pieces Waku gives you.
category: Integrations
order: 20
---

## What Waku provides

Waku does not ship an authentication system; that is a deliberate boundary (see [Use Cases](/guides/use-cases)). What it provides is enough to plug one in: request headers in server code, server actions for signing in and out, and middleware that sees every request and owns the response. This guide shows where each part of an auth flow goes, and the two places where the obvious approach does not work.

The session itself can come from a library or, as in the code below, from a few lines over a signed cookie. Everything here is condensed from the [`nextjs-dashboard`](https://github.com/wakujs/waku-examples/tree/main/fs-router/nextjs-dashboard) example in the waku-examples repository, which runs the full flow end to end.

## Reading the session

The snippets use two small libraries — `cookie` to parse and serialize headers
and `jose` to sign and verify the token — and the cookie jar below imports
`node:async_hooks`, whose declarations come from `@types/node`, which the starter
template does not include. `hono`, whose `MiddlewareHandler` type the middleware
imports, is already a dependency of a Waku project.

```sh
npm install cookie jose
npm install --save-dev @types/node
```

Server code reads request cookies from `getRequest().headers`. Wrap that in one function and use it everywhere:

```
/* Detailed source-code truncated for AI context efficiency. */
```

`auth()` works anywhere the router runs your code: pages, layouts, slices, server actions and API route handlers. It does not work in a static render. Static pages and layouts are prerendered at build time, so anything that reads the session must be `render: 'dynamic'`; see [Sessions on static pages](#sessions-on-static-pages) for keeping the rest of the page static.

## Protecting routes

There are two different jobs here, and they go in two different places.

**Authorization belongs in the data layer.** Every function that returns
protected data, and every mutation, verifies the session before doing anything
else:

```ts
// ./src/lib/session.ts (continued)
export const requireSession = async (): Promise<Session> => {
  const session = await auth();
  if (!session) {
    throw new Error('Unauthorized');
  }
  return session;
};
```

```ts
// ./src/lib/data.ts
export async function fetchInvoices() {
  await requireSession();
  return sql`SELECT ...`;
}
```

```ts
// ./src/lib/actions.ts
'use server';

export async function deleteInvoice(id: string) {
  await requireSession();
  await sql`DELETE FROM invoices WHERE id = ${id}`;
}
```

This is the boundary, and it has to be, because of how a route renders. Waku
renders a route's layouts and its page as independent slots of one response. A
`redirect()` thrown in a layout redirects the layout; it does not stop the page
beneath it from rendering. With a check in the layout only, an unauthenticated
request still gets the page's data:

```
GET /dashboard                     -> 307  Location: /login?callbackUrl=%2Fdashboard
GET /RSC/R/dashboard.txt           -> 200  22,532 bytes, including every figure on the page
```

With `requireSession()` in the data functions, the same request carries error
slots and nothing else. A server action is an endpoint of its own and an API
route is outside every layout, so neither is covered by anything but its own
check either.

**The redirect is for people.** Put it in middleware, so that a signed-out
visitor lands on the login page before anything renders. Match the route rather
than the url, and build the redirect in the same shape as the request:

```ts
// ./src/middleware/auth.ts
import * as cookie from 'cookie';
import type { MiddlewareHandler } from 'hono';
import {
  unstable_formatRouterRequest as formatRouterRequest,
  unstable_parseRouterRequest as parseRouterRequest,
} from 'waku/router/server';
import { verifySessionToken } from '../lib/session';

const auth = (): MiddlewareHandler => async (c, next) => {
  const route = parseRouterRequest(c.req.raw);
  if (
    route?.type === 'route' &&
    (route.path === '/dashboard' || route.path.startsWith('/dashboard/'))
  ) {
    // Middleware runs before the router, so it reads the cookie from Hono.
    const cookies = cookie.parseCookie(c.req.header('cookie') ?? '');
    const session = await verifySessionToken(cookies.session);
    // Pass the requested route along so that signing in can return to it.
    const callbackUrl = route.query
      ? `${route.path}?${route.query}`
      : route.path;
    const location =
      !session &&
      formatRouterRequest(
        c.req.raw,
        '/login',
        new URLSearchParams({ callbackUrl }).toString(),
      );
    if (location) {
      // 303, so a form posted without JavaScript is not resent to /login
      return c.redirect(location.pathname + location.search, 303);
    }
  }
  await next();
};

export default auth;
```

In production, a route prerendered as `static` is served before middleware runs,
so every page under `/dashboard` must be `render: 'dynamic'`, as anything that
reads the session must be anyway. The login page can be static when it does not
read the session on the server; the redirect preserves `callbackUrl` during
client-side navigation.

```tsx
export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

An API route answers with a status instead:

```ts
// ./src/pages/_api/report.ts
import { auth } from '../../lib/session';

export async function GET() {
  if (!(await auth())) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }
  // ...
}
```

### Why the middleware matches routes

If you are coming from a framework where route protection lives in middleware, the natural port is a Hono middleware that matches the path:

```ts
// This looks right and is not enough.
if (c.req.path.startsWith('/dashboard') && !session) {
  return c.redirect('/login');
}
```

`c.req.path` only sees document requests. A client-side navigation to `/dashboard` requests `/RSC/R/dashboard.txt` instead, so the middleware never matches and the protected route's payload is served:

```
GET /dashboard                    -> 302  Location: /login
GET /RSC/R/dashboard.txt?query=   -> 200  the rendered dashboard
```

A visitor with no session who clicks a `<Link to="/dashboard">` gets the page. The check passes the obvious manual test (loading the URL redirects) and fails on the path users actually take.

`unstable_parseRouterRequest` reads either url as the route it addresses, which is why the middleware above matches `route.path`. Redirecting the RSC request to `/login` would then send the fetch a document, which the client fails to decode as a Flight payload, so `unstable_formatRouterRequest` builds the destination in the shape the caller asked for:

```
GET /dashboard             -> 303  Location: /login?callbackUrl=%2Fdashboard
GET /RSC/R/dashboard.txt   -> 303  Location: /RSC/R/login.txt?query=callbackUrl%3D%252Fdashboard
```

A route match is still an optimistic redirect, not a boundary. A server action dispatched from the client carries no route — it reports `{ type: 'action' }` — so no path rule covers it, which is why the data layer checks for itself.

## Signing in: setting a cookie from a server action

Waku has no cookie-writing API. Server components and server functions can read the request; only middleware owns the response. Setting a cookie from a sign-in action therefore takes a small bridge: a request-scoped jar that the action writes into and middleware empties onto the response.

```ts
// ./src/lib/cookie-jar.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import * as cookie from 'cookie';

type CookieJar = { setCookies: string[] };

const storage = new AsyncLocalStorage<CookieJar>();

export const runWithCookieJar = <T>(fn: () => Promise<T>) =>
  storage.run({ setCookies: [] }, fn);

export const getCookieJar = () => storage.getStore();

export const queueSetCookie = (value: string) => {
  const jar = storage.getStore();
  if (!jar) {
    throw new Error(
      'No cookie jar in scope. Is the cookies middleware registered?',
    );
  }
  jar.setCookies.push(value);
};

// A cookie queued earlier in this same request, so a render that follows the
// write sees it. See "The read-through" below for why this matters.
export const readPendingCookie = (name: string): string | undefined => {
  const queued = storage.getStore()?.setCookies ?? [];
  for (let i = queued.length - 1; i >= 0; i -= 1) {
    const parsed = cookie.parseSetCookie(queued[i]!);
    if (parsed?.name === name) {
      return parsed.value;
    }
  }
  return undefined;
};
```

```ts
// ./src/middleware/cookies.ts
import type { MiddlewareHandler } from 'hono';
import { getCookieJar, runWithCookieJar } from '../lib/cookie-jar';

const cookies = (): MiddlewareHandler => {
  return async (c, next) => {
    await runWithCookieJar(async () => {
      await next();
      const jar = getCookieJar();
      if (!jar?.setCookies.length || !c.res) {
        return;
      }
      const headers = new Headers(c.res.headers);
      for (const value of jar.setCookies) {
        headers.append('set-cookie', value);
      }
      c.res = new Response(c.res.body, {
        status: c.res.status,
        statusText: c.res.statusText,
        headers,
      });
    });
  };
};

export default cookies;
```

The sign-in action verifies the credentials, queues the cookie, and redirects:

```ts
// ./src/lib/session.ts (continued)
import { SignJWT } from 'jose';
import { queueSetCookie } from './cookie-jar';

export const signIn = async (userId: string) => {
  const token = await new SignJWT({})
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setExpirationTime('7d')
    .sign(getSecret());

  queueSetCookie(
    cookie.stringifySetCookie({
      name: 'session',
      value: token,
      httpOnly: true,
      path: '/',
      sameSite: 'lax',
      // Secure by default; only plain-http local development opts out. Do not
      // key this on NODE_ENV === 'production': Cloudflare Workers set no NODE_ENV.
      secure: getEnv('NODE_ENV') !== 'development',
      maxAge: 60 * 60 * 24 * 7,
    }),
  );
};
```

```ts
// ./src/lib/actions.ts
'use server';

import { unstable_redirect as redirect } from 'waku/router/server';
import { signIn } from './session';
import { verifyPassword } from './users';

export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  const user = await verifyPassword(
    String(formData.get('email')),
    String(formData.get('password')),
  );
  if (!user) {
    return 'Invalid credentials.';
  }
  await signIn(user.id);
  // 303, so that a browser following the redirect without JavaScript requests
  // the destination with GET. The default 307 re-sends the form POST — with the
  // action encoded in it — to the destination.
  redirect(dashboardPath(String(formData.get('redirectTo') ?? '')), 303);
}

// Anyone can write a callbackUrl into the login page's query string, so follow
// it only to a same-origin path under /dashboard, which is all the middleware
// sends. The cast is needed because redirect() is typed against the app's
// routes.
const dashboardPath = (url: string) => {
  try {
    const { origin, pathname, search } = new URL(url, 'http://localhost');
    if (origin === 'http://localhost' && /^\/dashboard(\/|$)/.test(pathname)) {
      return `${pathname}${search}` as Parameters<typeof redirect>[0];
    }
  } catch {
    // Not a URL at all.
  }
  return '/dashboard';
};
```

The login form reads `callbackUrl` from `useRouter().query` and posts it back in a hidden `redirectTo` field.

### The read-through

`redirect()` resolves on the server: the destination renders inside the same request that ran the action. At that point `getRequest().headers` still reports the cookies the browser sent, which do not include the one just queued, so a protected destination would render as signed out. The cookie is set, the login worked, and it looks like it did not.

That is what `readPendingCookie` is for. Make `auth()` prefer a cookie queued in this request over the one from the headers:

```ts
import { readPendingCookie } from './cookie-jar';

export const auth = async (): Promise<Session | null> => {
  const pending = readPendingCookie('session');
  if (pending !== undefined) {
    return verifySessionToken(pending || undefined);
  }
  const cookies = cookie.parseCookie(getRequest().headers.get('cookie') ?? '');
  return verifySessionToken(cookies.session);
};
```

An empty pending value is a cookie being cleared, which is why it verifies as no session.

### What does not work

Hono's `c.header()` cannot be used from server code to set the cookie, even with `hono/context-storage` making the context reachable. Waku's handler returns its own `Response`, which replaces `c.res` and discards headers prepared on the context, so the write is silently lost. Rebuilding the response in middleware after `next()`, as above, is the approach that works.

## Signing out

Queue an expired cookie and redirect. Because `auth()` reads through the jar, the redirect destination already renders signed out:

```ts
export const signOut = () => {
  queueSetCookie(
    cookie.stringifySetCookie({
      name: 'session',
      value: '',
      httpOnly: true,
      path: '/',
      sameSite: 'lax',
      maxAge: 0,
    }),
  );
};
```

```tsx
<form
  action={async () => {
    'use server';
    signOut();
    redirect('/', 303);
  }}
>
  <button>Sign out</button>
</form>
```

## Sessions on static pages

A page that reads the session cannot be prerendered, but a page that merely _shows_ something session-dependent can be. Put the session-dependent part in a lazy dynamic [slice](https://waku.gg/#slices): the page is built once, and the slice is rendered per request with the request's cookies.

```tsx
// ./src/pages/_slices/account-menu.tsx
import { auth } from '../../lib/session';

export default async function AccountMenu() {
  const session = await auth();
  return session ? (
    <a href="/account">Account</a>
  ) : (
    <a href="/login">Sign in</a>
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

```tsx
// ./src/pages/index.tsx
import { Slice } from 'waku';

export default async function HomePage() {
  return (
    <>
      <Slice id="account-menu" lazy fallback={<span />} />
      {/* the rest of the page is static */}
    </>
  );
}

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

Keep session reads as far down the tree as they can go. A root layout that reads the session makes every page dynamic.

## Choosing a library

The code above is a complete, small session, and it is a reasonable place to stop for an app with a password form. If you want OAuth providers, email links, or session storage, any framework-agnostic library fits the same shape: it verifies credentials in a server action or API route, and it needs `auth()` to read its cookie. Writing one depends on where it runs: a server action needs the cookie jar above, while an API route can return the library's own `Response` unchanged, `Set-Cookie` and all. [Auth.js](https://authjs.dev) (`@auth/core`, not a framework wrapper) and [Better Auth](https://www.better-auth.com) are two options. A library that is built on another framework's internals, such as one that imports that framework's request or header helpers, will not port.

## Runtime notes

The cookie jar uses `AsyncLocalStorage`, which Waku itself already depends on, so it runs wherever Waku runs: Node.js, Bun, Deno, and Cloudflare Workers with the `nodejs_als` compatibility flag that the [Cloudflare guide](/guides/cloudflare) sets. Keep the jar request-scoped and small; it exists only to carry headers from where they are decided to where they are written.
Read configuration through `getEnv` from `waku` rather than `process.env`, which Node.js provides and the other runtimes do not.

---

### Guides Integrations Explicit Caching (`docs/guides/integrations/explicit-caching.mdx`)

---
slug: explicit-caching
title: Explicit Caching
description: Why caching is outside Waku's core, and waku-cache as one solution.
category: Integrations
order: 10
---

## Rendering in Waku

Waku's rendering model has two modes and no hidden cache:

| Rendering | When server code runs | Freshness                  | Invalidation                            |
| --------- | --------------------- | -------------------------- | --------------------------------------- |
| Static    | At build time         | Fixed until the next build | None; a rebuild produces a new artifact |
| Dynamic   | On every request      | Fresh by default           | None needed; nothing is cached          |

Static pages and layouts are prerendered into build artifacts that a deploy replaces wholesale. Dynamic ones execute on every request, and what you fetch is what you render. Waku does not place a cache in front of either, so there is no framework revalidation model to learn: when your data changes, the next request sees it. The [Learn series](/guides/static-and-dynamic-rendering) covers this model hands-on.

## Caching is outside the core

That is a deliberate boundary, not a missing feature. A built-in cache would give the framework its own data lifecycle, with keys, lifetimes, and invalidation rules that every app inherits whether it needs them or not. Waku keeps the core minimal and its execution semantics visible (see [Philosophy](/guides/philosophy)), and leaves caching to ordinary libraries, the same way `waku/router` itself is a library on top of the Waku core.

Practically, this means:

1. Correctness never depends on a cache. Start without one.
2. Measure before caching. Look for repeated, expensive work: hot database queries, slow upstream APIs, costly renders.
3. Choose the tool that fits: HTTP and CDN caching for whole responses, memoization you write yourself, or a caching library. Whatever you pick, you own its policy, and removing it changes performance, never correctness.

## waku-cache, one solution

[`waku-cache`](https://github.com/wakujs/waku-cache) is one library that fills this space: a separate, still-evolving package that caches exactly what you wrap and nothing else. It offers two primitives, caching a function and caching an RSC subtree, over a small swappable storage interface.

Create one cache instance for your app:

```ts
// ./src/lib/cache.ts
import { createCache } from 'waku-cache';
import { memoryStore } from 'waku-cache/stores/memory';

export const cache = createCache({
  store: memoryStore(),
  defaults: { ttl: 60_000 },
});
```

The memory store is process-local, so a multi-instance deployment needs a shared store instead.

### Cache a function

Wrap an async function; the call signature is unchanged, so it is a drop-in:

```ts
import { cache } from './lib/cache.js';

const getProduct = cache.fn(
  async (id: string) => db.products.findUnique({ where: { id } }),
  {
    key: (id) => ['product', id],
    ttl: 5 * 60_000,
  },
);

const product = await getProduct('abc'); // identical to the unwrapped call
```

The key is a function of the arguments, and concurrent misses for the same key are single-flighted within a process.

### Cache an RSC subtree

Wrap a server component; its rendered subtree is serialized once and replayed on later requests:

```tsx
import { cache } from '../lib/cache.js';

const ProductCard = async ({ id }: { id: string }) => {
  const product = await getProduct(id);
  return <article>{product.name}</article>;
};

const { Component, getEtag } = cache.rsc(ProductCard, {
  key: ({ id }) => ['product', id, 'card'],
  ttl: 60_000,
});

export default Component;
```

The returned `getEtag` is optional; the section below explains what it is for.

### Keys and invalidation

Keys are arrays of parts, and invalidation matches by prefix, so one call can drop a whole group of entries:

```ts
await cache.invalidate({ key: ['product', 'abc'] }); // one product and its card
await cache.invalidate({ key: ['product'] }); // every product entry
```

A common place to call `invalidate` is wherever you handle a mutation, such as a server action or an API route. This is the only invalidation in a Waku app, and it exists only because you added the cache.

Include every value that can affect the output in the cache key: the user, tenant, locale, permissions, and anything else read from request context rather than from arguments or props. The cache returns entries purely by key, so a missing dimension replays one request's output to another, and a process-local store does not make this safe. Do not cache request-specific or sensitive subtrees unless the key safely scopes them. The same applies to `cache.fn` when the wrapped function depends on context that is not part of its arguments.

## Etags: skipping unchanged payloads

The client keeps a small cache of etags for the elements it currently holds. Each slot in an RSC response (a page, a layout, a slice) can carry an etag; the client remembers them and sends them back with the next navigation fetch in the `X-Waku-Etags` header. The server compares them and omits any slot whose etag still matches, so the response carries only what changed, and the client keeps the elements it already has for the omitted slots.

Static slots are marked immutable, which is how the router knows it can always reuse them. Dynamic slots have no etag by default, so they are always re-sent: without one, the server cannot know whether the content changed.

A dynamic route can opt in through `getConfig`'s `unstable_getEtag`, and this is where waku-cache fits: `cache.rsc` returns a `getEtag` that hashes the cached subtree's serialized bytes. The tag stays stable while the cached bytes are unchanged; after invalidation or TTL expiry the entry is regenerated, and the tag changes only when the serialized content changes:

```tsx
export const getConfig = () => {
  return {
    render: 'dynamic',
    unstable_getEtag: getEtag, // delete this line and the render cache still works
  } as const;
};
```

The two savings compose: `cache.rsc` saves the server from re-rendering the subtree, and the etag saves the network from re-sending it. See the [waku-cache README](https://github.com/wakujs/waku-cache#readme) for the details.

An instant navigation drops the etags of dynamic slots; see [Instant Navigation](/guides/navigation-prefetching#instant-navigation) for when that costs a render and how to avoid it.

## What stays uncached

During client-side navigation, the router reuses prefetched RSC payloads so that moving between pages is fast. This is a browser-side navigation optimization: it does not create a server data cache. Static payloads can be reused freely because they change only with a deploy. An explicitly prefetched dynamic response may also be reused until its prefetch TTL expires (60 seconds by default), so a navigation shortly after a prefetch may not issue a new request; whenever a request does reach the server, it renders fresh. See [Navigation and Prefetching](/guides/navigation-prefetching) for the details and the experimental tuning APIs.

Beyond the application, other caches exist with their own controls: the browser cache and CDNs (driven by HTTP headers), static build output (typically served with long-lived caching by hosts), and database drivers or API SDKs that cache internally. Waku does not control these caches; they can affect whether a request reaches your server at all, and what data your dependencies return when it does.

## Common misconceptions

- **"Dynamic means uncached."** Dynamic means uncached _by Waku_. You can still cache expensive work explicitly, and infrastructure layers can cache responses if you tell them to.
- **"Static output is a cache."** Static output is a build artifact. It has no keys, no TTL, and no invalidation API; it is replaced wholesale by the next build.
- **"Client navigation reuse makes server data stale."** It only affects what the browser shows during navigation; the server's answer to any request it receives is computed fresh.
- **"Server components run once."** Static server components execute at build time; dynamic server components execute on every request. Neither runs in the browser.
- **"Every framework needs a revalidation model."** An invalidation responsibility appears when a cache is added. Waku without an explicit cache has nothing to invalidate.

---

### Guides Integrations State Across Client Server (`docs/guides/integrations/state-across-client-server.mdx`)

---
slug: state-across-client-server
title: State Across Client and Server
description: Share selected client-module values with server code using unstable_allowServer.
category: Integrations
order: 30
tags: [Experimental]
---

## When to Use `unstable_allowServer`

Sometimes a value must be declared in a client module, but server code still needs to import that one value. `unstable_allowServer` marks a specific export from a `'use client'` module as safe for Waku's server build.

Use it for shared definitions, not live state:

- Jotai atom definitions that must be colocated with a client component
- small constants used by both server and client code
- pure factory results that do not touch browser APIs

Do not use it for:

- React components
- hooks
- functions that read `window`, `document`, `localStorage`, or browser-only globals
- mutable singletons that should be request-scoped
- secrets, database clients, or server-only resources
- a shortcut to import an entire client module from server code

The API is highly experimental and may change.

## Colocated Jotai Atom

This example follows the pattern used by the Waku Jotai examples. The atom is declared in the same client module as the component, and only the atom export is marked as server-safe.

```tsx
// src/components/counter.tsx
'use client';

import { useTransition } from 'react';
import { atom, useAtom } from 'jotai';
import { unstable_allowServer as allowServer } from 'waku/client';

export const countAtom = allowServer(atom(1));

export const Counter = () => {
  const [count, setCount] = useAtom(countAtom);
  const [isPending, startTransition] = useTransition();

  const increment = () => {
    startTransition(() => {
      setCount((count) => count + 1);
    });
  };

  return (
    <button onClick={increment}>
      Count: {count}
      {isPending ? '...' : ''}
    </button>
  );
};
```

Because `countAtom` is wrapped with `allowServer(...)`, server code can import that one export from the client module and use it with the Jotai store:

```tsx
// src/pages/index.tsx
import { getStore } from 'waku-jotai/router';
import { Counter, countAtom } from '../components/counter';

export default async function Page() {
  const store = await getStore();

  return (
    <>
      <Counter />
      <p>Initial count: {store.get(countAtom)}</p>
    </>
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

For file-system-router apps, wrap the route tree with `RouterProvider`:

```tsx
// src/pages/_layout.tsx
import type { ReactNode } from 'react';
import { RouterProvider } from 'waku-jotai/router';

export default function RootLayout({ children }: { children: ReactNode }) {
  return <RouterProvider>{children}</RouterProvider>;
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

Every page or layout that reads `getStore()` or mounts `RouterProvider` must render dynamically: the store is request-scoped and the atom values arrive with each RSC request, so a static render would bake in build-time values.

If you use the Minimal API, use `Provider` and `getStore` from `waku-jotai/minimal`. Unlike `RouterProvider`, this `Provider` needs the RSC request identity as props:

```tsx
<Provider rscPath="" rscParams={rscParams}>
  {children}
</Provider>
```

## Prefer a Neutral Module When Possible

If the shared definition does not need to live in a client module, put it in a module without a `'use client'` directive. React Server Components can import ordinary server-safe modules without `unstable_allowServer`.

For example, a Jotai atom definition can often live in a shared module:

```ts
// src/state/count.ts
import { atom } from 'jotai/vanilla';

export const countAtom = atom(1);
```

Then client components can import that shared value from a client module:

```tsx
// src/components/counter.tsx
'use client';

import { useAtom } from 'jotai';
import { countAtom } from '../state/count';

export const Counter = () => {
  const [count, setCount] = useAtom(countAtom);
  return (
    <button onClick={() => setCount((count) => count + 1)}>{count}</button>
  );
};
```

Server code can also import `countAtom` from `src/state/count.ts` if it has a real server-side use for the atom definition. Use this pattern when you can because it keeps the client/server boundary obvious.

## What Waku Transforms

`unstable_allowServer` is an identity function at runtime. Its main purpose is to tell Waku's Vite plugin which expression from a client module should remain importable in the RSC build.

In the RSC environment, Waku transforms a `'use client'` module so that:

- the wrapped `allowServer(...)` export is preserved
- dependencies used by that wrapped expression are preserved
- other exports are not made callable server functions
- the wrapper call itself is removed from the emitted server-side expression

This means the server can import `countAtom`, but it does not make every export in `counter.tsx` server-safe.

`unstable_allowServer` must receive exactly one argument:

```tsx
export const countAtom = allowServer(atom(1));
```

## What It Does Not Do

`unstable_allowServer` itself only gives the atom a shared, server-safe identity. When the waku-jotai provider is mounted, wrapped atom values are synchronized from the client to the server during RSC refetches: the client subscribes to the allowed atoms and refetches the route with their current values, which the server reads through the request-scoped store. The synchronization is one way; the server does not independently push atom updates back to the browser.

It also does not make browser-only code safe on the server. This is unsafe:

```tsx
'use client';

import { unstable_allowServer as allowServer } from 'waku/client';

export const theme = allowServer(localStorage.getItem('theme'));
```

The server has no `localStorage`, so this module cannot be evaluated safely in the RSC environment.

## Safer Alternatives

Before using `unstable_allowServer`, consider these alternatives:

- Move shared definitions to a neutral module without `'use client'`.
- Pass serializable values from server components to client components as props.
- Put request-specific data in request context or provider state.
- Keep browser-only behavior inside client components and hooks.
- Use provider boundaries to initialize client state from server-rendered values.

Use `unstable_allowServer` only for the narrow case where a client module must expose one server-safe definition and moving that definition to a neutral module is worse for the integration.

---

### Guides Learn Building For Production (`docs/guides/learn/building-for-production.mdx`)

---
slug: building-for-production
title: Building for Production
description: Build the app, inspect what got prerendered, and watch the rendering model hold.
category: Learn
order: 50
---

## Build

Stop the dev server and run:

```sh
npm run build
```

The build bundles the client and server code, then executes every static page and layout to prerender them. The result lands in `dist`:

```text
dist/
├── public/                    # served as static files
│   ├── index.html
│   ├── about/index.html
│   ├── blog/index.html
│   ├── blog/hello-waku/index.html
│   ├── blog/server-components/index.html
│   ├── built/index.html
│   ├── contact/index.html
│   ├── RSC/                   # prerendered payloads for client-side navigation
│   ├── assets/                # JS and CSS bundles
│   └── ...                    # plus everything copied from public/
└── server/                    # the server that renders dynamic routes
```

You can read the rendering model straight out of this listing:

- Every static page is now an HTML file, including one page per `staticPaths` entry of the blog post route.
- `/now` is nowhere in `dist/public`. A dynamic page has no build-time output; it lives in `dist/server` and executes when requested.
- The `RSC` directory holds the prerendered payloads that make client-side navigation between static pages fast.

## Run it

```sh
npm run start
```

Open [http://localhost:8080](http://localhost:8080) and check the two pages from the [previous chapter](/guides/static-and-dynamic-rendering):

- `/built` shows the moment the build ran, and reloading never changes it. The page executed once, during `npm run build`; you are now being served its artifact.
- `/now` shows a new timestamp on every reload. The page executes on each request, and Waku does not implicitly cache the result.

This is the whole freshness model, observed: **static at build time, fresh at request time**. To update prerendered content, such as a blog post in `src/lib/posts.ts`, run `npm run build` again: a rebuild produces new artifacts.

## Deploy

The starter uses Waku's default adapter, which targets Node.js and automatically switches when the build runs on Vercel, Netlify, or Cloudflare. Other adapters cover AWS Lambda, Deno, and Bun. Fully static sites can skip the server entirely and deploy `dist/public` to any static host.

See the deployment guides for specifics:

- [Static Deployments](/guides/static-deployments)
- [Cloudflare](/guides/cloudflare)
- [AWS Lambda](/guides/aws-lambda)
- [Docker](/guides/docker)

## Where to go from here

You have now seen every idea from [The Mental Model](/guides/concept) working: file-based routing, server and client components, declared rendering modes, and a production build that honors them.

- The [guides](/guides) cover specific capabilities: styling, metadata, request context, middleware, and more.
- The [waku-examples](https://github.com/wakujs/waku-examples) repository has focused, runnable examples for common patterns.
- The [API reference](https://waku.gg/#routing) documents the full routing convention set, server actions, and API routes.

---

### Guides Learn Concept (`docs/guides/learn/concept.mdx`)

---
slug: concept
title: The Mental Model
description: The five ideas that explain every Waku app.
category: Learn
order: 10
---

## Five ideas

Everything you will build in this series follows from five ideas. Hold onto these and the rest of Waku is detail.

### 1. Server components first

Components are server components by default. They execute on the server, can be `async`, and can fetch data, read files, or query databases directly. Their output, not their code, is sent to the browser. When you need interactivity, you add `'use client'` to a file; that module and everything it imports, directly or transitively, become client code running in the browser.

### 2. Static first

Every page and layout is prerendered at build time by default. If a page should instead reflect the current request (current data, cookies, headers), you declare it dynamic with `getConfig`. Static output is a build artifact: it stays exactly the same until the next build replaces it.

### 3. Fresh at request time

A dynamic page executes on every request, and Waku does not implicitly cache the result or the data fetched inside it. When your data changes, the next request sees it. There is no revalidation API to call because there is no hidden cache to invalidate.

### 4. All React, small surface

Waku adds conventions, not a parallel programming model. Routing is files in `src/pages`, navigation is the `<Link>` component, rendering mode is a `getConfig` export, and mutations are React server actions. Data fetching is `await` inside a server component; there is no framework data layer between you and your sources.

### 5. Portable across runtimes

The same application deploys through adapters to Node.js, Vercel, Netlify, Cloudflare, AWS Lambda, Deno, and Bun. The React programming model does not change per host; only runtime-specific capabilities (like filesystem access) vary.

## Where code runs

| Code                      | Where it runs          | When it runs              |
| ------------------------- | ---------------------- | ------------------------- |
| Static page or layout     | Server (build machine) | Once, at build time       |
| Dynamic page or layout    | Server                 | On every request          |
| Server action / API route | Server                 | When invoked              |
| Client component          | Browser                | On render and interaction |

One nuance: client components also execute once on the server per page render to produce the initial HTML (server-side rendering), then hydrate in the browser.

## What this series proves

Each chapter demonstrates one of these ideas hands-on, continuing the project from the [Quick Start](/guides/quick-start):

1. [Pages and Layouts](/guides/pages-and-layouts): routing with files
2. [Server and Client Components](/guides/server-and-client-components): the component model and the `'use client'` boundary
3. [Static and Dynamic Rendering](/guides/static-and-dynamic-rendering): declaring rendering modes and observing freshness
4. [Building for Production](/guides/building-for-production): seeing the model hold in a real build

---

### Guides Learn Pages And Layouts (`docs/guides/learn/pages-and-layouts.mdx`)

---
slug: pages-and-layouts
title: Pages and Layouts
description: Add routes, nest layouts, and link between pages with the file-based router.
category: Learn
order: 20
---

## Files become routes

This chapter continues the project from the [Quick Start](/guides/quick-start). Everything inside `src/pages` follows the router's conventions. The starter already contains:

- `src/pages/index.tsx` → `/`
- `src/pages/about.tsx` → `/about`
- `src/pages/_layout.tsx` → the root layout wrapping every page

There are two equivalent ways to define a page: a named file (`contact.tsx`) or an index file in a directory (`contact/index.tsx`). Both render at `/contact`.

## Add a page

Create `src/pages/contact.tsx`:

```tsx
// ./src/pages/contact.tsx
import { Link } from 'waku';

export default async function ContactPage() {
  return (
    <div>
      <title>Contact</title>
      <h1 className="text-4xl font-bold tracking-tight">Contact</h1>
      <p>You can reach us at hello@example.com.</p>
      <Link to="/" className="mt-4 inline-block underline">
        Return home
      </Link>
    </div>
  );
}
```

With the dev server running, visit [http://localhost:3000/contact](http://localhost:3000/contact). That is the whole route definition; there is no registration step.

Two things worth noticing:

- The `<title>` element is hoisted into the document head automatically. The same works for `meta` and `link` tags. When a layout and a page both declare a `title`, `description`, or scalar Open Graph tag, the page's wins. See [Metadata](/guides/metadata).
- `<Link>` performs client-side navigation between pages. Use it instead of `<a>` for internal links. For programmatic navigation, there is also a `useRouter` hook.

## Layouts

A `_layout.tsx` file wraps every page in its directory and below. The starter's root layout at `src/pages/_layout.tsx` renders the header, footer, and global styles around all pages.

Layouts nest. Let's build a blog section with its own sub-layout. Create `src/pages/blog/_layout.tsx`:

```tsx
// ./src/pages/blog/_layout.tsx
import type { ReactNode } from 'react';
import { Link } from 'waku';

export default async function BlogLayout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <div>
      <nav className="mb-4">
        <Link to="/blog" className="underline">
          Blog
        </Link>
      </nav>
      {children}
    </div>
  );
}
```

And a blog index page at `src/pages/blog/index.tsx`:

```tsx
// ./src/pages/blog/index.tsx
export default async function BlogIndexPage() {
  return (
    <div>
      <title>Blog</title>
      <h1 className="text-4xl font-bold tracking-tight">Blog</h1>
      <p>Posts will appear here.</p>
    </div>
  );
}
```

Visiting `/blog` now renders the root layout, then the blog layout, then the page, outermost to innermost.

## Dynamic segments

Square brackets in a file name declare a route segment whose value comes from the URL. Create `src/pages/blog/[slug].tsx`:

```tsx
// ./src/pages/blog/[slug].tsx
import type { PageProps } from 'waku/router';

export default async function BlogPostPage({
  slug,
}: PageProps<'/blog/[slug]'>) {
  return (
    <div>
      <title>{slug}</title>
      <h1 className="text-4xl font-bold tracking-tight">{slug}</h1>
    </div>
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

Visit `/blog/hello-world` and the page receives `slug: 'hello-world'` as a prop, typed via `PageProps`.

The `getConfig` export is required here: a segment page must declare how its concrete paths come about: either it renders on demand (`render: 'dynamic'`, as above) or you list the paths to prerender at build time. Rendering modes are the subject of [Static and Dynamic Rendering](/guides/static-and-dynamic-rendering), where we will revisit this page.

## More conventions

Other conventions you will meet later, for reference:

- `[...parts].tsx`: catch-all segment matching any number of path parts
- `(group)/`: route groups that organize files without affecting the URL
- `_actions`, `_components/` and `_hooks/`: directories ignored by the router, for co-locating files inside `src/pages`
- `_root.tsx`: customizes the outermost document structure
- `_api/`: API route handlers
- `_slices/`: page fragments with their own rendering mode
- `_interceptors/`: request handler interceptors

See the [API reference](https://waku.gg/#routing) for the details of each.

## Next Step

Pages so far have only rendered markup. [Server and Client Components](/guides/server-and-client-components) brings in data and interactivity.

---

### Guides Learn Server And Client Components (`docs/guides/learn/server-and-client-components.mdx`)

---
slug: server-and-client-components
title: Server and Client Components
description: Fetch data in server components and add interactivity at the client boundary.
category: Learn
order: 30
---

## Server components by default

Components in a Waku app are server components by default: everything is server code except the subtrees you mark with `'use client'`. A `'use client'` module and all of its imports, direct and transitive, are bundled for the browser. Everything outside those subtrees executes only on the server, at build time if the route is static or on each request if it is dynamic, and only its rendered output is sent to the browser; its code never becomes part of the client bundle.

That is why they can be `async` and fetch data directly, the way the starter's home page does. Let's use this for real data in the blog from the [previous chapter](/guides/pages-and-layouts).

## Fetch where you render

Create a small data module at `src/lib/posts.ts`. In a real application this would query a database or CMS; the shape of the page code stays the same.

```ts
// ./src/lib/posts.ts
const posts = [
  {
    slug: 'hello-waku',
    title: 'Hello Waku',
    body: 'Waku is the minimal React framework.',
  },
  {
    slug: 'server-components',
    title: 'Thinking in Server Components',
    body: 'Fetch where you render.',
  },
];

export const getPosts = async () => posts;

export const getPost = async (slug: string) =>
  posts.find((post) => post.slug === slug);
```

Update the blog index to list posts:

```tsx
// ./src/pages/blog/index.tsx
import { Link } from 'waku';
import { getPosts } from '../../lib/posts';

export default async function BlogIndexPage() {
  const posts = await getPosts();

  return (
    <div>
      <title>Blog</title>
      <h1 className="text-4xl font-bold tracking-tight">Blog</h1>
      <ul className="mt-4">
        {posts.map((post) => (
          <li key={post.slug}>
            <Link to={`/blog/${post.slug}`} className="underline">
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

And update `src/pages/blog/[slug].tsx` to load the post it is rendering:

```tsx
// ./src/pages/blog/[slug].tsx
import type { PageProps } from 'waku/router';
import { getPost } from '../../lib/posts';

export default async function BlogPostPage({
  slug,
}: PageProps<'/blog/[slug]'>) {
  const post = await getPost(slug);

  if (!post) {
    return <h1>Post not found</h1>;
  }

  return (
    <div>
      <title>{post.title}</title>
      <h1 className="text-4xl font-bold tracking-tight">{post.title}</h1>
      <p>{post.body}</p>
    </div>
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

The component fetches its own data with `await`: no loader functions, no data-passing configuration. Composition determines data flow. (For returning a real 404 status, see [Redirects and Not Found](/guides/redirects-and-not-found).)

## Client components for interactivity

Server components have no state or event handlers, since they don't run in the browser. When you need interactivity, mark a component with `'use client'`, like the starter's `Counter`. Let's add a like button for blog posts. Create `src/components/like-button.tsx`:

```tsx
// ./src/components/like-button.tsx
'use client';

import { useState } from 'react';

export const LikeButton = ({ title }: { title: string }) => {
  const [liked, setLiked] = useState(false);

  return (
    <button
      onClick={() => setLiked((l) => !l)}
      className="rounded-xs mt-4 bg-black px-2 py-0.5 text-sm text-white"
    >
      {liked ? `You like ${title}` : 'Like'}
    </button>
  );
};
```

Then import it in the post page:

```tsx
// ./src/pages/blog/[slug].tsx
import { LikeButton } from '../../components/like-button';
```

and render it below the body text in the returned JSX:

```tsx
      <p>{post.body}</p>
      <LikeButton title={post.title} />
```

The post page is still a server component; the like button is a client component receiving a serializable prop across the boundary. Clicking updates state in the browser without involving the server.

## Rules of the boundary

- `'use client'` marks the entry to client territory: that file and everything it imports are bundled for the browser.
- Server components can render client components. The reverse is not true: a client component cannot import a server component (importing it would pull it into the client bundle), but it can receive server-rendered content via `children` or other props.
- Props passed from server to client components must be serializable: no functions (except server actions), class instances, or other non-serializable values.
- Client components also execute once on the server per page render to produce the initial HTML, then hydrate in the browser. Code that must never run on the server belongs in effects or event handlers.

A useful default: keep components on the server, and push `'use client'` toward the leaves of the tree: the button, not the page.

## Next Step

The blog index renders statically while the post page declared `render: 'dynamic'`, but what does that actually mean? [Static and Dynamic Rendering](/guides/static-and-dynamic-rendering) explains both modes, when to choose each, and how to prerender the post pages too.

---

### Guides Learn Static And Dynamic Rendering (`docs/guides/learn/static-and-dynamic-rendering.mdx`)

---
slug: static-and-dynamic-rendering
title: Static and Dynamic Rendering
description: Declare when each route executes and understand Waku's freshness model.
category: Learn
order: 40
---

## The model

Every page and layout renders in one of two modes, declared by its `getConfig` export:

| Mode                 | When server code runs | How its content updates            |
| -------------------- | --------------------- | ---------------------------------- |
| `'static'` (default) | Once, at build time   | The next build replaces it         |
| `'dynamic'`          | On every request      | The next request sees current data |

Static pages are **prerendered at build time**: the HTML and the component output are produced during `npm run build` and served as-is until a new build replaces them. Dynamic pages are **executed on every request**: they see current data, cookies, and headers.

Waku does not implicitly cache dynamic rendering or the data you fetch inside it. What you fetch is what you render, so there is no revalidation step when your data changes; the next request is simply fresh. If you ever want caching, you add it explicitly around the specific expensive work, as a deliberate optimization; see [Explicit Caching](/guides/explicit-caching).

## Static is the default

The pages you created without a `getConfig` export, `/contact` and `/blog`, are static. The starter's pages spell it out explicitly, which means the same thing:

```tsx
export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
```

Static fits any content that is the same for every visitor and only changes when you deploy: marketing pages, documentation, blog posts. Because static pages execute at build time, request-specific information does not exist for them; there is no "current user" during a build.

## Declare a dynamic page

Create `src/pages/now.tsx`:

```tsx
// ./src/pages/now.tsx
export default async function NowPage() {
  const now = new Date().toISOString();

  return (
    <div>
      <title>Now</title>
      <h1 className="text-4xl font-bold tracking-tight">Now</h1>
      <p>This page rendered at {now}.</p>
      <p>It is executed on every request.</p>
    </div>
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
```

Visit `/now` and reload: the timestamp changes on every request, in development and production alike.

For comparison, create a static counterpart at `src/pages/built.tsx`:

```tsx
// ./src/pages/built.tsx
export default async function BuiltPage() {
  const builtAt = new Date().toISOString();

  return (
    <div>
      <title>Built</title>
      <h1 className="text-4xl font-bold tracking-tight">Built</h1>
      <p>This page was prerendered at {builtAt}.</p>
    </div>
  );
}
```

Reload `/built` and the timestamp does not change: even in development, a static page executes once and its result is reused. It re-executes when you edit the file, because the dev server re-renders on code changes. In the production build this becomes permanent: the page executes during `npm run build` and its output is frozen until the next build, as the next chapter shows.

## Prerender dynamic segments with staticPaths

The blog post page from the previous chapters is `render: 'dynamic'`, executing on every request. But blog posts are stable content, ideal for prerendering. A segment route can be static if you provide the list of paths to prerender. In `src/pages/blog/[slug].tsx`, replace the `getConfig` export (and add `getPosts` to the existing import from `../../lib/posts`):

```tsx
export const getConfig = async () => {
  const posts = await getPosts();

  return {
    render: 'static',
    staticPaths: posts.map((post) => post.slug),
  } as const;
};
```

At build time, Waku calls `getConfig`, receives the slugs, and prerenders one page per post. The component code is unchanged; only the declaration of when it runs is different. Note that paths outside the list now return 404 instead of rendering the component.

## Choosing a mode

- Same content for every visitor until the next deploy? **Static.**
- Depends on the request (user, cookies, headers) or must show current data? **Dynamic.**
- Stable shell with one fresh region inside it? Keep the page static and make the layout or a [slice](https://waku.gg/#slices) dynamic; modes are declared per page, per layout, and per slice, so you can mix them in one tree.

## Next Step

Declarations only matter if the build honors them. [Building for Production](/guides/building-for-production) runs the production build and shows exactly what got prerendered and what stays fresh.

---

--- METRICS ---
- Files Extracted: 40
- Estimated Token Budget: ~85813 tokens
- Canonical Reference: https://codewiki.google/github.com/wakujs/waku
