⛩️ The minimal React framework

6,390 stars TypeScript
RAW Doc

Community/Examples

Community Work

- Waku with yarn pnp @bysxx
- Waku with TailwindCSS v4 & Shadcn/ui & SSR pre-hydrated theme logic @JesseKoldewijn

---

Community/Waku En Espanol

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

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

- Visit the Guide
- Waku Official Docs
- Waku GitHub

---

_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/Getting Started/Comparison

---
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 | Astro | React Router | TanStack Start |
| --------------------------------- | ---- | ---------------------------------- | --------------------------------- | --------------------------------------- | --------------------------------------------------- |
| 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. 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 describes the application shapes Waku fits best, and the ones where another framework is the better call.

---

Guides/Getting Started/Introduction

---
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/Getting Started/Philosophy

---
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/Getting Started/Quick Start

---
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 ^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 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 middleware that the default setup picks up automatically. The starter ships one that removes trailing slashes.
- 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
- npm run build creates a production build in dist
- npm run start serves the production build at http://localhost:8080

Next Step

Read What is Waku? for a tour of what the framework offers.

---

Guides/Getting Started/Use Cases

---
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/Integrations/Explicit Caching

---
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 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), 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 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 for the details.

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

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

---
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 and check the two pages from the previous chapter:

- /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
- Cloudflare
- AWS Lambda
- Docker

Where to go from here

You have now seen every idea from The Mental Model working: file-based routing, server and client components, declared rendering modes, and a production build that honors them.

- The guides cover specific capabilities: styling, metadata, request context, middleware, and more.
- The waku-examples repository has focused, runnable examples for common patterns.
- The API reference documents the full routing convention set, server actions, and API routes.

---

Guides/Learn/Concept

---
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:

1. Pages and Layouts: routing with files
2. Server and Client Components: the component model and the 'use client' boundary
3. Static and Dynamic Rendering: declaring rendering modes and observing freshness
4. Building for Production: seeing the model hold in a real build

---

Guides/Learn/Pages And Layouts

---
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. 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 [email protected].</p>
<Link to="/" className="mt-4 inline-block underline">
Return home
</Link>
</div>
);
}

With the dev server running, visit 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.
- <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, 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 for the details of each.

Next Step

Pages so far have only rendered markup. Server and Client Components brings in data and interactivity.

---

Guides/Learn/Server And Client Components

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

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

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 explains both modes, when to choose each, and how to prerender the post pages too.

---

Guides/Learn/Static And Dynamic Rendering

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

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 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 runs the production build and shows exactly what got prerendered and what stays fresh.

---

Guides/Adapter Authoring

---
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('.//*.{tsx,ts}', { base: './pages' })),
);

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 unstable_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

waku/minimal/server exports unstable_defineServerEntry for cases where you already have a complete server entry and do not want to wrap app handlers with an adapter builder.

ts
import { unstable_defineServerEntry as defineServerEntry } from 'waku/minimal/server';

export default defineServerEntry({
fetch: async (req) => {
return new Response(Request: ${new URL(req.url).pathname});
},
build: async () => {},
});

This bypasses router and Minimal API handler helpers. 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

---
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('.//*.{tsx,ts}', { base: './pages' })),
{ 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('.//*.{tsx,ts}', { base: './pages' })),
{ 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

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:

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

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:

text
/ 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.

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

---

Guides/Cloudflare

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

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

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 and compatibility flags.

Waku attempts to stay minimal and compatible with WinterCG servers. 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.

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, 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 for more information.

For general Waku request context usage, see 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 from your Waku app.

ts
import { env, waitUntil } from 'cloudflare:workers'; // eslint-disable-line import/no-unresolved
import { unstable_getRequest as getRequest } from 'waku/router/server';

const getData = async () => {
const req = getRequest();
waitUntil(
new Promise<void>((resolve) => {
console.log('Waiting for 5 seconds');
setTimeout(() => {
console.log('OK, done waiting');
resolve();
}, 5000);
}),
);
const url = new URL(req.url);
const userId = url.searchParams.get('userId');
if (!userId) {
return null;
}
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 for more details.

Additional Handlers

Waku supports defining additional 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.

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

---
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 (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 (XSS). To mitigate this, a common practice is to set nonce attribute on <script>, together with proper Content-Security-Policy response header.

Since Waku v1.0.0-alpha.3, introduced in PR#1922, Waku has built-in support with nonce.

Waku has 2 ways to set nonce.

Hono Middleware

Hono provides a middleware which simplifies the setup of security headers. Set up a middleware in src/middleware/nonce.ts that enables Hono's 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.

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.

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

---
slug: custom-router
title: Custom Router
description: Define route, API, and slice configs manually with Waku's low-level router.
category: Low-level APIs
order: 20
---

When to Use a Custom Router

Waku's file-system router and createPages cover most applications. Use unstable_defineRouter only when you need to generate or own Waku's router config directly, for example:

- building a routing abstraction on top of Waku
- importing routes from another framework or CMS
- generating route configs from non-file-system metadata
- experimenting with custom route, API, or slice behavior

The API currently uses an unstable_ name and may change. If you only need programmatic pages, prefer the createPages reference. If you need lower-level request and build control than the router provides, see Minimal API.

Entry Points

A custom router still uses the normal Waku router client:

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 { ErrorBoundary, Router } from 'waku/router/client';

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

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

Then define the server router in src/waku.server.tsx:

text
/ Detailed source-code truncated for AI context efficiency. /

See define-router/basic for a complete example.

Path Specs

Routes, APIs, and slug slices use a path spec array instead of a path string.

ts
// /
[];

// /about
[{ type: 'literal', name: 'about' }];

// /posts/[slug]
[
{ type: 'literal', name: 'posts' },
{ type: 'group', name: 'slug' },
];

// /files/[...path]
[
{ type: 'literal', name: 'files' },
{ type: 'wildcard', name: 'path' },
];

group segments match one path segment. wildcard segments match the rest of the path. A group can also include prefix and suffix fields for segment patterns such as /@[username].

Route Configs

A route config describes how Waku renders one browser route.

Important fields:

- type: 'route' identifies a page route.
- path is the route path spec.
- isStatic marks the route as static for router metadata and build output when the path has no dynamic segments.
- rootElement renders the document root and usually includes <Children />.
- routeElement composes the active route from slots.
- elements maps slot IDs to renderers.
- slices lists slice IDs that should be included with the route payload.
- noSsr can return Waku's fallback HTML for document requests.
- pathPattern can associate a concrete static route with the dynamic pattern it came from.

Slot IDs are application-defined strings, but root, route:, and slice: are reserved by Waku.

Each element has its own isStatic flag. Static elements can be cached and reused. Dynamic elements are rendered for the current request.

Route and element renderers receive:

ts
type RendererOption = {
routePath: string;
query: string | undefined;
};

The router does not pass named params to route element renderers. If you need named params, derive them from routePath in your own router layer, or use createPages instead.

API Configs

An API config handles a request directly:

tsx
{
type: 'api',
path: [
{ type: 'literal', name: 'api' },
{ type: 'group', name: 'id' },
],
isStatic: false,
handler: async (req, { params }) => {
return Response.json({
id: params.id,
url: req.url,
});
},
}

params is derived from the path spec. Use isStatic: true only for API responses that can be emitted at build time from a literal path.

Slice Configs

Slice configs define server-rendered fragments that routes can include in their payload or that the client can request later with Waku's <Slice> component.

tsx
{
type: 'slice',
id: 'cart-summary',
isStatic: false,
renderer: async () => <CartSummary />,
}

For slug slices, provide pathSpec:

tsx
{
type: 'slice',
id: 'product-card/[id]',
pathSpec: [
{ type: 'literal', name: 'product-card' },
{ type: 'group', name: 'id' },
],
isStatic: false,
renderer: async (params) => <ProductCard id={String(params?.id)} />,
}

Include non-lazy slices in the route's slices array:

tsx
{
type: 'route',
path: [{ type: 'literal', name: 'cart' }],
// ...
slices: ['cart-summary'],
}

Build Behavior

During waku build, the custom router:

- prerenders static route configs with literal paths
- emits static API responses with literal paths
- caches static elements for reuse
- emits static slices when their ID has no slug path spec
- saves router metadata used by the production server and client prefetching

Use unstable_skipBuild to skip selected static route or API outputs:

tsx
export default adapter(
defineRouter({
getConfigs,
unstable_skipBuild: (routePath) => routePath === '/preview',
}),
);

unstable_skipBuild receives a concrete route path. It does not run for dynamic path specs that cannot be emitted as a concrete file.

Boundaries

unstable_defineRouter is lower-level than createPages, but it is not the same as the Minimal API. Waku still owns:

- router request dispatch
- RSC path encoding
- document rendering
- server action and server function plumbing
- router client metadata
- static build metadata

Avoid importing constants or helpers from waku/router/client just to reproduce Waku's internal route IDs or RSC path format. Exports such as unstable_ROUTE_ID, unstable_getRouteSlotId, and unstable_encodeRoutePath are highly experimental implementation details. Prefer plain slot IDs that you own, and let unstable_defineRouter handle Waku's reserved entries.

---

Guides/Docker

---
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 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:

text
/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.

---

Guides/Minimal Api

---
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 or Custom Router. If you are implementing a deployment/runtime adapter, start with Adapter Authoring.

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

API Surface

The minimal API is small, but it exposes both server-side handlers and client-side rendering primitives:

| Entry point | Surface | Use when |
| --------------------- | ---------------------------- | --------------------------------------------------------------------- |
| waku/minimal/server | unstable_defineHandlers | Defining handleRequest and handleBuild. |
| waku/minimal/server | unstable_defineServerEntry | Writing server entries directly, usually for adapters. |
| 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 | unstable_fetchRsc | Fetching and decoding an RSC payload. |
| waku/minimal/client | useMergeElements_UNSTABLE | Merging an RSC payload into the current element map. |
| waku/minimal/client | unstable_* helpers | Building router-like abstractions. |

Examples below often import these as shorter names.
This guide does not document every exported helper from waku/minimal/client;
undocumented exports are for Waku internals or custom integrations.

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 { unstable_defineHandlers as defineHandlers } 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 = defineHandlers({
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);
}

unstable_defineHandlers is optional. It is currently an identity helper that gives you a typed place to define handlers before passing them to an adapter. Most examples in this repository pass the handler object directly to adapter(...).

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

#### unstable_defineHandlers

unstable_defineHandlers is the main server-side entry point from waku/minimal/server.

tsx
import { unstable_defineHandlers as defineHandlers } from 'waku/minimal/server';

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

If you do not need that, this is equivalent:

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 unstable_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. |
| 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: '' }.
- 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.
- Throwing from handleRequest turns into an HTTP error response, and a custom error keeps its status. A location is a redirect a document request answers with a 3xx, but a fetch cannot follow one it is unable to read, so an RSC or server function request answers 200 and the client throws an error whose info carries the location and unstable_leave, for you to navigate to.

#### 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) | Write Waku's default fallback HTML to dist/public. |

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
- children: ReactNode

Important behavior:

- initialRscPath defaults to ''.
- Root is required for Slot, Children, and useMergeElements.
- The client store is a module-level singleton (normally one per document), so mounting multiple Root instances is not supported.
- Root injects default head tags for charset, viewport, and generator.
- For SSR or SSG, use hydrateRoot(document, rootElement) when globalThis.__WAKU_HYDRATE__ is set.
- 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.

#### 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 {
unstable_fetchRsc as fetchRsc,
unstable_registerRscReloadListener as registerRscReloadListener,
useMergeElements_UNSTABLE as useMergeElements,
} from 'waku/minimal/client';

const useRefetch = () => {
const mergeElements = useMergeElements();
return useCallback(
(rscPath: string, rscParams?: unknown) => {
const refetch = () => mergeElements(fetchRsc(rscPath, rscParams));
registerRscReloadListener(
() => {
void refetch();
},
{ replace: true },
);
return refetch();
},
[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.
- The reload registration keeps development HMR on the refetched RSC path and has no effect in production.
- If you return only InnerApp, previously rendered elements such as App stay mounted.
- If the refetch fails, the existing element map stays in place.

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

waku/minimal/server also exports unstable_defineServerEntry. That is a lower-level hook than unstable_defineHandlers and is meant for adapter authors who need to work directly with server entries, request processing, and build processing. Most advanced users of the minimal API do not need it.

For adapter implementation details, see Adapter Authoring.

Examples

- Minimal SSR and prerendered root route
- Using Children to compose client content into a server tree
- Server functions returning a value and updated elements
- Nested slots, useRefetch, runWithRequest, and fallback HTML generation
- Server actions and formState
- Custom API endpoints alongside minimal SSR
- SPA-style build using generateDefaultHtml

---

Guides/Monorepo

---
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 for the original duplicate React report.

---

Guides/Navigation Prefetching

---
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 targets as router.push(): a typed route href (autocompleted from your routes), or a structured { to, params, search, hash } target for prefetching a dynamic route. For a computed string that is not a known route, cast it with as Unstable_RouteHref.

tsx
router.prefetch({ to: '/posts/[slug]', params: { slug } });

For navigating to dynamic routes with typed params, see Typed Routes.

<Link> also has experimental prefetch helpers for common interaction patterns:

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 custom unstable_startTransition therefore does not run for that commit. When the shell is not cached, navigation waits for the response and uses the custom transition to commit the destination; useNavigationStatus_UNSTABLE() reports pending for that wait. When the cache commit is available, the shell itself is the pending state.

Pending UI

A descendant of <Link> can read the navigation status with useNavigationStatus_UNSTABLE() and render pending UI while a navigation transition is in progress. It works like React's useFormStatus: it reflects the nearest enclosing <Link>, and pending stays true until the destination route's async components resolve (including client-only Suspense).

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,
Unstable_RoutePath as RoutePath,
} from 'waku/router/client';
import { PendingIndicator } from './pending-indicator';

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

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

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

Observe Route Changes

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

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: a newer navigation can supersede the request, and a render-time redirect or 404 can start another navigation after it settles. Observe the route fields above when you need the destination that was committed.

Custom Transitions

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

tsx
'use client';

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

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

export const ViewTransitionLink = <Path extends RoutePath>(
props: LinkProps<Path>,
) => <WakuLink {...props} unstable_startTransition={startViewTransition} />;

Because unstable_startTransition replaces React's transition, useNavigationStatus_UNSTABLE() stays { pending: false } for links that use it.

The prefetch, instant-navigation, and transition props in this guide are experimental and may change.

---

Guides/No Ssr

---
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 Programmatic Route

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 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('.//*.tsx', { base: './pages' }));

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

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

---
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 — 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 awaits 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

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

---

Guides/Redirect Maps

---
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 corresponding RSC payload route, such as /RSC/R/old.txt

If you want /old to behave like a redirect to /new even during client-side navigation, you usually need to redirect both paths.

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 in future versions of Waku.

If you customize basePath or rscBase, adjust the redirect rules accordingly.

For application-level redirects implemented inside Waku routes or server actions, see 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';

const redirectsMiddleware = (): MiddlewareHandler => async (c, next) => {
const url = new URL(c.req.raw.url);

// With the default Waku config, redirect both the browser route
// and the corresponding RSC payload route.
const redirects: Record<string, string> = {
'/old': '/new',
'/RSC/R/old.txt': '/RSC/R/new.txt',
// ... add more redirects here
};

if (url.pathname in redirects) {
url.pathname = redirects[url.pathname]!;
c.res = new Response(null, {
status: 302,
headers: { location: url.toString() },
});
return;
}

return await next();
};

export default redirectsMiddleware;

This only handles the paths you explicitly match. If you redirect only /old and not /RSC/R/old.txt, direct requests may work while client-side navigation can still fail or land on the wrong state.

Redirect via hosting environment

This very much depends on the hosting environment you are using. For example, on Vercel 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 and Cloudflare Pages will respect a _redirects file that you can place in the public folder:

text
/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 instead of external redirect maps.

---

Guides/Redirects And Not Found

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

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.

redirect also accepts the same typed target as router.push / router.replace: a structured { to, params, search, hash }, so a dynamic route's params and a route's typed search are checked and serialized for you:

tsx
redirect({ to: '/posts/[slug]', params: { slug: post.slug } });
redirect({ to: '/products', search: { q: 'waku', page: 1 } }, 303);

search is serialized with that route's search codec (see the Typed Routes guide).

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_redirect as redirect } from 'waku/router/server';
import { createPost } from '../../lib/posts';

export default function NewPostPage() {
return (
<form
action={async (formData) => {
'use server';

const post = await createPost({
title: String(formData.get('title') || ''),
});

redirect({ to: '/posts/[slug]', 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.

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

Redirects and not-found responses work best when they are thrown before the page has started streaming meaningful content. Once a response has started streaming to the browser, the server may not be able to change the 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

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

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

Read the Current Request

Use unstable_getRequest in server-only code to access the current Request:

tsx
import { unstable_getRequest as getRequest } from 'waku/router/server';

const getCurrentPath = () => {
const req = getRequest();
return new URL(req.url).pathname;
};

export default function Page() {
const path = getCurrentPath();

return <p>Current path: {path}</p>;
}

getRequest() throws if request context is not available. Call it during request handling, not in module scope.

unstable_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 unstable_getRequest().url can differ. Use the handler argument for routing-specific values and unstable_getRequest() for the original request.

unstable_getRequest and unstable_getHeaders are exported from waku/router/server and are available wherever the router runs your code: renders (request and build), API route handlers, and interceptors. 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 unstable_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. (During a static route build unstable_getRequest().url is the route's path, but a static slice build uses a placeholder root request that does not identify the slice.)

Read Request Headers

For headers, use unstable_getHeaders:

tsx
import { unstable_getHeaders as getHeaders } from 'waku/router/server';

export default function Page() {
const headers = getHeaders();
const userAgent = headers['user-agent'] || 'unknown';

return <p>User agent: {userAgent}</p>;
}

The returned object is a snapshot of the request headers for the current request.

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 unstable_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 { unstable_getHeaders as getHeaders } from 'waku/router/server';
import { runWithRequestData } from '../../lib/request-data.js';

const requestDataInterceptor: HandlerInterceptor = (next) => {
const country = getHeaders()['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 { unstable_getHeaders as getHeaders } from 'waku/router/server';
import { runWithRequestData } from './lib/request-data.js';

createPages(async ({ createPage, createInterceptor }) => {
createInterceptor((next) => {
const country = getHeaders()['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>;
}

If the value is just a request header, you can also read it from unstable_getHeaders() 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 unstable_getHeaders() 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 { unstable_getHeaders as getHeaders } from 'waku/router/server';

export default function Page() {
const cookies = cookie.parseCookie(getHeaders()['cookie'] ?? '');

return <p>Session: {cookies.sessionId ?? 'none'}</p>;
}

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 { unstable_getRequest as getRequest } from 'waku/router/server';

export default async function Page() {
const req = getRequest();
const url = new URL(req.url);
const id = url.searchParams.get('id');
const item = id
? await env.DB.prepare('SELECT * FROM item WHERE id = ?').bind(id).first()
: null;

return <pre>{JSON.stringify(item, null, 2)}</pre>;
}

See Run Waku on Cloudflare for the full Cloudflare setup.

---

Guides/Router Fetch Strategy

---
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/Static Deployments

---
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('.//*.{tsx,ts}', { base: './pages' })),
{ 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/Stream Intercept

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

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/Typed Routes

---
slug: typed-routes
title: Typed Routes
description: Type-safe navigation to dynamic routes with router.push and router.replace.
category: Routing and Navigation
order: 11
tags: [Experimental]
---

Overview

Waku's router provides type-safe routing helpers layered on top of the string-based APIs. The string APIs keep working; the typed helpers are additive.

Typed Navigation

router.push() and router.replace() accept a structured target, so you can navigate to a dynamic route without interpolating slugs by hand. The params are type-checked against the route pattern:

tsx
'use client';

import { useRouter } from 'waku';

export const PostButton = ({ slug }: { slug: string }) => {
const router = useRouter();

return (
<button
onClick={() =>
router.push({
to: '/posts/[slug]',
params: { slug },
hash: 'top',
})
}
>
Open post
</button>
);
};

- to is a route pattern (e.g. /posts/[slug], /docs/[...path]). params is required when the pattern has slugs and is typed from it; a catch-all takes a string[].
- hash is optional. search is typed per route by a search codec (see Search Params).
- The plain string form (router.push('/posts/hello')) keeps working.
- For same-page navigation, a bare #hash or ?query (e.g. router.push('#section') or router.push('?tab=2')) is resolved against the current URL; a #hash also scrolls to that element.

Reading Route Params

useParams() reads the current route's params, typed from the route pattern you pass. It returns null when the current path does not match that pattern, so it is safe to call from a component rendered under more than one route.

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

- The result is typed from the pattern: /posts/[slug] gives { slug: string }, and a catch-all like /docs/[...path] gives { path: string[] }.
- Values are URL-decoded, mirroring how router.push encodes them.
- It re-renders when the current route path changes.
- Page components already receive their params through props; useParams() is for client and shared components that do not.

Search Params

Waku types the URL search params (the query string) per route with a search codec you provide. The codec converts the query string to a typed object and back, so server components, client hooks, and navigation share one typed shape. Waku ships the contract; you bring the implementation (hand-written, or wrapping a library).

Define a codec

A codec is a plain object with a stable id, a parse, and a serialize:

ts
// ./src/lib/search.ts
import type { Unstable_SearchCodec } from 'waku/router';

type ProductsSearch = { q: string; page: number };

export const searchCodec = {
id: 'search',
parse: (query: string): ProductsSearch => {
const params = new URLSearchParams(query);
return { q: params.get('q') ?? '', page: Number(params.get('page')) || 1 };
},
serialize: ({ q, page }: ProductsSearch) =>
new URLSearchParams({ q, page: String(page) }).toString(),
} satisfies Unstable_SearchCodec<ProductsSearch>;

parse may throw to reject a malformed query; Waku turns that into a 400.

satisfies Unstable_SearchCodec<ProductsSearch> validates the codec where you define it. You can drop it (and the import) if you annotate parse/serialize with your ProductsSearch type yourself; the route still type-checks the codec when you attach it.

Wrapping a validation library

A codec is just { id, parse, serialize }, so you can back it with a schema or query-state library instead of hand-writing the logic. Use the library for parse (validation and coercion) and let it (or URLSearchParams) build the query string in serialize.

With Zod:

ts
// ./src/lib/search.ts
import { z } from 'zod';
import type { Unstable_SearchCodec } from 'waku/router';

const schema = z.object({
q: z.string().default(''),
// .default only fills a missing value; a bad value throws (-> 400).
// swap it for .catch(1) to fall back to 1 instead
page: z.coerce.number().int().min(1).default(1),
});

type ProductsSearch = z.infer<typeof schema>;

export const searchCodec = {
id: 'search',
parse: (query: string): ProductsSearch =>
schema.parse(Object.fromEntries(new URLSearchParams(query))),
serialize: (search: ProductsSearch): string =>
new URLSearchParams({
q: search.q,
page: String(search.page),
}).toString(),
} satisfies Unstable_SearchCodec<ProductsSearch>;

With nuqs (its nuqs/server entry has no 'use client', so the codec stays isomorphic):

ts
// ./src/lib/search.ts
import {
createLoader,
createSerializer,
parseAsInteger,
parseAsString,
} from 'nuqs/server';
import type { Unstable_SearchCodec } from 'waku/router';

const parsers = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
};

const loadSearch = createLoader(parsers);
const serializeSearch = createSerializer(parsers);

type ProductsSearch = { q: string; page: number };

export const searchCodec = {
id: 'search',
// nuqs loaders don't validate by default; { strict: true } throws on a
// bad value (-> 400). drop it to fall back to the parser defaults instead
parse: (query: string): ProductsSearch =>
loadSearch(new URLSearchParams(query), { strict: true }),
// createSerializer prepends "?"; the codec returns the query without it
serialize: (search: ProductsSearch): string =>
serializeSearch(search).replace(/^\?/, ''),
} satisfies Unstable_SearchCodec<ProductsSearch>;

Using nuqs here differs from its own server-side approach. With Waku, nuqs is only the parser/serializer (nuqs/server); the codec is registered with the router by id, so Waku runs parse as part of the request (typed props.search, same codec on the client). You don't wire up nuqs's own server pieces like createSearchParamsCache or NuqsAdapter / useQueryState.

Attach the codec to a route

Set it on the route's config, and map the route to the codec for typing.

With createPages, pass unstable_searchCodec and declare the mapping:

tsx
import { searchCodec } from './lib/search';

declare module 'waku/router' {
interface SearchCodecsConfig {
'/products': typeof searchCodec;
}
}

// inside createPages(...)
createPage({
render: 'dynamic',
path: '/products',
component: ProductsPage,
unstable_searchCodec: searchCodec,
});

With the file-system router, export it from getConfig and the types are generated for you:

tsx
// ./src/pages/products.tsx
import { searchCodec } from '../lib/search';

export const getConfig = async () => ({
render: 'dynamic',
unstable_searchCodec: searchCodec,
});

Read search on the server

The page component receives a typed search prop:

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

export default function ProductsPage({ search }: PageProps<'/products'>) {
return (
<p>
{search.q} (page {search.page})
</p>
);
}

Provide codecs on the client

To read, update, or navigate with search on the client, the codec has to be available there. Wrap your app with Unstable_SearchCodecsProvider. Because codecs hold functions, the provider must be rendered from a 'use client' module that imports them:

tsx
// ./src/components/search-codecs.tsx
'use client';

import type { ReactNode } from 'react';
import { Unstable_SearchCodecsProvider } from 'waku/router/client';
import * as searchCodecs from '../lib/search';

export const SearchCodecs = ({ children }: { children: ReactNode }) => (
<Unstable_SearchCodecsProvider searchCodecs={searchCodecs}>
{children}
</Unstable_SearchCodecsProvider>
);

Pass only codecs. import * as works when the module exports just codecs (types are erased); if the module also has non-codec exports, list the codecs explicitly instead, e.g. searchCodecs={[searchCodec]}. Non-codec values are ignored with a warning.

Render it in your root layout so it wraps every page:

tsx
// ./src/pages/_layout.tsx (fs-router root layout)
import type { ReactNode } from 'react';
import { SearchCodecs } from '../components/search-codecs';

export default function RootLayout({ children }: { children: ReactNode }) {
return <SearchCodecs>{children}</SearchCodecs>;
}

With createPages, wrap your root layout (or root) component's children the same way.

Provide codecs app-wide, not per route. A codec must be available on the page you navigate from. If a link on your home page does push({ to: '/products', search }), the home page needs /products's codec to serialize the URL, so a provider only around /products is not enough. Wrapping your root layout covers every page.

Read and update search on the client

tsx
'use client';

import {
useSearch_UNSTABLE as useSearch,
useSetSearch_UNSTABLE as useSetSearch,
} from 'waku/router/client';

export const Pager = () => {
const search = useSearch({ from: '/products' });
const setSearch = useSetSearch({ from: '/products' });
if (!search) {
return null;
}
return (
<button onClick={() => setSearch((prev) => ({ page: prev.page + 1 }))}>
page {search.page}
</button>
);
};

- useSearch returns the typed search, or null when the current path does not match from.
- setSearch takes a partial or an updater, serializes with the codec, and navigates (push by default, or { history: 'replace' }). Like useSearch, it's a no-op when the current path does not match from (or that route has no codec).

push, replace, and Link accept a typed search for the target route:

tsx
router.push({ to: '/products', search: { q: 'waku', page: 1 } });

<Link to={{ to: '/products', search: { q: 'waku', page: 1 } }}>Products</Link>;

A route without a codec takes no search (its search is never); use the plain string form (push('/foo?ref=home')) for ad-hoc queries.

---

Create Pages

---
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 build output uses fallback HTML for that route. 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>;
}
interface CreatePagesConfig {
pages: typeof pages;
}
}

export default adapter(pages);

Once this is done, any <Link /> component or hook from waku/router that uses paths in your app will use this type. 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.

#### PageProps

The PageProps type gives you type safety for the path and slug parameters in your pages.

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 <>{/ .../}</>;
};

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

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 unstable_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 for seeding your own store and Configure 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.

---