waku Documentation

️ The minimal React framework

6,474 stars TypeScript TypeScript 40 files Β· ~84,361 tokens
RAW Doc

Technical Documentation: wakujs/waku

ℹ️ Provenance: Multi-Tier Ingestion Β· CodeWiki Reference

Overview (`README.md`)

Waku

⛩️ The minimal React framework

visit waku.gg or npm create waku@latest




Introduction

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

Getting started

Start a new Waku project with the create command for your preferred package manager. It will scaffold a new project with our default Waku starter.

sh
npm create waku@latest

Commands

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

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

For a guided path, start with the Quick Start guide and continue with the Learn series on waku.gg/guides, which builds a small app step by step.

Rendering

While there's a bit of a learning curve to modern React rendering, it introduces powerful new patterns of full-stack composability that are only possible with the advent of server components.

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

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

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

Server components

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

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

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

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

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

Client components

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

tsx
// client component
'use client';

import { useState } from 'react';

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

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

Shared components

Simple React components that meet all of the rules of both server and client components can be imported into either server or client components without affecting the server-client boundary.

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

Weaving patterns

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

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

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

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
tsx
// ./src/components/providers.tsx
'use client';

import { Provider } from 'jotai';

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

Server-side rendering

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

tl;dr:

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

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

Server components can be rendered below this boundary, but only via composition (e.g., children props). Together they form a new "React server" layer that runs before the traditional "React client" layer with which you're already familiar.

Client components are still server-side rendered as SSR is separate from RSC. Please see the linked diagrams for a helpful visual.

Further reading

To learn more about the modern React architecture, we recommend Making Sense of React Server Components and The Two Reacts.

Routing

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

Its underlying low-level API is also available for those that prefer programmatic routing. This documentation covers file-based routing since many React developers prefer it, but please feel free to try both and see which you like more!

Overview

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

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

Waku currently supports two rendering options:

  • 'static' for static prerendering (SSG)

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

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

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

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

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

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

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
tsx
// ./src/pages/index.tsx

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

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

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

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

Pages

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

Single routes

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

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

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

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
tsx
// ./src/pages/blog/index.tsx

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

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

Segment routes

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

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

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

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

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

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

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

export const getConfig = async () => {
  return {
    render: 'static',
    staticPaths: ['introducing-waku', 'introducing-pages-router'],
  } as const;
};
tsx
// ./src/pages/shop/[category].tsx
import type { PageProps } from 'waku/router';

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

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

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

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

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

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

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

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

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

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

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

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

Nested segment routes

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

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

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

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

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

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

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

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

Catch-all routes

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

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

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

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

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

Group routes

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

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

text
β”œβ”€β”€ (main)
β”‚ β”œβ”€β”€ _layout.tsx
β”‚ β”œβ”€β”€ about.tsx
β”‚ └── contact.tsx
└── index.tsx

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

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

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

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
tsx
// ./src/pages/(main)/about.tsx
export default async function AboutPage() {
  return <h1>About Us</h1>;
}

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

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

text
(main)
β”œβ”€β”€ (dynamic)
β”‚ β”œβ”€β”€ _layout.tsx # dynamic layout
β”‚ β”œβ”€β”€ dashboard.tsx
β”‚ └── profile.tsx
└── _layout.tsx # static layout

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

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

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
tsx
// ./src/pages/(main)/(dynamic)/_layout.tsx
// Dynamic layout - runs at request time
export default async function DynamicLayout({ children }) {
  const userData = await fetchUserData(); // Dynamic data fetching

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

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

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

Ignored routes

The following directories are ignored by the router:

  • _actions
  • _components
  • _hooks

All files inside these directories are excluded from routing.

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

text
pages/
β”œβ”€β”€ about.tsx
β”œβ”€β”€ _components/
β”‚   β”œβ”€β”€ header.tsx   // πŸ‘ˆπŸΌ ignored
β”‚   β”œβ”€β”€ footer.tsx   // πŸ‘ˆπŸΌ ignored
β”‚   β”œβ”€β”€ ...          // πŸ‘ˆπŸΌ ignored

Router paths type safety

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

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

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

Layouts

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

Root layout

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

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

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

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

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
tsx
// ./src/components/providers.tsx
'use client';

import { createStore, Provider } from 'jotai';

const store = createStore();

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

Other layouts

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

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

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

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

Root element

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

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

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

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

Slices

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

Creating slices

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

text
src/pages
 β”œβ”€β”€ _slices
 β”‚   β”œβ”€β”€ one.tsx
 β”‚   β”œβ”€β”€ two.tsx
 β”‚   └── nested
 β”‚       └── three.tsx
 └── some-page.tsx

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

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

// Create slice component
export default function SliceOne() {
  return <p>πŸ•</p>;
}

export const getConfig = () => {
  return {
    render: 'static', // default is 'static'
  };
};
tsx
// ./src/pages/_slices/nested/three.tsx

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

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

Using slices

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

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

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

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

Lazy slices

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

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

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

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

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

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

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

to is a URL string. For typed params and search, use unstable_createRouteHref from waku/router to construct the string (see Typed Routes). The same strings work with router.push, router.replace, and router.prefetch.

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

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

useRouter

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

router properties

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

tsx
'use client';

import { useRouter } from 'waku';

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

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

router methods

The router object also contains several methods for programmatic navigation:

  • router.push(to) - navigate to the provided URL string, including strings constructed with unstable_createRouteHref (see Typed Routes)

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

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

  • router.reload() - reload the current route

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

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

tsx
'use client';

import { useRouter } from 'waku';

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

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

Error handling

Waku sets up a default error boundary at the root of your application. You can customize error handling by adding your own error boundaries anywhere, for example with the react-error-boundary library.

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

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

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

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

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

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

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

Metadata

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

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

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

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

Metadata can also be generated programmatically.

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

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

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

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

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

Styling

Global styles

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

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

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

export const getConfig = async () => {
  return {
    render: 'static',
  } as const;
};
css
/* ./src/styles.css */
@import 'tailwindcss';
js
// ./waku.config.ts
import { defineConfig } from 'waku/config';
import tailwindcss from '@tailwindcss/vite';

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

Static assets

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

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

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

File system

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

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

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

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

Middleware

In the default Waku setup, files in ./src/middleware are automatically loaded as Hono middleware. Each file should default export a function that returns a MiddlewareHandler.

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

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

export default logger;

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

Interceptors

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

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

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

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

export default logger;

Data fetching

Server

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

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

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

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

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

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

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

Client

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

Mutations

Data mutations can be performed via server actions or API endpoints.

API endpoints

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

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

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

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

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

  try {
    await client.sendEmail({
      From: '[email protected]',
      To: '[email protected]',
      Subject: 'Contact form submission',
      Body: body.message,
    });

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

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

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

Typed route parameters

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

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

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

This also works with multiple parameters and wildcard routes:

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

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

Calling API routes

API routes are accessible at paths with the _api prefix stripped. For example a file at ./src/pages/_api/contact.ts is available at /contact, and ./src/pages/_api/blog/rss.xml.ts is available at /blog/rss.xml. You can call these endpoints from your client components using the standard Fetch method.

tsx
'use client';

import { useState } from 'react';

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

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

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

      const data = await response.json();

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

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

Configuring API routes

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

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

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

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

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

Server actions

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

Defining and protecting actions

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

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

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

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

Making and consuming actions

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

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

import db from 'some-db';

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

  return <ContactForm sendMessage={sendMessage} />;
}
tsx
// ./src/components/contact-form.tsx
'use client';

import { useState } from 'react';

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

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

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

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

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

import db from 'some-db';

export async function sendMessage(message: string) {
  await db.messages.create(message);
}
tsx
// ./src/components/contact-button.tsx
'use client';

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

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

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

Invoking actions

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

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

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

import db from 'some-db';

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

  await db.messages.create(message);
}
tsx
// ./src/components/create-todo-button.tsx
'use client';

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

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

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

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

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

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

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

Enhancing actions

Server actions integrate with many other React APIs such as the useTransition hook for handling pending states, the useActionState hook for accessing returned values, and the useOptimistic hook for performing optimistic UI updates.

See the talk What's new in React 19? to learn more.

State management

We recommend Jotai for global React state management based on the atomic model's performance and scalability, but Waku is compatible with all React state management libraries such as Zustand and Valtio.

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

Environment variables

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

Private

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

Public

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

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

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

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

  return <>{/* ...*/}</>;
};
tsx
// client components can only access public variables
'use client';

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

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

Node.js

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

Deployment

Node.js (default)

After building, waku start runs the production server.

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

Pure SSG

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

Vercel

Waku projects can be deployed to Vercel with the Vercel CLI automatically.

sh
vercel

Pure SSG with Vercel

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

./src/waku.server.tsx:

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

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

Netlify

Waku projects can be deployed to Netlify with the Netlify CLI.

sh
NETLIFY=1 npm run build
netlify deploy

Pure SSG with Netlify

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

./src/waku.server.tsx:

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

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

Cloudflare Workers

Waku projects can be deployed to Cloudflare Workers with Wrangler.

sh
CLOUDFLARE=1 npm run build
wrangler deploy

Pure SSG with Cloudflare Workers

./src/waku.server.tsx:

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

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

Deno Deploy (experimental)

./src/waku.server.tsx:

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

export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')));
sh
npm run build
deployctl deploy --prod dist/serve-deno.js --exclude node_modules

Bun (experimental)

./src/waku.server.tsx:

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

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

AWS Lambda (experimental)

./src/waku.server.tsx:

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

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

The handler entrypoint is dist/serve-aws-lambda.js: see Hono AWS Lambda Deploy Docs.

Edge

waku/adapters/edge adapter provides a minimal server output without deployment target specific code. For example, you can use it with Nitro to handle packaging for various deployment platforms. See waku-nitro-example for the example.

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

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

Community

Please join our friendly GitHub discussions or Discord server to participate in the Waku community. Hope to see you there!

Roadmap

Waku is in active development and we're seeking additional contributors. Check out our roadmap for more information.

Contributing

If you would like to contribute, please see CONTRIBUTING.md!


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


slug: create-pages title: createPages

Routing (low-level API)

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

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

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

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

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

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

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

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

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

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

export default adapter(pages);

Pages

Single routes

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

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

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

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

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

export default adapter(pages);

Segment routes

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

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

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

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

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

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

export default adapter(pages);

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

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

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

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

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

export default adapter(pages);

Nested segment routes

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

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

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

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

export default adapter(pages);

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

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

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

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

export default adapter(pages);

Catch-all routes

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

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

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

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

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

export default adapter(pages);

Advanced page options

Disabling SSR

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

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

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

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

export default adapter(pages);

This option is unstable and may change.

Exact paths

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

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

This is primarily intended for custom router integrations.

Router paths type safety

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

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

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

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

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

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

export default adapter(pages);

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

For parameterized URLs, use unstable_createRouteHref from waku/router. Its factories infer params and search from a local pattern and optional codec. This augmentation checks raw string hrefs, not factory patterns: without generated route metadata, a factory for an unknown path is accepted by navigation. See Typed Routes.

PageProps

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

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

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

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

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

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

Layouts

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

Root layout

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

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

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

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

export default adapter(pages);
tsx
// ./src/templates/root-layout.tsx
import '../styles.css';

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

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

import { createStore, Provider } from 'jotai';

const store = createStore();

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

Other layouts

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

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

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

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

export default adapter(pages);
tsx
// ./src/templates/blog-layout.tsx
import { Sidebar } from '../components/sidebar';

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

Root component

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

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

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

Add this with createRoot function inside createPages.

Note About ``

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


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

Community Work


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


slug: waku-en-espanol title: Learn Waku in Spanish 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

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


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


slug: adapter-authoring title: Adapter Authoring category: Low-level APIs order: 30

When to Write an Adapter

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

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

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

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

Mental Model

A Waku server entry has two jobs:

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

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

unstable_createServerEntryAdapter is the usual adapter-authoring helper:

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

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

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

Your adapter decides when and where to call them.

Minimal Fetch Adapter

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

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

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

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

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

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

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

Hono-Based Adapter

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

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

const { rscMiddleware, middlewareRunner } = honoMiddleware;

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

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

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

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

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

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

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

Static Assets

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

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

const { DIST_PUBLIC } = constants;

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

Adapter Builder Inputs

The callback passed to createServerEntryAdapter receives:

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

The returned server entry can include:

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

Platform Environment Bindings

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

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

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

Platform Default Exports

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

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

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

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

Build Enhancers

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

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

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

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

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

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

type BuildOptions = {
  distDir: string;
};

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

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

Preview Server

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

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

It returns:

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

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

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

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

Direct Server Entries

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

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

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

export default entry;

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

Practical Boundaries

Adapter authors should keep these boundaries clear:

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

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


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


slug: aws-lambda title: Deploy Waku to AWS Lambda category: Deployment order: 20 tags: [Experimental]

Configure the AWS Lambda Adapter

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

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

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

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

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

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

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

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

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

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

Installation

Add this Serverless Framework plugin to your project:

sh
pnpm add -D serverless-scriptable-plugin

Setup

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

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

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

plugins:
  - serverless-scriptable-plugin

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

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

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

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

Deploy

sh
pnpx serverless deploy

Output:

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 (`docs/guides/cloudflare.mdx`)


slug: cloudflare title: Run Waku on Cloudflare 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

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

Dev Mode

The @cloudflare/vite-plugin configured in waku.config.ts runs your code in the Cloudflare workerd runtime during local development, so Cloudflare bindings like KV, D1, etc. are available in your server components and functions without additional setup. See Cloudflare's Vite plugin documentation 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 (`docs/guides/csp.mdx`)


slug: csp title: Configure CSP 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 (`docs/guides/custom-router.mdx`)


slug: custom-router title: Custom Router category: Low-level APIs order: 20

When to Use a Custom Router

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

For programmatic page and layout declarations, use createPages. For a different client protocol or lower-level request handling, use the Minimal API.

Server Entry

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

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

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

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

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

Each source uses Minimal's Unstable_ElementSource:

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

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

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

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

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

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

Client Entry

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

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

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

HTTP Handlers

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

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

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

Build Paths

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

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

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

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

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

Independently Requested Elements

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

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

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

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

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

Boundaries

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

For changes to Router itself or another client binding, see Router Protocol.

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

The API is unstable and may change.


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


slug: docker title: Dockerise a Waku app 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 Metadata (`docs/guides/metadata.mdx`)


slug: metadata title: Metadata category: Routing and Navigation order: 45

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

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

Pages override layouts

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

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

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

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

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

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

Everything else is your responsibility

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

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

Overriding charset and viewport

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

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

An app that defines its own root, or that renders the document itself through
the minimal API, owns these tags and must declare the
ones it wants.


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


slug: migrating-from-nextjs title: Migrating from Next.js category: Getting Started order: 60

Before you start

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

This guide is written from four migrations of real Next.js apps, which live in
the waku-examples repository and are
referenced throughout:

Example Migrated from What it exercises
nextjs-blog-starter vercel/next.js examples/blog-starter static generation, metadata, markdown, CSS modules
nextjs-dashboard vercel/next-learn dashboard/final-example auth, a database, server actions, streaming, search
nextjs-commerce vercel/commerce caching, cookies, optimistic UI, sitemap and OG images
nextjs-photo-modal vercel/nextgram parallel and intercepting routes

The shape of the project

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

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

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

The document shell moves

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

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

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

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

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

Every page declares how it renders

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

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

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

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

Params and search params arrive as props

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

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

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

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

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

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

Metadata

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

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

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

Routing APIs

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

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

Typed routes reject broad strings

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

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

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

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

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

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

Files that were conventions

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

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

Caching, or the lack of it

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

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

Two exceptions:

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

Cookies, sessions and route protection

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

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

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

Parallel and intercepting routes

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

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

Things Next.js provides that Waku does not

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

Build and tooling details

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

A migration order that works

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

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


slug: minimal-api title: Minimal API 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.

Waku Router's use of these primitives is described in Router Protocol. Its route IDs and navigation policy belong to Router, not Minimal.

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

API Surface

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

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

Examples below often import these as shorter names.

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

Advanced composition APIs remain available to any Minimal-based framework:

  • unstable_combineElements(a, b, { filter }) selects keys from b
    while preserving their validators.
  • unstable_isImmutableElement(elements, slotId) inspects a slot's immutable
    status without exposing its validator.
  • useMergeElements_UNSTABLE accepts a merge callback for custom updates to
    the Root's queued element promise.
  • unstable_buildElements, Unstable_ElementSource, and
    Unstable_BuiltElements build payloads that omit slots the client already
    holds, using the etags supplied to handleRequest.
  • unstable_parseRequest(request) inspects HTTP, RSC, and server-call URLs
    without consuming their bodies. unstable_formatRscUrl(rscPath, baseUrl)
    constructs a payload URL using the configured base path and RSC endpoint.
    These support middleware in Minimal-based frameworks; request inspection
    does not validate actions or authorize access.

Mental Model

At this level, Waku gives you rendering primitives and very little policy.

  • handleRequest decides how each incoming request is handled.
  • renderRsc renders a React Server Components payload as a ReadableStream.
  • renderHtml renders the HTML shell that boots the client.
  • Root fetches and owns the current RSC payload on the client.
  • Slot renders a named element from the RSC payload.
  • handleBuild decides which files are emitted during waku build.

Two terms are important:

  • RSC ID: the key of an element returned by renderRsc.
  • rscPath: an application-defined string that identifies which RSC payload to fetch. Waku treats it as opaque.

If the server returns:

tsx
return renderRsc({
  App: <App />,
  Sidebar: <Sidebar />,
});

then the client can render those elements with:

tsx
<Root>
  <Slot id="App" />
  <Slot id="Sidebar" />
</Root>

End-To-End Example

A minimal SSR setup has two entry points:

  • src/waku.server.tsx
  • src/waku.client.tsx

src/waku.server.tsx:

tsx
import type { Unstable_Handlers as Handlers } from 'waku/minimal/server';
import adapter from 'waku/adapters/default';
import { Slot_UNSTABLE as Slot } from 'waku/minimal/client';
import App from './components/App.js';

const handlers: Handlers = {
  handleRequest: async (input, { renderRsc, renderHtml }) => {
    if (input.type === 'rsc') {
      return renderRsc({ App: <App name={input.rscPath || 'Waku'} /> });
    }
    if (input.type === 'http' && input.pathname === '/') {
      const rscPath = '';
      return renderHtml(
        await renderRsc({ App: <App name="Waku" /> }),
        <Slot id="App" />,
        { rscPath },
      );
    }
    return null;
  },

  handleBuild: async ({
    renderRsc,
    renderHtml,
    rscPath2pathname,
    generateFile,
  }) => {
    const rscPath = '';
    const stream = await renderRsc({ App: <App name="Waku" /> });
    const [rscStream, htmlStream] = stream.tee();
    await generateFile(rscPath2pathname(rscPath), rscStream);
    const html = await renderHtml(htmlStream, <Slot id="App" />, { rscPath });
    await generateFile('index.html', html.body!);
  },
};

export default adapter(handlers);

src/waku.client.tsx:

tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import {
  Root_UNSTABLE as Root,
  Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';

const rootElement = (
  <StrictMode>
    <Root>
      <Slot id="App" />
    </Root>
  </StrictMode>
);

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

Passing a handler object directly to adapter(...) also infers these types.

Request Flow

In the example above, a request to / looks like this:

  1. The adapter receives GET / and calls handleRequest with input.type === 'http'.
  2. handleRequest renders an RSC payload with renderRsc(...).
  3. handleRequest passes that payload to renderHtml(...) together with an HTML tree containing <Slot id="App" />.
  4. The browser loads src/waku.client.tsx.
  5. <Root> fetches the RSC payload for rscPath === ''.
  6. <Slot id="App" /> renders the server element stored under the App key.

Server API

Handler Objects

Define handleRequest and handleBuild in the object passed to an adapter.
Use Handlers when defining the object separately:

tsx
import type { Unstable_Handlers as Handlers } from 'waku/minimal/server';

const handlers: Handlers = {
  handleRequest: async () => null,
  handleBuild: async () => {},
};

Use it when you want to:

  • define handlers separately from the adapter call
  • compose handlers before exporting them
  • keep type checking close to the handler object

Or let the adapter infer the handler types:

tsx
import adapter from 'waku/adapters/default';

export default adapter({
  handleRequest: async () => null,
  handleBuild: async () => {},
});

`handleRequest(input, utils)`

handleRequest is the runtime dispatcher. It receives every request that reaches the minimal server.

The input argument is one of the following shapes:

input.type When it is used Extra fields
rsc RSC payload fetches initiated by Root or fetchRsc rscPath, rscParams
call Server function calls fn, args
http Ordinary HTTP requests such as document requests, custom endpoints, and form submissions tryAction?

Every input also includes:

  • pathname: pathname from the request URL
  • req: the original Request object

input.req is the request for this handler. The Minimal API does not provide an
ambient getRequest() (that is a router feature); pass input.req
where you need it, or wrap the body in your own AsyncLocalStorage to reach it
from deeper components and server functions.

Typical handling patterns:

rsc requests usually return an RSC payload:

tsx
if (input.type === 'rsc') {
  return renderRsc({
    App: <App name={input.rscPath || 'Waku'} />,
  });
}

call requests usually execute the server function and return its result with the value option:

tsx
if (input.type === 'call') {
  const value = await input.fn(...input.args);
  return renderRsc({}, { value });
}

If a server function should also update the rendered server elements, return the updated elements and pass the function result with value:

tsx
if (input.type === 'call') {
  const value = await input.fn(...input.args);
  return renderRsc({ App: <App name="Updated" /> }, { value });
}

On multipart POST requests, which may be no-JS server action submissions,
the input carries tryAction. Calling it consumes the request body
and resolves { action: true, formState } for a decoded server action, or
{ action: false, formData } with the parsed form data when the body
contains no server action reference (a plain HTML form or a crawler) β€”
route that to your ordinary POST handling. tryAction is memoized, so
calling it again returns the same result, and it rejects cross-origin
requests only when they carry an action reference: an ordinary
cross-origin form post is delivered as form data. Requests without
tryAction cannot be action submissions, and their body stays
untouched unless you read it:

tsx
if (input.type === 'http' && input.pathname === '/') {
  let formState;
  if (input.tryAction) {
    const result = await input.tryAction();
    if (result.action) {
      formState = result.formState;
    } else {
      // an ordinary multipart form: treat it like any other POST
      await handlePost(result.formData);
    }
  }
  return renderHtml(
    await renderRsc({ App: <App name="Waku" /> }),
    <Slot id="App" />,
    {
      rscPath: '',
      formState,
    },
  );
}

http requests are where you implement document rendering and custom endpoints:

tsx
if (input.type === 'http' && input.pathname === '/') {
  return renderHtml(
    await renderRsc({ App: <App name="Waku" /> }),
    <Slot id="App" />,
    { rscPath: '' },
  );
}

if (input.type === 'http' && input.pathname === '/api/hello') {
  return new Response('world');
}

The utils argument provides these helpers:

Utility Purpose
renderRsc(elements, options?) Render an RSC payload as a ReadableStream. Object keys become RSC IDs; use options.value for a server function result.
renderHtml(elementsStream, html, options) Render the full HTML response that boots the client.
renderHtmlFallback(options?) Return the client-rendered HTML shell, without rendering an RSC tree.
loadBuildMetadata(key) Read metadata saved during handleBuild.

handleRequest may return any of the following:

Return value Meaning
ReadableStream Waku wraps it in new Response(stream).
Response Returned as-is.
'fallback' Ask Waku to serve its fallback HTML response.
null or undefined No direct response. For /, Waku still falls back to HTML. For other paths, the adapter receives no response.

Important details:

  • renderHtml expects an options object such as { rscPath: '' }.
  • renderHtmlFallback({ nonce }) sets the CSP nonce on the client bootstrap script without rendering a route. Use the nonce from your response's CSP header for request-time fallback HTML, including dynamic routes with document SSR disabled. Returning 'fallback' uses the default shell without a nonce. See Configure CSP.
  • renderHtml and renderHtmlFallback accept unstable_extraScriptContent for additional bootstrap code. generateDefaultHtml(fileName, options) accepts the fallback options for build-time bootstrap code; use request-specific nonces only with request-time HTML, not static files.
  • RSC IDs starting with _ are reserved for Waku internals.
  • rscPath and rscParams are opaque application data. Waku does not assign them semantics.
  • Each fetchRsc call issues a new request. A router-like abstraction can own prefetching and response reuse.

Status And Redirect Errors

Use createCustomError to attach status or redirect information to an error
that survives Flight serialization:

ts
import { unstable_createCustomError as createCustomError } from 'waku/minimal/server';

throw createCustomError('Not found', { status: 404 });

Throwing a status error directly from handleRequest produces an HTTP
response with that status. A redirect uses a location and a redirect status
such as 307. Document requests become HTTP redirects, while RSC and server
function redirects return a readable payload with status 200 and surface
the redirect on the client. Payload consumers can inspect it and choose how
to navigate:

ts
import { unstable_getErrorInfo as getErrorInfo } from 'waku/minimal/client';

const handleError = (error: unknown) => {
  const info = getErrorInfo(error);
  if (info?.location) {
    window.location.assign(info.location);
  }
};

getErrorInfo returns null for errors without valid Waku information. It is
also available from waku/minimal/server for server-side error handling.

`handleBuild(utils)`

handleBuild runs during waku build. It does not return a manifest or instruction list. It is responsible for emitting any static files you want in the build output.

The build utilities are:

Utility Purpose
renderRsc(elements, options?) Render an RSC payload stream. Use options.value for a server function result.
renderHtml(elementsStream, html, options) Render HTML from an RSC stream and client shell.
rscPath2pathname(rscPath) Convert an RSC path into the correct output pathname for the RSC payload.
saveBuildMetadata(key, value) Persist metadata that will later be available through loadBuildMetadata(...) in handleRequest.
generateFile(fileName, body) Write a file to dist/public. Accepts ReadableStream or string.
generateDefaultHtml(fileName, options?) Write the default client-rendered HTML shell to dist/public, optionally with extra bootstrap code.

The most common patterns are:

Dynamic SSR only:

tsx
handleBuild: async () => {},

Prerender one HTML page and one RSC payload:

tsx
handleBuild: async ({
  renderRsc,
  renderHtml,
  rscPath2pathname,
  generateFile,
}) => {
  const rscPath = '';
  const stream = await renderRsc({ App: <App name="Waku" /> });
  const [rscStream, htmlStream] = stream.tee();

  await generateFile(rscPath2pathname(rscPath), rscStream);

  const html = await renderHtml(htmlStream, <Slot id="App" />, { rscPath });
  await generateFile('index.html', html.body!);
},

renderHtml(...) consumes its stream. If you need the same RSC payload both as a standalone file and as input to renderHtml(...), use ReadableStream.prototype.tee() as shown above.

Generate fallback HTML only, for example in an SPA build:

tsx
handleBuild: async ({ generateDefaultHtml }) => {
  await generateDefaultHtml('index.html');
},

Persist build metadata and read it at request time:

tsx
const BUILD_METADATA_KEY = 'metadata-key';

handleBuild: async ({ saveBuildMetadata }) => {
  await saveBuildMetadata(BUILD_METADATA_KEY, 'metadata-value');
},

handleRequest: async (input, { renderRsc, loadBuildMetadata }) => {
  if (input.type === 'rsc') {
    return renderRsc({
      App: (
        <App metadata={(await loadBuildMetadata(BUILD_METADATA_KEY)) || 'Empty'} />
      ),
    });
  }
  return null;
},

Render during build:

tsx
handleBuild: async ({ renderRsc, generateFile, rscPath2pathname }) => {
  const body = await renderRsc({ App: <App name="Waku" /> });
  await generateFile(rscPath2pathname(''), body);
},

At build time there is no real request. If your render reads request-scoped data through your own AsyncLocalStorage, wrap the render and seed it with a synthetic Request.

Client API

The minimal client API lives in waku/minimal/client.

`Root`

Root is the top-level provider for the minimal client runtime.

tsx
import { Root_UNSTABLE as Root } from 'waku/minimal/client';

Props:

  • initialRscPath?: string
  • initialRscParams?: unknown
  • children: ReactNode

Important behavior:

  • initialRscPath defaults to ''.
  • Root is required for Slot, Children, useElementsPromise, useMergeElements,
    useRegisterRscEnhancer, and useRegisterRscReloadListener. Outside a
    Root, useFetchRsc still works but runs no enhancers.
  • Each Root owns its client store. Bare server action calls use the Root
    that was most recently mounted when the request starts.
  • Root injects a generator meta tag. Charset and viewport are yours to
    declare.
  • For SSR or SSG, use hydrateRoot(document, rootElement) when globalThis.__WAKU_HYDRATE__ is set.
  • 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: .
  • undefined is not allowed. If an element is intentionally empty, return null.
  • Slot must be rendered under Root.

`Children`

Children lets a server element render the client children passed to Slot.

tsx
import { Children_UNSTABLE as Children } from 'waku/minimal/client';

Server:

tsx
return renderRsc({
  App: (
    <App>
      <Children />
    </App>
  ),
});

Client:

tsx
<Slot id="App">
  <h3>A client element</h3>
</Slot>

This is useful for nested layouts and composition patterns where the server tree decides where client-provided children should appear.

Reading Elements

useElementsPromise returns the enclosing Root's current element-map promise.
Read it with React's use; the component suspends while the record is pending:

tsx
import { use } from 'react';
import { useElementsPromise_UNSTABLE as useElementsPromise } from 'waku/minimal/client';

const ElementIds = () => {
  const elements = use(useElementsPromise());
  return <p>{Object.keys(elements).join(', ')}</p>;
};

The record is read-only. Use combineElements to construct a new map while
preserving its etags, then pass it to mergeElements to update the Root.

Refetching

The Minimal API exposes fetching and merging separately. Define a hook for the behavior your application needs:

tsx
import { useCallback, useTransition } from 'react';
import {
  useFetchRsc_UNSTABLE as useFetchRsc,
  useMergeElements_UNSTABLE as useMergeElements,
} from 'waku/minimal/client';

const useRefetch = () => {
  const fetchRsc = useFetchRsc();
  const mergeElements = useMergeElements();
  return useCallback(
    (rscPath: string, rscParams?: unknown) =>
      mergeElements(fetchRsc(rscPath, rscParams)),
    [fetchRsc, mergeElements],
  );
};

const Counter = () => {
  const [isPending, startTransition] = useTransition();
  const refetch = useRefetch();

  const handleClick = (count: number) => {
    startTransition(async () => {
      await refetch('InnerApp=' + count);
    });
  };

  return (
    <button onClick={() => handleClick(1)} disabled={isPending}>
      Refetch
    </button>
  );
};

Important behavior:

  • refetch(...) requests a new RSC payload for the given rscPath and optional rscParams.
  • The returned payload is merged into the current element map by key.
  • Each slot's etag stays with the element map Minimal made it in. Passing a map as unstable_base to fetchRsc sends its etags, so the server can skip the slots it holds. A map made by combineElements keeps them; a copy made with a spread or Object.assign does not.
  • If you return only InnerApp, previously rendered elements such as App stay mounted.
  • If the refetch fails, the existing element map stays in place.

With unstable_base, fetchRsc sends the base's validators and returns only
the response entries. Omitted slots are not filled from the base. Passing this
patch to mergeElements preserves the Root's queued values and removals.
For a standalone snapshot, combine the retained base and response explicitly:

tsx
import { unstable_combineElements as combineElements } from 'waku/minimal/client';

const patch = await fetchRsc(rscPath, rscParams, {
  unstable_base: retainedElements,
});
const snapshot = combineElements(retainedElements, patch);

Do not merge this request-time snapshot into a Root that may have queued other
updates. Merge the patch with its latest elements.

Custom Merges

The optional second argument to mergeElements chooses the Root's next element
promise. It receives the latest queued promise, including updates that have not
rendered yet, and the incoming payload promise:

Use startTransition when merging pending work to retain visible content.
An urgent merge can show the nearest Suspense fallback if an earlier queued
elements promise is still pending.

tsx
mergeElements(response, (prevPromise, patchPromise) =>
  Promise.all([prevPromise, patchPromise]).then(([prev, patch]) =>
    combineElements(prev, combineElements(patch, clientElements)),
  ),
);

Return previous directly to leave the Root unchanged without introducing a
pending promise. The callback must be pure; React can replay it with a different
queued predecessor. Minimal reuses its result when the predecessor is the same.

The incoming promise passed to the callback recovers a failed payload to an
empty record. The promise returned by mergeElements is the original payload
promise, so callers can handle its rejection. It does not wait for the custom
merge or for React to render the update. To apply client entries only on success,
transform the response before merging:

tsx
mergeElements(
  response.then((received) => combineElements(received, clientElements)),
);

Use combineElements when retaining server entries so their etags follow their
values. Minimal does not impose an eager rendering or stale-while-revalidate
policy; Waku Router implements instant navigation with the merge callback.

Development Reloads

Development RSC reloads refetch the Root's initial rscPath and rscParams by
default. Fetching and merging a different input does not change that target.

useRegisterRscReloadListener_UNSTABLE returns a registrar for the enclosing
Root. Register a callback that fetches and merges the current input with
{ replace: true } to refresh that input after an edit. Without replace,
listeners run alongside the Root's refetch. The registrar returns an unregister
function for effect cleanup and is a no-op in production.

Request enhancers

useRegisterRscEnhancer_UNSTABLE extends the RSC requests of the enclosing Root. An enhancer wraps the request function, so it can rewrite the inputs, wrap the transport, or transform the result:

tsx
import { useEffect } from 'react';
import { useRegisterRscEnhancer_UNSTABLE as useRegisterRscEnhancer } from 'waku/minimal/client';

const Locale = ({ locale }: { locale: string }) => {
  const registerRscEnhancer = useRegisterRscEnhancer();
  useEffect(
    () =>
      registerRscEnhancer(
        (requestRsc) => (rscPath, rscParams, options) =>
          requestRsc(rscPath, rscParams, {
            ...options,
            fetch: (input, init) => {
              const headers = new Headers(
                init?.headers ??
                  (input instanceof Request ? input.headers : undefined),
              );
              headers.set('accept-language', locale);
              return options.fetch(input, { ...init, headers });
            },
          }),
      ),
    [locale, registerRscEnhancer],
  );
  return null;
};

Important behavior:

  • options.type is rsc for a payload fetch and call for a server function call.
  • The result is { elements, value }, where value is a server function's return value. The elements never carry Minimal's reserved keys.
  • Build a changed element map with unstable_combineElements. Each slot's etag stays with the map Minimal made it in, so a result rebuilt with a spread arrives without them and the server stops skipping the slots the Root already holds.
  • Enhancers run for the Root's fetches, the server actions it receives, and its development reloads, but not for its initial payload. A bare server action goes to the Root mounted last, which is not necessarily the one that rendered the component calling it.
  • A higher order, the optional second argument that defaults to 0, wraps a lower one, so it runs earlier on the request and later on the result. At the same order, a later registration wraps an earlier one. The Waku router registers its own enhancer at order 100. Enhancers above that order must return the result without introducing an asynchronous delay after the inner enhancer resolves. The router acts on an action's result while the chain is still unwinding: it applies the route change and decides whether a rerender of the page the action came from still applies. Minimal merges the returned elements only after the whole chain resolves. Delaying the result above the router can therefore render the new route before its slot has been merged, so Slot throws, or let a navigation started during the delay be overwritten when the action's elements are applied.
  • A request keeps the enhancers it started with, so unregistering does not change a response already on its way.
  • The initial payload is a known limitation. A Root requests it before any descendant can register, so an application that has to reach that one request wraps globalThis.fetch itself.

Common Patterns

Dynamic SSR without prerendering

Use renderHtml(...) for document requests and leave handleBuild empty:

tsx
export default adapter({
  handleRequest: async (input, { renderRsc, renderHtml }) => {
    if (input.type === 'rsc') {
      return renderRsc({ App: <App name={input.rscPath || 'Waku'} /> });
    }
    if (input.type === 'http' && input.pathname === '/') {
      return renderHtml(
        await renderRsc({ App: <App name="Waku" /> }),
        <Slot id="App" />,
        { rscPath: '' },
      );
    }
    return null;
  },
  handleBuild: async () => {},
});

Custom API endpoints

Use input.type === 'http' and return a Response directly:

tsx
if (input.type === 'http' && input.pathname === '/api/hello') {
  return new Response('world');
}

Server functions

Server functions typically return their result with the value option, and may optionally return updated server elements in the same payload:

tsx
if (input.type === 'call') {
  const value = await input.fn(...input.args);
  return renderRsc({ App: <App name="Updated" /> }, { value });
}

Server actions with progressive enhancement

Server actions often return HTML rather than a bare RSC payload so that the same flow works with or without JavaScript:

tsx
if (input.type === 'http' && input.pathname === '/') {
  const result = input.tryAction ? await input.tryAction() : undefined;
  const formState = result?.action ? result.formState : undefined;
  return renderHtml(
    await renderRsc({ App: <App name="Waku" /> }),
    <Slot id="App" />,
    {
      rscPath: '',
      formState,
    },
  );
}

Pitfalls

  • renderHtml(...) consumes its stream. Call tee() if you also need to emit that same payload as a file.
  • Slot IDs must exactly match the keys returned by renderRsc(...).
  • undefined is invalid for slot values. Use null for an intentionally empty element.
  • rscPath2pathname(...) should be used instead of hardcoding RSC payload file names.
  • handleBuild emits nothing unless you explicitly call generateFile(...) or generateDefaultHtml(...).
  • Returning 'fallback' is not the same as returning null. 'fallback' explicitly asks Waku to generate the fallback HTML response.
  • The client bootstrap for SSR uses hydrateRoot(document, ...), not createRoot(document.getElementById('root')!).
  • This API is intentionally low-level. If you find yourself rebuilding routing conventions, waku/router is probably the better fit.

Adapter Authors

Adapter authors own server entries, request processing, and build processing.
waku/adapter-builders exports Unstable_ServerEntry for entries authored
directly. Minimal applications normally pass their handlers to an adapter.

For adapter implementation details, see Adapter Authoring.

Examples


Guides Monorepo (`docs/guides/monorepo.mdx`)


slug: monorepo title: Waku in a Monorepo Setup 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 (`docs/guides/navigation-prefetching.mdx`)


slug: navigation-prefetching title: Navigation and Prefetching category: Routing and Navigation order: 10 tags: [Experimental]

How Client Navigation Works

Waku's <Link> component renders an anchor element and handles normal same-tab clicks with the client router. On navigation, Waku fetches the route's RSC payload and updates the current route without a full document reload.

Use <Link> for internal app routes:

tsx
import { Link } from 'waku';

export const Nav = () => (
  <nav>
    <Link to="/">Home</Link>
    <Link to="/about">About</Link>
  </nav>
);

Use a regular <a> element for external URLs, downloads, and links that intentionally open in another browsing context.

Static Route Caching

After a static route has been loaded, Waku can reuse its cached RSC payload on later visits. For example, if the user starts on /, navigates to /about, and then returns to /, the client can render / from the cache instead of requesting that static route again.

Dynamic routes are different. Waku expects dynamic route output to be request-specific, so a visit to a dynamic route may need a fresh server request. Prefetching is most useful when you want to start that work before the user clicks.

Manual Prefetching

Use router.prefetch() when a client component knows which route the user is likely to visit next.

tsx
'use client';

import { useRouter } from 'waku';

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

  return (
    <button
      onFocus={() => router.prefetch('/dashboard')}
      onMouseEnter={() => router.prefetch('/dashboard')}
      onClick={() => router.push('/dashboard')}
    >
      Dashboard
    </button>
  );
};

router.prefetch() accepts the same URL strings as router.push(). Use a local href factory for typed params and search:

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

const post = createRouteHref({ path: '/posts/[slug]' });

router.prefetch(post({ params: { slug } }));

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

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

tsx
import { Link } from 'waku';

export const Nav = () => (
  <nav>
    <Link to="/docs" unstable_prefetchOnEnter={{}}>
      Docs
    </Link>
    <Link to="/blog" unstable_prefetchOnView={{ mode: 'once' }}>
      Blog
    </Link>
  </nav>
);
  • unstable_prefetchOnEnter starts prefetching when the pointer enters the link.
  • unstable_prefetchOnView starts prefetching when the link enters the viewport.

Both props take an options object. Passing an empty object enables prefetching with the defaults; omitting the prop disables it.

  • mode: 'always' (the default) fetches on the first trigger and dedupes repeat triggers for the same path and query within the ttl. Within the ttl, navigation reuses the prefetched response without another request.
  • mode: 'once' fetches a route at most once per session, ignoring the query. Its main purpose is to warm the route's static parts, which cannot change within a build, so instant navigation can paint them even on the route's first visit. Warmed routes are kept in a bounded store, so a long session may eventually fetch a route again.
  • ttl sets how long a prefetched response stays reusable, in milliseconds (default: 60000).

A repeat prefetch sends the etags of the stored response, so the server only renders and sends what changed.

The same options work with router.prefetch(to, options).

Prefer intent-based prefetching for expensive dynamic routes. View-based prefetching can be useful for short pages with a small number of important links, but it can waste server work if applied to every link in a large list.

Instant Navigation

By default, navigating to a dynamic route waits for the server response before the page updates. With the experimental unstable_instant option, Waku instead paints the route's cached static shell (including its <Suspense> fallbacks) right away, then streams the dynamic parts in.

It relies on two things:

  • The route's static shell is already cached, from an earlier visit or from a prefetch (any prefetch caches the static parts it learns, so a prefetched route's first visit can be instant).
  • The dynamic part of the page sits inside a <Suspense> boundary, so there is a fallback to show while it streams.

Opt in per navigation with the unstable_instant prop on <Link>. Here a static layout wraps the dynamic page in <Suspense>:

tsx
import { Suspense } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'waku';

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div>
      <nav>
        <Link to="/posts/1" unstable_instant>
          Post 1
        </Link>
        <Link to="/posts/2" unstable_instant>
          Post 2
        </Link>
      </nav>
      <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
    </div>
  );
}

The page it streams in is an ordinary dynamic route:

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

export default async function Post({ id }: PageProps<'/posts/[id]'>) {
  const post = await loadPost(id); // request-specific work
  return <article>{post.body}</article>;
}

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

You can also navigate programmatically with router.push or router.replace:

tsx
'use client';

import { useRouter } from 'waku';

export const PostButton = () => {
  const router = useRouter();
  return (
    <button onClick={() => router.push('/posts/1', { unstable_instant: true })}>
      Post 1
    </button>
  );
};

Both return a promise that resolves once the navigation you asked for has been handled: after the response when the route needs one, right away when it does not, such as a static route already loaded once or the route you are already on, and also when a newer navigation supersedes it.

When a fetch redirects to another route or needs the custom 404 page, the same navigation fetches that destination before it resolves. Anything your page throws later while rendering, such as a late unstable_notFound() or unstable_redirect(), is followed by a navigation of its own after this promise has settled.

A missing route depends on who answers the request. A Waku server can send the 404 page as the first response. Where the first request instead reports a missing route, the client fetches the custom 404 page itself. In both cases, the promise resolves with the /404 route while the address bar keeps the URL that was requested. Without a custom 404 route, the promise rejects and the built-in Not Found page is shown.

The promise also rejects when the navigation fails outright and when a redirect hands the page to the browser. A redirect to a route of this app is different: the client follows it and the promise resolves with that destination. Catch failures when you call these methods programmatically. Navigation failures are rendered through ErrorBoundary. The promise does not wait for React to finish rendering the destination, so the address bar may still show the previous URL when it resolves.

When the shell is cached, the layout and the Loading... fallback appear with no round trip, and the post's content streams into the fallback once the server responds. When the shell is not cached, the navigation falls back to normal behavior: the current page stays put until the response arrives, so there is no blank flash.

The <Suspense> boundary decides how localized the loading state is. Wrapping the whole page area (as above) swaps the page for a skeleton; wrapping only a dynamic slice inside an otherwise-static page leaves the rest in place and shows the skeleton for just that slice.

Because an instant navigation commits before the server responds, it reconciles afterward: a server redirect updates the URL to the redirect target, while a not-found (404) response keeps the requested URL, so the 404 page shows at the address the user tried.

An instant navigation that can commit from cache, or that needs no fetch, commits urgently rather than inside a React transition, since a transition would suppress the immediate skeleton. A <ViewTransition> therefore does not animate that commit. When the shell is not cached, navigation waits for the response and commits the destination in a transition; useNavigationStatus_UNSTABLE() reports pending for that wait. When the cache commit is available, the shell itself is the pending state.

When an instant navigation paints a cached shell, the dynamic slots the client already had lose their etags, including slots from earlier pages and slots that opted in with unstable_getEtag. The next regular navigation or server action that includes such a slot has the server render and send it again, and the slot gets its etag back. Prefetches are not affected, because they send the etags of the prefetched response. Instant navigation suits dynamic parts that change often or are cheap to render. If your app relies on etags to skip costly dynamic slots, pair them with cache.rsc so the server replays the cached subtree instead of rendering it again, or do not use unstable_instant.

Pending UI

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

tsx
'use client';

import { useNavigationStatus_UNSTABLE as useNavigationStatus } from 'waku/router/client';

export const PendingIndicator = () => {
  const { pending } = useNavigationStatus();
  return (
    <span
      aria-hidden
      style={{ opacity: pending ? 1 : 0, pointerEvents: 'none' }}
    >
      Loading...
    </span>
  );
};
tsx
'use client';

import { Link as WakuLink } from 'waku';
import type { LinkProps } from 'waku/router/client';
import { PendingIndicator } from './pending-indicator';

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

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

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

Observe Route Changes

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

tsx
'use client';

import { useEffect } from 'react';
import { useRouter } from 'waku';

export const NavigationLogger = () => {
  const { path, query, hash } = useRouter();
  useEffect(() => {
    console.log('viewed route', { path, query, hash });
  }, [path, query, hash]);
  return null;
};

The effect runs for the initial route and whenever the current route fields change. It does not run again for reload() or navigation to the same URL, and it does not observe the start of a navigation. Instant navigation commits the requested route before reconciliation, so a redirected instant navigation logs the optimistic route and then the reconciled one; keep the previous value if you only want landings.

Catch the promises returned by programmatic push, replace, and reload calls to handle fetch errors. There is no global failure callback for <Link> or browser back and forward; failed navigations surface through ErrorBoundary from waku/router/client (or your own boundary around the router). Do not treat promise resolution as a committed-route signal: rendering can still suspend, and a render-time redirect or 404 can start another navigation after the response is accepted. Observe the route fields above when you need the destination that was committed. To learn that a newer navigation cancelled yours while its response was pending, pass the experimental unstable_onSuperseded option to push or replace. For an instant navigation, its shell may already have painted. An accepted response is not cancelled merely because its components have not finished rendering.

View Transitions

Waku commits a navigation inside a React transition, so animating one needs nothing from the router: wrap the part of the tree you want animated in React's <ViewTransition> and React drives the browser View Transitions API for you.

tsx
'use client';

import { ViewTransition } from 'react';
import type { ReactNode } from 'react';

export const AnimatedPage = ({ children }: { children: ReactNode }) => (
  <ViewTransition>{children}</ViewTransition>
);

Call addTransitionType alongside a router.push() to vary the animation per navigation. A browser without the View Transitions API just applies the update without animating it.

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


Guides No Ssr (`docs/guides/no-ssr.mdx`)


slug: no-ssr title: No SSR and Static Fallbacks category: Routing and Navigation order: 40 tags: [Experimental]

When to Disable Document SSR

By default, Waku renders an HTML document for page requests. This gives the browser useful HTML before client-side JavaScript loads.

Disabling document SSR changes that behavior. The server returns Waku's fallback HTML shell, and the client router fetches the RSC payload to render the route in the browser.

This can be useful for:

  • authenticated app areas where HTML content is not useful before client state loads
  • browser-only experiences where server-rendered document content would be misleading
  • routes where you intentionally prefer a static shell over per-request document rendering

It is usually a poor fit for public content pages, marketing pages, documentation, or any route where SEO and initial HTML content matter.

Disable SSR for a Page

With the file-system router, return unstable_disableSSR from the page's getConfig:

tsx
// src/pages/app.tsx
export default async function AppPage() {
  return <h1>App</h1>;
}

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

With createPages, use unstable_disableSSR on a page:

tsx
// src/waku.server.tsx
import { createPages } from 'waku';
import adapter from 'waku/adapters/default';
import { AppPage } from './templates/app-page';

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

export default adapter(pages);

unstable_disableSSR currently uses an unstable_ name and may change. See the createPages reference for the API-level details.

Disable SSR from a Custom Server Entry

If you own src/waku.server.tsx, you can return 'fallback' for document requests. This is useful when using the file-system router but deciding at the server-entry level that some or all document requests should skip SSR.

The simplest whole-app version looks like this:

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

const router = fsRouter(import.meta.glob('./pages/**/*.tsx'));

export default adapter({
  handleRequest: async (input, utils) => {
    if (input.type === 'http') {
      return 'fallback';
    }
    return router.handleRequest(input, utils);
  },
  handleBuild: (utils) => {
    return router.handleBuild(utils);
  },
});

You can also make this conditional:

tsx
handleRequest: async (input, utils) => {
  if (input.type === 'http' && input.pathname.startsWith('/app')) {
    return 'fallback';
  }
  return router.handleRequest(input, utils);
};

In production, a static page is served from the HTML the build prerendered for it, before handleRequest runs, so 'fallback' only reaches dynamic pages. Give a static page unstable_disableSSR as well, and the build writes the fallback shell for it instead.

What the Fallback Contains

The fallback is an HTML shell that can boot the Waku client. It is not the same as a fully rendered page.

When a route uses the fallback:

  • the initial document does not contain that route's rendered content
  • the client still needs JavaScript to render the route
  • the RSC payload is still requested and rendered by the client router

No SSR vs Static Rendering

Static rendering and no SSR solve different problems.

Static rendering prerenders route output at build time. The generated HTML can contain the page content.

No SSR skips document rendering and serves a fallback shell. It can reduce server document work, but it also removes the initial page content from the HTML document.

Disabling SSR does not turn every Waku feature into pure static output. Dynamic server behavior still needs a runtime that can serve the relevant RSC requests and APIs.

Verify the Result

After building or starting the app, inspect the page source for a route with SSR disabled. You should see the Waku shell rather than the route's full rendered content. Then load the page normally in the browser and verify that the client renders the route after JavaScript starts.


Guides Performance Tracks (`docs/guides/performance-tracks.mdx`)


slug: performance-tracks title: Server Components Performance Tracks 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 (`docs/guides/react-compiler.mdx`)


slug: react-compiler title: React Compiler in Waku 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 (`docs/guides/redirect-maps.mdx`)


slug: redirect-maps title: Redirect Maps category: Routing and Navigation order: 20 tags: [Experimental]

External Redirect Maps

This guide is for redirect maps that sit in front of the Waku server, such as middleware, reverse proxies, CDNs, or hosting-platform redirect rules.

When a Waku app uses the client router, a navigation usually fetches both:

  • the browser route, such as /old
  • the RSC payload for the same route, at a separate url

Both have to be redirected. If you redirect only /old, a direct visit works while client-side navigation still lands on the old route.

Inside the app, unstable_parseRouterRequest reads either url as the route it addresses, and unstable_formatRouterRequest builds the matching url for the destination, so middleware never has to spell the RSC url out. Outside the app β€” a CDN or a hosting platform's redirect rules β€” there is no way to call into Waku, so those rules do have to name the url. With Waku's default config:

  • basePath is /
  • rscBase is RSC
  • the route /old maps to the RSC path /RSC/R/old.txt

The /RSC/ file naming convention is subject to change in future versions of Waku. Prefer the middleware approach below, which does not depend on it.

If you customize basePath or rscBase, adjust any hand-written rules accordingly.

For application-level redirects implemented inside Waku routes or server actions, see Redirects and Not Found instead. That is a separate use case from the redirect-map approach described here.

Redirect via middleware

Create a new middleware file in src/middleware/. Waku automatically discovers and loads middleware from this directory.

typescript
// ./src/middleware/redirects.ts
import type { MiddlewareHandler } from 'hono';
import {
  unstable_formatRouterRequest as formatRouterRequest,
  unstable_parseRouterRequest as parseRouterRequest,
} from 'waku/router/server';

const redirects: Record<string, string> = {
  '/old': '/new',
  // ... add more redirects here
};

const redirectsMiddleware = (): MiddlewareHandler => async (c, next) => {
  // Reads the browser route and the RSC payload request the same way.
  const parsed = parseRouterRequest(c.req.raw);
  const destination =
    parsed?.type === 'route' ? redirects[parsed.path] : undefined;
  // Rebuilds the same kind of url, so client-side navigation gets a payload
  // and a direct visit gets the page. `null` means this request cannot be
  // rewritten without losing state, so leave it alone.
  const location = destination
    ? formatRouterRequest(c.req.raw, destination)
    : null;
  if (location) {
    c.res = new Response(null, {
      status: 302,
      headers: { location: location.toString() },
    });
    return;
  }

  return await next();
};

export default redirectsMiddleware;

Matching on parsed.path covers both requests with one entry per route, and keeps the redirect map readable as a list of routes.

In a production build the adapter serves statically generated files before middleware runs, so a middleware redirect does not fire for a route rendered with render: 'static'. Redirect those from the hosting environment instead, or make the route dynamic.

Note the type check: an action dispatched from the client is addressed by function id and carries no route, so a path rule can never match it. A progressively enhanced form submitted without JavaScript is the other way around β€” it posts to the route's own url, so it parses as 'route' and a rule for that path does apply to it. Either way, a path rule is a redirect, not an authorization boundary; see Authentication for where to put checks that must not be bypassed.

Redirect via hosting environment

A redirect map outside the app cannot call into Waku, so these rules name both urls explicitly and depend on the /RSC/ convention noted above.

This very much depends on the hosting environment you are using. For example, on Vercel 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 (`docs/guides/redirects-and-not-found.mdx`)


slug: redirects-and-not-found title: Redirects and Not Found 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.

Use a local href factory for typed params and search, then pass its string result to redirect:

tsx
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { unstable_redirect as redirect } from 'waku/router/server';

const postHref = createRouteHref({ path: '/posts/[slug]' });

redirect(postHref({ params: { slug: post.slug } }), 303);

The factory can bind a search codec directly; no server or browser registry is required. Its result excludes the application base path, which redirect applies. See Typed Routes.

Redirect After a Server Action

For form submissions and mutations, use 303 when the next page should be loaded with a GET request:

tsx
// src/pages/posts/new.tsx
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { unstable_redirect as redirect } from 'waku/router/server';
import { createPost } from '../../lib/posts';

const postHref = createRouteHref({ path: '/posts/[slug]' });

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

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

        redirect(postHref({ params: { slug: post.slug } }), 303);
      }}
    >
      <input name="title" />
      <button>Create post</button>
    </form>
  );
}

Waku renders the destination into the same response, so the client does not make a second request. That only works for a target it can render: a path on this site, without a hash. Anything else, including a target that is not a route at all or one that renders not found, is handed to the browser as a 303, which costs a full page load. The 303 is what keeps the form submission from being sent again to the destination.

To stay on the page and show the action's changes instead, see Rerendering After Server Actions.

Render Not Found for Missing Data

Use unstable_notFound when a route matches but the backing data does not exist:

tsx
// src/pages/posts/[slug].tsx
import type { PageProps } from 'waku/router';
import { unstable_notFound as notFound } from 'waku/router/server';
import { getPost } from '../../lib/posts';

export default async function PostPage({ slug }: PageProps<'/posts/[slug]'>) {
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return <article>{post.title}</article>;
}

Add a Custom 404 Page

Create src/pages/404.tsx to customize the not-found page:

tsx
// src/pages/404.tsx
export default function NotFoundPage() {
  return (
    <main>
      <h1>Not found</h1>
      <p>The page you requested does not exist.</p>
    </main>
  );
}

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

If a /404 page exists, Waku uses it for unmatched routes and notFound() results. If it does not exist, Waku falls back to a minimal not-found response.

A client-side navigation to a missing route is answered the same way: the server sends the 404 page in the response, so the client does not need a second request. That response carries status 200, because it carries a page rather than an error. A direct page load of the same URL is still a 404. Where no Waku server answers, such as a static export, the request 404s and the client fetches the 404 page itself.

Status and Streaming Notes

When notFound() is thrown before the initial HTML shell is ready, a direct request server-renders the custom /404 page and returns status 404. If it is thrown from inside a Suspense boundary after the shell has already been sent, the response keeps status 200 and the browser completes the not-found handling after hydration. This late path requires JavaScript; without it, the Suspense fallback remains.

Redirects have the same streaming constraint. Once a response has started streaming to the browser, the server cannot change its HTTP status or response headers.

For redirects that should happen before Waku handles the request at all, use middleware or a hosting-platform redirect map instead.


Guides Request Context (`docs/guides/request-context.mdx`)


slug: request-context title: Request Context category: Runtime and Middleware order: 10

When to Use Request Context

Waku provides request-scoped context for server code. Use it when a server component or server function needs information from the current request. Middleware runs before the render scope is established, so middleware reads the request from its Hono context instead.

Read the Current Request

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

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

export default function Page() {
  const userAgent = getRequest().headers.get('user-agent') ?? 'unknown';

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

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

Header names are case-insensitive, and headers.get() returns null for a header the request does not have.

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

getRequest().url is the URL the request was sent to. After a client navigation, that is Waku's RSC URL (such as /RSC/R/about.txt), not the page URL. A page receives its params and query as props; elsewhere, read the route with unstable_parseRouterRequest(getRequest()). A server function called from the client sends its own request, which unstable_parseRouterRequest reports only as { type: 'action' }, so pass the function any route data it needs as arguments.

getRequest() returns the original incoming request. Inside an API route handler, the handler's own req argument may have a rewritten URL (the route path with any prefix stripped), so req.url and getRequest().url can differ. Use the handler argument for routing-specific values and for the body, which it shares with getRequest().

getRequest is exported from waku/router/server and is available wherever the router runs your code: renders (request and build), server functions, API route handlers, interceptors, and a custom router's resolve. In the Minimal API, use input.req from handleRequest instead, and bring your own AsyncLocalStorage if you need it deeper in the tree.

Components rendered as static are prerendered at build time, so do not read getRequest, headers, or interceptor-seeded state in a static page, layout, or slice; those values would be baked into the prerendered output. Read request-scoped data only in dynamic rendering. File-system pages, layouts, and slices are static by default, so the page examples in this guide export getConfig with render: 'dynamic'. Only the file-system router reads getConfig; with createPages, pass render: 'dynamic' to createPage instead. (During a static route build getRequest().url is the route's path, but a static slice build uses a placeholder root request that does not identify the slice.)

Pass Data with an Interceptor

Waku's context is request-only. To share derived, request-scoped data with server components and server functions, bring your own AsyncLocalStorage and seed it from a handler interceptor. An interceptor wraps each render in both the request and build phases, so getRequest is available inside it.

Define your own store:

ts
// src/lib/request-data.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export type RequestData = {
  country?: string;
};

const store = new AsyncLocalStorage<RequestData>();

export const getRequestData = (): RequestData => store.getStore() ?? {};

export const runWithRequestData = <T>(
  data: RequestData,
  fn: () => Promise<T>,
) => store.run(data, fn);

In managed mode (no waku.server.tsx), seed the store from an interceptor in src/pages/_interceptors/request-data.ts:

ts
import type { HandlerInterceptor } from 'waku/router/server';
import { getRequest } from 'waku/router/server';
import { runWithRequestData } from '../../lib/request-data.js';

const requestDataInterceptor: HandlerInterceptor = (next) => {
  const country = getRequest().headers.get('cf-ipcountry') ?? 'unknown';
  return runWithRequestData({ country }, next);
};

export default requestDataInterceptor;

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

ts
import { getRequest } from 'waku/router/server';
import { runWithRequestData } from './lib/request-data.js';

createPages(async ({ createPage, createInterceptor }) => {
  createInterceptor((next) => {
    const country = getRequest().headers.get('cf-ipcountry') ?? 'unknown';
    return runWithRequestData({ country }, next);
  });
  return [
    // ...pages...
  ];
});

Then read that data from server code:

tsx
import { getRequestData } from '../lib/request-data.js';

export default function Page() {
  const { country } = getRequestData();

  return <p>Country: {country ?? 'unknown'}</p>;
}

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

If the value is just a request header, you can also read it from getRequest().headers in the component without a store. Reach for an interceptor when you want to compute the value once per request and share it across the render.

Cookies

Waku does not currently provide a dedicated cookie API. Read request cookies from getRequest().headers in server code, and use middleware to set response cookies after rendering.

Read a request cookie from server code with the cookie package:

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

export default function Page() {
  const cookies = cookie.parseCookie(getRequest().headers.get('cookie') ?? '');

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

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

Set a response cookie from middleware, which owns the response:

ts
// src/middleware/session.ts
import * as cookie from 'cookie';
import type { MiddlewareHandler } from 'hono';

const sessionMiddleware = (): MiddlewareHandler => {
  return async (c, next) => {
    const cookies = cookie.parseCookie(c.req.header('cookie') || '');
    const sessionId = cookies.sessionId;

    await next();

    if (c.res && sessionId) {
      const headers = new Headers(c.res.headers);
      headers.append(
        'set-cookie',
        cookie.stringifySetCookie({
          name: 'sessionId',
          value: sessionId,
          httpOnly: true,
          path: '/',
          sameSite: 'lax',
          secure: true,
        }),
      );
      c.res = new Response(c.res.body, {
        status: c.res.status,
        statusText: c.res.statusText,
        headers,
      });
    }
  };
};

export default sessionMiddleware;

Keep any request-scoped store small and request-specific. Do not store values that should outlive the request.

Cloudflare Bindings

On Cloudflare Workers, import bindings from cloudflare:workers. Use Waku request context for request data, and Cloudflare's runtime APIs for environment bindings, D1, KV, and waitUntil.

tsx
import { env } from 'cloudflare:workers'; // eslint-disable-line import/no-unresolved
import { getRequest } from 'waku/router/server';

export default async function Page() {
  const country = getRequest().headers.get('cf-ipcountry');
  const store = country
    ? await env.DB.prepare('SELECT * FROM store WHERE country = ?')
        .bind(country)
        .first()
    : null;

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

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

See Run Waku on Cloudflare for the full Cloudflare setup.


Guides Rerender After Actions (`docs/guides/rerender-after-actions.mdx`)


slug: rerender-after-actions title: Rerendering After Server Actions category: Routing and Navigation order: 35

An Action Renders Nothing by Default

When a server action settles, Waku returns its value and leaves the page as it was last rendered. After a mutation, that means the page shows stale data. It also undoes useOptimistic: React drops the optimistic value when the action settles, expecting the page to show the change by then, so the update appears and then disappears.

The API in this guide currently uses an unstable_ name and may change.

Rerender the Page the Action Came From

Call unstable_rerenderRoute with no arguments. Waku renders the route the action was called from into the action's response, so the page and the optimistic value settle on the same data.

tsx
// src/pages/cart.tsx
import { unstable_rerenderRoute as rerenderRoute } from 'waku/router/server';
import { AddToCart } from '../components/add-to-cart';
import { addItem, getCart } from '../lib/cart';

export default async function CartPage() {
  const cart = await getCart();

  return (
    <AddToCart
      count={cart.items.length}
      addItem={async (formData: FormData) => {
        'use server';

        await addItem(String(formData.get('id')));
        rerenderRoute();
      }}
    />
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
tsx
// src/components/add-to-cart.tsx
'use client';

import { useOptimistic } from 'react';

export const AddToCart = ({
  count,
  addItem,
}: {
  count: number;
  addItem: (formData: FormData) => Promise<void>;
}) => {
  const [optimisticCount, setOptimisticCount] = useOptimistic(count);

  return (
    <form
      action={async (formData) => {
        setOptimisticCount(optimisticCount + 1);
        await addItem(formData);
      }}
    >
      <p>{optimisticCount} items</p>
      <input type="hidden" name="id" value="42" />
      <button>Add to cart</button>
    </form>
  );
};

The page has to render dynamically for the rerender to read the new data.

If the user moves to another page while the action runs, Waku drops the rerender instead of taking them back to the page they left. The action still returns its value.

Render a Specific Route

Pass a path, and optionally a query, to render that route instead:

tsx
rerenderRoute('/cart', 'tab=saved');

If that is not the route on screen, the router moves to it. To send the user to another page after a mutation, a redirect is usually the better fit; see Redirects and Not Found.

Forms Without JavaScript

Without JavaScript, the form posts to the page and Waku renders that page after the action runs, so the document already includes the change. Calling unstable_rerenderRoute() there still works; it renders the same page.

Avoid Rerendering From an Effect

Do not call unstable_rerenderRoute() in an action that a client component calls from an effect with the action in its dependencies:

tsx
useEffect(() => {
  void track();
}, [track]);

Every rerender gives the client a new track function, which runs the effect again and calls the action again, in a loop. This is also why Waku does not rerender after every action by default.

Router Requirement

The no-argument form needs a router on the client that tells the server which route the action came from. Waku's router does, and a router built on waku/router/client-core does by calling useActionRouting_UNSTABLE. In an action that did not come through such a router, unstable_rerenderRoute() throws.


Guides Router Fetch Strategy (`docs/guides/router-fetch-strategy.mdx`)


slug: router-fetch-strategy title: Navigation and Prefetching Has Moved hidden: true

Use the New Guide

This guide has moved to Navigation and Prefetching.


Guides Router Protocol (`docs/guides/router-protocol.mdx`)


slug: router-protocol title: Router Protocol category: Low-level APIs order: 25

Scope

unstable_defineRouter and waku/router/client implement the two ends of this
protocol. A custom route resolver does not need to implement it: supply element
sources to defineRouter, as shown in Custom Router.

Read this guide when changing Router itself or implementing another client binding
with waku/router/client-core. A router with a different protocol can use the
Minimal API directly.

Minimal owns Flight transport, element storage, and etags. It does not interpret
Router's IDs or decide which URL to display. Router uses public Minimal APIs.
Its control entries are reserved by Router; Minimal treats them as application data.

Requests and Slots

These are different identities:

  • A URL has a pathname, query, and client-owned fragment.
  • An RSC input identifies a route or an independently requested slice.
  • A slot ID identifies content inside an element map.

Use unstable_encodeRoutePath(pathname) and unstable_createRscParams(query)
from waku/router/client-core for route requests. Fragments are not sent to the
server. Minimal handles the HTTP endpoint and its wire encoding.

Slot ID Meaning
root Document composition; its ID is shared across routes.
route: Route composition scoped to one pathname.
Other resolver-owned IDs Content composed with Slot; IDs are shared wherever they appear.
slice: A slice, bundled in a route or fetched independently.

The resolver calls its source route; defineRouter maps it to the path-scoped
slot. An immutable slot must have the same content for its lifetime. Path-scoping
lets immutable route compositions coexist without claiming that different URLs
have the same content. Merges can retain previously visited routes' slots.

Response Metadata

Route responses also carry these entries. They are control data, not components
to render with Slot.

Entry Contract
ROUTE [pathname, query] of the route the server rendered, not necessarily the requested route.
IS_STATIC Every source of that route is immutable, including sources omitted by matching etags. It does not mean HTML was emitted at build time.
HAS404 A custom /404 route is available. Present when true.
IS_ORIGIN An action used the no-argument rerenderRoute() form to refresh its request-time origin. The action enhancer consumes this marker before merging.

Use unstable_getRouteFromElements, unstable_isStaticFromElements, and
unstable_has404FromElements from waku/router/client-core to read route
metadata. Independently fetched slices and actions that do not rerender a route
need not contain it. Neither the action return value nor Minimal's etags are
Router metadata.

These contracts describe server responses. The standard client also writes
ROUTE and IS_STATIC for cached and instant paints, so the live element map is
not necessarily a settled server response. The binding owns navigation intent,
errors, and history, and reconciles them with elements at commit boundaries.

An immutable route composition is a reusable shell, not proof that every slot it
renders is immutable. A shell can contain a mutable page. The client cache therefore
distinguishes hasCachedShell from canReuseStaticRoute.

Partial Responses

Minimal can send the etags of a supplied unstable_base with a fetch. The server
omits slots whose validators match; fetching returns only response entries.
A response is consequently not guaranteed to be a complete renderable tree.
Callers retain omitted slots or combine a standalone snapshot explicitly.

Route metadata describes the resolved route even when its slots are omitted. For
example, a dynamic page whose etag matches is omitted, but IS_STATIC remains
false. Looking only at the returned slots would give the wrong reuse decision.

Use Minimal's unstable_combineElements when combining element maps. Object
spreads discard the validators associated with their values.

Requested, Rendered, and Browser URLs

Situation Requested route Rendered route Browser URL after commit
Ordinary navigation /post?q=one /post?q=one /post?q=one
Eager internal redirect /old /new /new
Custom not-found route /missing?q=one /404?q=one /missing?q=one

An early internal redirect can be resolved on the server into the destination's
elements in the same response. ROUTE tells the client which destination landed.
Not every redirect can be resolved this way: document redirects leave the router,
and redirects thrown by streamed slots are handled by the client error boundary.

Similarly, a fetch can succeed before a streamed slot throws not-found. The
client uses HAS404 to decide whether to follow /404 or show its generic
not-found fallback. Follow decisions share loop detection and a navigation budget.
See Redirects and Not Found for SSR and streaming.

Fragments remain client-owned. Static route reuse also preserves the browser's
query instead of treating build-time route metadata as a query-changing redirect.
Reading ROUTE is therefore not enough to decide what to write to history.

On failure, retained elements can describe an earlier route. The attempted URL
and error state belong to the client binding, not the server metadata.

Link, push, replace, prefetch, and unstable_redirect consume URL strings.
unstable_createRouteHref from waku/router binds a local pattern and optional
search codec to a function returning an app-base-free primitive string. Its
type-only brand records the normalized route pattern, not membership in a
finite static build. Construction requires no codec registry, provider, or
additional Flight metadata. See Typed Routes.

Search interpretation belongs to the code supplying the codec: createPages
parses incoming page queries, and client search hooks parse the current query
using an explicit factory or { path, searchCodec } descriptor. A custom
defineRouter resolver receives the query string and chooses how to parse it.
Generated SearchCodecsConfig types describe PageProps.search, not navigation.
Outbound construction calls serialize without reparsing its result; the
destination must independently validate its incoming query.

Loading Without a Cache

unstable_load from waku/router/client-core accepts a fetch callback. It follows
fetch-time redirects and custom 404s, enforces the follow budget, and stops waiting
when cancelled. It does not require the Router cache or merge elements.

This hook binds the loader to the enclosing Minimal Root without a Router cache:

tsx
import { useFetchRsc_UNSTABLE } from 'waku/minimal/client';
import {
  type Unstable_FetchRoute,
  type Unstable_LoadOptions,
  type Unstable_RouteProps,
  unstable_createRscParams,
  unstable_encodeRoutePath,
  unstable_load,
} from 'waku/router/client-core';

function useLoadRoute() {
  const fetchRsc = useFetchRsc_UNSTABLE();
  const fetchRoute: Unstable_FetchRoute = ({ route }, signal) =>
    fetchRsc(
      unstable_encodeRoutePath(route.path),
      unstable_createRscParams(route.query),
      { signal },
    );
  return (requested: Unstable_RouteProps, options: Unstable_LoadOptions) =>
    unstable_load(fetchRoute, requested, options);
}

Call useLoadRoute() while rendering your binding, then call its returned
function from a navigation handler:

ts
const outcome = await loadRoute(requested, {
  signal: controller.signal,
  has404,
  settled,
  url: requestedUrl,
  follows,
});

Here settled identifies the route whose composition the binding retains for
reuse, which can be accepted before it paints. follows is zero for a new
navigation or the preceding count for a render-time follow. The callback receives
each attempt's route, intended browser URL, and follow count, plus the cancellation
signal. It can supply unstable_base to fetchRsc for etag reuse when the binding
retains the corresponding elements.

Outcome Binding responsibility
loaded Reconcile response metadata and queue its elements with navigation intent.
reused Retain existing content and queue the requested route metadata and intent.
external Leave the document for url, applying the binding's history policy.
failed Present the error at the attempted URL.
aborted Discard the result.

A callback returns undefined only to reuse usable content already in this Root;
missing routes must reject with a not-found error. A hash-only fetch redirect back
to the settled RSC route also returns reused, avoiding an identical fetch.
outcome.route describes the final attempt, not necessarily the server-rendered
destination: reconcile ROUTE using the URL rules above. Streamed-slot errors
must continue the same follow budget through the binding's error boundary.

Bindings that want Router's cache policy call useRouterCache_UNSTABLE() during
rendering. Its cache.load(requested, { ...options, base: elements }) wraps the
same loader with static reuse, prefetch, and optional response adoption. A
cache-free binding does not need to construct a cache-shaped adapter.
Network results contain response patches; omitted entries are not filled from
the request-time base. Cached prefetches return their stored element maps.
Bindings that read a complete snapshot can materialize a loaded result:

ts
import { unstable_combineElements as combineElements } from 'waku/minimal/client';

const outcome = await cache.load(requested, { ...options, base });
if (outcome.type === 'loaded') {
  const snapshot = combineElements(base, outcome.elements);
}

Queue the response patch, not this request-time snapshot, when preserving later
action or client updates. unstable_buildMergePatch implements a snapshot-conflict
policy for custom bindings; the standard binding queues response patches directly.

If an instant paint has already merged an adopted response, the binding must not
merge it again. A follow fetch produces a new response requiring an ordinary
commit.

Client Commits

The standard client keeps elements and navigation intent in one Minimal Root
update queue. The intent is stored under the client-only ROUTER_STATE_ID symbol;
it is neither a server response field nor another slot. Minimal merges it without
interpreting it.

State Owner Purpose
Elements and attached Router state Minimal Root's React state Select the content and URL intent that render together.
Requested or accepted route Router ref Cancel pending work and route action results until UI catches up.
Applied snapshot identity and destination Router's layout effect Write history and scroll once for each committed intent.
Fetch error Router's React state Render the failure and clear it when another request starts.

Each request carries forward accepted RSC elements as its cache baseline,
which can be ahead of the last painted route. Accepted instant responses retain
their prefetch fallback and reuse the eager paint's navigation intent without
repeating history and scrolling. The eager merge represents its queued input and
prefetch fallback as pinned values or response-backed placeholders. A private
instant-state wrapper records represented slot IDs for each element snapshot,
while retaining the same navigation intent identity through React replays.
Acceptance backfills only unrepresented response entries and replaces the
wrapper with ordinary Router state. A later queued removal of a represented
slot stays removed even if the response returns it. Until that state commits,
their cache baseline makes no etag
claims because it can differ from the latest queued values. Static-route
eligibility is learned when the accepted instant state commits.
A metadata-only action dispatch makes no element-validator claims for its
destination. This baseline does not track every queued action or client merge;
response patches merge with the latest queued elements through Minimal's
update queue.
After a failure, refetch decisions use the retained request origin, not an
instant preview whose response may have been cancelled. Action origins use
the retained elements' route metadata as described below. Failed commits do
not learn static routes from retained preview metadata. Before replacing the
queued Router state, a failure resolves its retained origin into route metadata,
preserving static-page queries and instant-preview origins.

An ordinary navigation queues its pending elements promise when fetching starts.
Later action and client merges follow it in React's update order. fetchRsc
returns the decoded response patch without filling omitted entries from its
request-time base. Returned entries replace earlier values, including equal
primitive values; omitted entries retain queued values and removals. Prefetching
combines its retained base with the response to provide
reusable element maps; instant merging fills omitted slots from its prefetch
base. A rejected instant response preserves the previous queued values rather
than replacing them with prefetch values. Instant fetch cancellation stops
waiting even when transport ignores the signal.
The destination's route slot and protocol metadata are reapplied when the
response arrives so an action from the previous route cannot restore its
composition over the destination.
Redirect and 404 follows after an instant paint queue their pending response
patch before later action and client updates, using the ordinary navigation
merge and destination finalization. Response metadata identifies the resolved
route and is applied independently of slot conflicts. Reusing a cached static
route restores its static classification.
At merge time, a missing destination route slot can be filled from the retained
cache, including an accepted instant response's prefetch fallback. An existing
queued value for that slot and unrelated updates or removals are preserved.

Fetching and committing are different boundaries. An ordinary fetch can finish
while a client component is suspended; its navigation promise does not wait for
that component. Successful ordinary navigations apply history and scrolling
after the snapshot commits. Eligible instant navigations can commit a cached
shell while fetching continues. Fetch failures and external redirects write
history without waiting for a successful content commit; failures render an
error rather than a slot that was never fetched. The render-error boundary has
its own reset conditions, separate from clearing the Router's fetch error.

Router's useMergeInstantElements owns the eager rendering policy. It uses
Minimal's merge callback to retain pinned slots, wait on the response for other
slots, and include cached immutable slots and client navigation intent. The
callback starts from the latest queued elements, so it cannot overwrite an
earlier queued update with a rendered snapshot. Settlement adds new slots and
updates response-owned overlay keys only while that instant merge still owns
the queued promise. Minimal owns updater replay safety, not SWR policy.

Cached and same-RSC-route commits queue before the first await, retaining the
caller's React priority. Transition callbacks run immediately, so wrapping an
uncached navigation does not postpone superseding the previous request.

Superseding a pending request resolves its queued elements update to an empty
patch. Once the response is accepted, its elements remain in the update chain,
even if rendering is suspended. A later navigation appends a new pending promise;
React controls which intermediate renders commit. No committed snapshot is copied
back into the queue, and unrelated updates retain their original order.

Ordinary element merges retain the attached state object by reference. The
layout effect recognizes that identity and does not repeat its scroll or history
intent. An external history.replaceState query/hash change is consequently not
undone by an unrelated element merge. A server redirect can change the destination
for the same intent; that correction replaces history rather than pushing again.

RSC hot reload selects an accepted destination before retiring the request. If
only a pending fetch exists, it refreshes the visible route. This keeps an
accepted but suspended navigation from being replaced by the previous page.

Action Intent

useActionRouting_UNSTABLE sends the settled route at action request time in
X-Waku-Action-Location. This header is client-to-server information.
After a failed navigation, the action origin follows the retained elements'
route metadata, which can be ahead of the request's cache baseline after an
instant paint. The failed destination's URL remains in history.

  • rerenderRoute() refreshes that origin and marks the response IS_ORIGIN.
    If the user has navigated away or a conflicting navigation is pending, the client
    discards its elements but retains the action's return value.
  • rerenderRoute(destination) and action redirects express navigation intent.
    Their destination must not be discarded merely because the user left the origin.

Install useActionRouting_UNSTABLE in a client binding rather than recreating this
policy. It registers before descendant passive effects can start an action. See
Rerender After Actions.

Reading the Implementation

Paths below are relative to packages/waku/src/router.

Responsibility File
Protocol IDs, RSC inputs, slot IDs, and metadata readers isomorphic-utils/router-protocol.ts
Route resolution to elements and route metadata define-router-utils/route-entries.ts
Request handling, redirects, not-found, and action responses define-router-utils/request-handler.tsx
Client action intent and stale-origin protection client-core-utils/action-routing.ts
Fetch-time follows and render-time follow policy client-core-utils/load.ts and client-core-utils/error-route.ts
Element/route/history commit in the standard binding client-utils/navigation.ts and client-utils/router-state.ts
Cached eager paints and response adoption client-utils/instant-navigation.ts
Pinned slots, waiting values, and instant response settlement client-utils/instant-elements.ts
Rendered error handling and boundary resets client-utils/render-error.tsx and client.tsx

packages/waku/tests/router-protocol.test.tsx exercises server responses through
Minimal's client and Router's public helpers, substituting only Flight
serialization. The executable alternative binding in
e2e/fixtures/nav-api-spike/src/nav-binding.tsx uses public Minimal and client-core
APIs; its browser tests exercise the real transport.


Guides Static Deployments (`docs/guides/static-deployments.mdx`)


slug: static-deployments title: Static Deployments category: Deployment order: 5

When Static Deployment Fits

A static deployment serves only the files generated during waku build. There is no Waku server, worker, Lambda function, or other request-time runtime after deploy.

Use a static deployment for apps where every public route can be known and rendered at build time:

  • marketing sites
  • documentation sites
  • blogs with generated static paths
  • static resources such as RSS feeds, sitemaps, and JSON files

Do not use a static-only deployment if the app needs request-time behavior:

  • dynamic routes without staticPaths
  • dynamic API routes
  • server actions
  • per-request cookies, headers, authentication, or personalization
  • middleware that must run for deployed requests
  • redirects or rewrites that must be decided at request time

If you need those features, deploy Waku with a server-capable adapter instead. Static pages can still be part of a server-capable deployment.

Make Routes Static

Pages, layouts, and slices are static by default in the file-system router. For routes that matter to a static deployment, it is still useful to be explicit:

tsx
// src/pages/index.tsx
export default async function HomePage() {
  return <main>...</main>;
}

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

Dynamic segments need staticPaths so Waku knows which concrete URLs to emit:

tsx
// src/pages/blog/[slug].tsx
export default async function BlogPost({ slug }: { slug: string }) {
  return <article>...</article>;
}

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

For nested segments, use arrays in the same order as the route parameters:

tsx
// src/pages/docs/[section]/[slug].tsx
export const getConfig = async () => {
  return {
    render: 'static',
    staticPaths: [
      ['guides', 'quick-start'],
      ['reference', 'configuration'],
    ],
  } as const;
};

API routes are dynamic by default. If an API route produces a build-time resource, mark it static too:

ts
// src/pages/_api/rss.xml.ts
export const GET = async () => {
  return new Response(generateRss(), {
    headers: { 'content-type': 'application/rss+xml' },
  });
};

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

Build Output

Run the normal production build:

sh
pnpm exec waku build

Static files are written to dist/public. Publish that entire directory:

  • generated HTML files
  • generated RSC payloads under Waku's RSC path
  • Vite client assets
  • files copied from public
  • static API route output

Do not publish only the HTML and asset files. The generated RSC payloads are part of the static app and are used during client-side navigation.

Static Hosts

For a plain static host, configure the publish directory as:

txt
dist/public

The host should serve generated files as they exist on disk. Avoid using a single-page-app fallback to serve index.html for every path. If a route is not emitted during the build, either add it to staticPaths or use a server-capable deployment.

If the host lets you configure response headers, keep Waku's generated RSC files deployable and cache immutable client assets aggressively. For public RSC payloads, a common choice is to prevent search indexing:

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

Adapter Static Mode

Some platform adapters can generate platform-specific static deployment output. Use their static option when you want that platform integration without a serverless function:

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

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

Use the adapter for your platform:

Platform Adapter Static output behavior
Vercel waku/adapters/vercel copies dist/public to .vercel/output/static
Netlify waku/adapters/netlify uses dist/public as the publish directory
Cloudflare waku/adapters/cloudflare configures Wrangler static assets to serve from dist/public

The static option only changes the deployment output. It does not make dynamic Waku features work without a runtime.

Static Rendering Is Not No SSR

Static rendering prerenders route output at build time and writes that output to dist/public. The generated HTML can contain the page's rendered content.

No SSR serves a fallback shell and lets the browser render the route after JavaScript starts. It is useful for different tradeoffs and does not make an app deployable without a server runtime. For that pattern, see No SSR and Static Fallbacks.


Guides Stream Intercept (`docs/guides/stream-intercept.mdx`)


slug: stream-intercept title: SSR Stream Interception 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 (`docs/guides/typed-routes.mdx`)


slug: typed-routes title: Typed Routes category: Routing and Navigation order: 11 tags: [Experimental]

Overview

Waku navigation accepts URL strings. Use unstable_createRouteHref from waku/router to construct them with typed path params and optional search. Factories are optional: <Link to="/about"> and router.push('/about') work without one.

The APIs in this guide are unstable and may change. For upgrading existing typed navigation, see Migration.

Build Route Hrefs

Define a factory in a module that both Server and Client Components can import:

ts
// ./src/lib/routes.ts
import { unstable_createRouteHref as createRouteHref } from 'waku/router';

export const post = createRouteHref({ path: '/posts/[slug]' });
export const docs = createRouteHref({ path: '/docs/[...path]' });
export const home = createRouteHref({ path: '/' });

Call it to encode params and optionally append a fragment:

tsx
import { Link } from 'waku';
import { docs, home, post } from '../lib/routes';

export default function Navigation() {
  return (
    <nav>
      <Link to={home()}>Home</Link>
      <Link to={post({ params: { slug: 'hello world' }, hash: 'top' })}>
        Post
      </Link>
      <Link to={docs({ params: { path: ['guides', 'routing'] } })}>Docs</Link>
    </nav>
  );
}
  • params is required for patterns with slugs. Named params are strings; catch-alls accept readonly arrays of nonempty strings. An empty array is allowed when the pattern matches the resulting path.
  • A factory without params allows a call such as home().
  • Route groups are stripped from the URL. hash accepts a fragment with or without the leading #.
  • Completed . and .. path segments are rejected because URL normalization would change the path.
  • The result is a primitive string, so it can cross a Server/Client Component boundary. Import factories and codecs where they are used; their functions cannot be passed as ordinary Client Component props.
  • Hrefs exclude the application's base path. Waku's Link, navigation methods, and unstable_redirect apply the base. A native <a href> needs a URL that includes the base.

Client navigation uses the same result:

tsx
'use client';

import { useRouter } from 'waku';
import { post } from '../lib/routes';

export const PostButton = ({ slug }: { slug: string }) => {
  const router = useRouter();
  const href = post({ params: { slug } });
  return <button onClick={() => router.push(href)}>Open post</button>;
};

router.replace(href), router.prefetch(href), and server-side unstable_redirect(href) also accept factory results. For same-page navigation, bare ?query and #hash strings are resolved against the current URL.

What the types check

Generated routes distinguish raw hrefs from constructed hrefs. Raw strings are checked against the configured concrete href types. A factory result carries a type-only brand recording its normalized route pattern; navigation checks that pattern against the generated route patterns.

Bind factories to registered patterns, not concrete build URLs: for /articles/[slug], use that pattern with { params: { slug: 'intro' } }, or pass the raw string '/articles/intro' directly to navigation. Navigation rejects a factory result for /articles/intro unless that exact pattern is registered. Factory results are assignable to string, but do not have literal URL types.

Parameter names are part of the pattern: /articles/[id] does not match a registered /articles/[slug].

For a static /posts/[slug] route, a factory checks the parameter shape, not whether a slug is among the generated static paths. Constructing an unbuilt slug can therefore succeed and navigate to not found. The brand has no runtime representation and does not register a route.

Without generated route patterns, factory arguments are inferred locally but the pattern's existence is unchecked. A manual RouteConfig.paths augmentation constrains raw hrefs only; see createPages type safety. Widening a factory result to string loses its type evidence, so it may be rejected by navigation in a strictly typed app.

Reading Route Params

useParams_UNSTABLE reads decoded params from the current route. It returns null when the pathname does not match:

tsx
'use client';

import { useParams_UNSTABLE as useParams } from 'waku/router/client';

export const PostTitle = () => {
  const params = useParams({ from: '/posts/[slug]' });
  if (!params) {
    return null;
  }
  return <h1>{params.slug}</h1>;
};

Named params are strings and catch-alls are string arrays. Page components already receive params through props; this hook is useful for shared Client Components.

Search Params

A search codec is a plain object with parse and serialize. Keep it in a module shared by the page and its href factory:

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

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

export const productSearchCodec = {
  parse: (query: string): ProductsSearch => {
    const params = new URLSearchParams(query);
    const page = Number(params.get('page') ?? '1');
    if (!Number.isInteger(page) || page < 1) {
      throw new Error('Invalid page');
    }
    return { q: params.get('q') ?? '', page };
  },
  serialize: ({ q, page }: ProductsSearch): string => {
    if (!Number.isInteger(page) || page < 1) {
      throw new Error('Invalid page');
    }
    return new URLSearchParams({ q, page: String(page) }).toString();
  },
} satisfies Unstable_SearchCodec<ProductsSearch>;

parse receives the incoming query without ?, applies defaults and validation, and returns the typed search value. The server turns a parsing failure into a 400. Client hooks also parse the current query; their errors propagate to the caller or render boundary.

serialize owns outbound validation and returns an encoded query without ?. Its errors propagate when constructing an href or updating search; calling a factory during render can therefore fail the component that renders the link. In development and production, Waku does not parse the serialized output to validate it. If a codec accepts invalid input during serialization, the destination may reject the resulting query.

ts
// ./src/lib/routes.ts
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { productSearchCodec } from './search';

export const product = createRouteHref({
  path: '/products/[id]',
  search: productSearchCodec,
});
tsx
import { Link } from 'waku';
import { product } from '../lib/routes';

export default function ProductLink() {
  return (
    <Link
      to={product({
        params: { id: '42' },
        search: { q: 'waku', page: 2 },
      })}
    >
      Product
    </Link>
  );
}

The factory infers search from its codec without a provider, codec ID, or runtime registry. It exposes .path and .searchCodec for the search hooks. A factory without a codec accepts no typed search argument; use a URL string for ad-hoc queries.

search is optional. Omitting it emits no query and calls neither codec function. The destination still parses the incoming empty query and can reject it if its codec requires search values.

Attach the codec to a page

With the file-system router, import the independent codec in getConfig. The generated SearchCodecsConfig supplies the type of PageProps.search; navigation infers types from the factory itself.

tsx
// ./src/pages/products/[id].tsx
import type { PageProps } from 'waku/router';
import { productSearchCodec } from '../../lib/search';

export default function ProductPage({
  id,
  search,
}: PageProps<'/products/[id]'>) {
  return (
    <p>
      Product {id}: {search.q} (page {search.page})
    </p>
  );
}

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

With createPages, attach the same codec and declare its page-prop mapping:

tsx
// ./src/waku.server.tsx
import { productSearchCodec } from './lib/search';

declare module 'waku/router' {
  interface SearchCodecsConfig {
    '/products/[id]': typeof productSearchCodec;
  }
}

createPage({
  render: 'dynamic',
  path: '/products/[id]',
  component: ProductPage,
  unstable_searchCodec: productSearchCodec,
});

The factory does not compare its codec with the page configuration; share one independent export to keep them consistent and keep server configuration independent of URL construction. A custom defineRouter resolver can parse its query with this same codec directly; it needs no codec registration.

Read and update search on the client

tsx
// ./src/components/pager.tsx
'use client';

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

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

Both hooks also accept a { path, searchCodec } descriptor. They infer search from the supplied codec, not from SearchCodecsConfig. Generated route types reject unknown patterns, ignoring route groups. Pass descriptors inline or preserve a named descriptor's literal path with as const.

  • useSearch returns the parsed search, or null for a different pathname.
  • The setter is a no-op for a different pathname. Otherwise, it parses the current query, shallow-merges a partial value or updater result, serializes, and navigates.
  • Updates preserve the pathname and fragment, default to push and no scrolling, and accept { history: 'replace', scroll: true } as the second argument.
  • Parsing, serialization, and navigation errors reject the setter's promise. There is no parse check of its newly serialized output.
  • Parser defaults participate in the merge. Have the serializer omit defaults if you do not want them written into the URL.

Using a validation library

A schema library can implement both codec operations. For example, with Zod:

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

const schema = z.object({
  q: z.string().default(''),
  page: z.coerce.number().int().min(1).default(1),
});

type ProductsSearch = z.infer<typeof schema>;

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

The schema validates inbound queries and outbound values independently. Query-state libraries such as nuqs can also supply parsers and serializers through an isomorphic module such as nuqs/server; return a query without its leading ? and implement any required outbound validation in serialize. Waku's hooks do not require the library's client provider.

Migration

The typed-navigation API changes are breaking, including for unstable exports.

  1. Replace structured to targets with a local factory call. Pass its string result to Link, push, replace, prefetch, or unstable_redirect:

    tsx
    import { unstable_createRouteHref as createRouteHref } from 'waku/router';
    
    const post = createRouteHref({ path: '/posts/[slug]' });
    
    <Link to={post({ params: { slug } })}>Post</Link>;
    router.push(post({ params: { slug } }));

    This replaces to={{ to: '/posts/[slug]', params: { slug } }} and router.push({ to: '/posts/[slug]', params: { slug } }). Plain string targets do not change. For link wrappers, use LinkProps without a route-pattern type argument.

  2. Remove SearchCodecsProvider_UNSTABLE and codec id fields. Import the codec into the factory via search: codec, and into the page configuration via unstable_searchCodec: codec.

  3. Replace useSearch({ from: '/products/[id]' }) and useSetSearch({ from: '/products/[id]' }) with useSearch(product) and useSetSearch(product), or pass { path: '/products/[id]', searchCodec }. useParams({ from }) is unchanged.

  4. Remove getSearchCodecs from unstable_defineRouter. Keep the shared codec wherever the resolver parses incoming queries. Generated or manual SearchCodecsConfig mappings remain for PageProps.search, not client navigation.

  5. Replace unstable_buildRouteHref from waku/router/client-core with unstable_createRouteHref from waku/router. The factory takes the codec directly and needs no resolver callback. The provider helpers unstable_isCodec and Unstable_AnyCodec are removed; use the Unstable_SearchCodec<Search> contract for a known search type.


Guides Getting Started Comparison (`docs/guides/getting-started/comparison.mdx`)


slug: comparison title: Comparison 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 (`docs/guides/getting-started/introduction.mdx`)


slug: introduction title: What is Waku? 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 (`docs/guides/getting-started/philosophy.mdx`)


slug: philosophy title: Philosophy 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 (`docs/guides/getting-started/quick-start.mdx`)


slug: quick-start title: Quick Start 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. In production, statically generated pages are served before middleware runs, so it only applies to dynamic routes; see Redirect Maps.
  • public files are copied to the site root unchanged, such as images and fonts.
  • waku.config.ts configures Waku and Vite. The starter enables Tailwind CSS and React Compiler plugins.

While the development server is running, Waku also generates src/pages.gen.ts containing route types for type-safe links. It is regenerated automatically whenever your pages change, so you never edit it by hand.

Make your first edit

Open src/pages/index.tsx:

tsx
// ./src/pages/index.tsx (excerpt)
export default async function HomePage() {
  const data = await getData();

  return (
    <div>
      <title>{data.title}</title>
      <h1 className="text-4xl font-bold tracking-tight">{data.headline}</h1>
      <p>{data.body}</p>
      <Counter />
      <Link to="/about" className="mt-4 inline-block underline">
        About page
      </Link>
    </div>
  );
}

The content comes from a getData function defined below the component. Change its body text and save. The browser updates instantly without a full reload.

Commands

Next Step

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


Guides Getting Started Use Cases (`docs/guides/getting-started/use-cases.mdx`)


slug: use-cases title: Use Cases 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 Authentication (`docs/guides/integrations/authentication.mdx`)


slug: authentication title: Authentication category: Integrations order: 20

What Waku provides

Waku does not ship an authentication system; that is a deliberate boundary (see Use Cases). What it provides is enough to plug one in: request headers in server code, server actions for signing in and out, and middleware that sees every request and owns the response. This guide shows where each part of an auth flow goes, and the two places where the obvious approach does not work.

The session itself can come from a library or, as in the code below, from a few lines over a signed cookie. Everything here is condensed from the nextjs-dashboard example in the waku-examples repository, which runs the full flow end to end.

Reading the session

The snippets use two small libraries β€” cookie to parse and serialize headers
and jose to sign and verify the token β€” and the cookie jar below imports
node:async_hooks, whose declarations come from @types/node, which the starter
template does not include. hono, whose MiddlewareHandler type the middleware
imports, is already a dependency of a Waku project.

sh
npm install cookie jose
npm install --save-dev @types/node

Server code reads request cookies from getRequest().headers. Wrap that in one function and use it everywhere:

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

auth() works anywhere the router runs your code: pages, layouts, slices, server actions and API route handlers. It does not work in a static render. Static pages and layouts are prerendered at build time, so anything that reads the session must be render: 'dynamic'; see Sessions on static pages for keeping the rest of the page static.

Protecting routes

There are two different jobs here, and they go in two different places.

Authorization belongs in the data layer. Every function that returns
protected data, and every mutation, verifies the session before doing anything
else:

ts
// ./src/lib/session.ts (continued)
export const requireSession = async (): Promise<Session> => {
  const session = await auth();
  if (!session) {
    throw new Error('Unauthorized');
  }
  return session;
};
ts
// ./src/lib/data.ts
export async function fetchInvoices() {
  await requireSession();
  return sql`SELECT ...`;
}
ts
// ./src/lib/actions.ts
'use server';

export async function deleteInvoice(id: string) {
  await requireSession();
  await sql`DELETE FROM invoices WHERE id = ${id}`;
}

This is the boundary, and it has to be, because of how a route renders. Waku
renders a route's layouts and its page as independent slots of one response. A
redirect() thrown in a layout redirects the layout; it does not stop the page
beneath it from rendering. With a check in the layout only, an unauthenticated
request still gets the page's data:

text
GET /dashboard                     -> 307  Location: /login?callbackUrl=%2Fdashboard
GET /RSC/R/dashboard.txt           -> 200  22,532 bytes, including every figure on the page

With requireSession() in the data functions, the same request carries error
slots and nothing else. A server action is an endpoint of its own and an API
route is outside every layout, so neither is covered by anything but its own
check either.

The redirect is for people. Put it in middleware, so that a signed-out
visitor lands on the login page before anything renders. Match the route rather
than the url, and build the redirect in the same shape as the request:

ts
// ./src/middleware/auth.ts
import * as cookie from 'cookie';
import type { MiddlewareHandler } from 'hono';
import {
  unstable_formatRouterRequest as formatRouterRequest,
  unstable_parseRouterRequest as parseRouterRequest,
} from 'waku/router/server';
import { verifySessionToken } from '../lib/session';

const auth = (): MiddlewareHandler => async (c, next) => {
  const route = parseRouterRequest(c.req.raw);
  if (
    route?.type === 'route' &&
    (route.path === '/dashboard' || route.path.startsWith('/dashboard/'))
  ) {
    // Middleware runs before the router, so it reads the cookie from Hono.
    const cookies = cookie.parseCookie(c.req.header('cookie') ?? '');
    const session = await verifySessionToken(cookies.session);
    // Pass the requested route along so that signing in can return to it.
    const callbackUrl = route.query
      ? `${route.path}?${route.query}`
      : route.path;
    const location =
      !session &&
      formatRouterRequest(
        c.req.raw,
        '/login',
        new URLSearchParams({ callbackUrl }).toString(),
      );
    if (location) {
      // 303, so a form posted without JavaScript is not resent to /login
      return c.redirect(location.pathname + location.search, 303);
    }
  }
  await next();
};

export default auth;

Both ends of the redirect render dynamically. In production, a route prerendered
as static is served before middleware runs, so every page under /dashboard
must be render: 'dynamic', as anything that reads the session must be anyway.
So must the login page: a client-side navigation redirected to a static /login
receives its prerendered payload, which carries no callbackUrl.

tsx
// ./src/pages/login.tsx, and each page under ./src/pages/dashboard
export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};

An API route answers with a status instead:

ts
// ./src/pages/_api/report.ts
import { auth } from '../../lib/session';

export async function GET() {
  if (!(await auth())) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }
  // ...
}

Why the middleware matches routes

If you are coming from a framework where route protection lives in middleware, the natural port is a Hono middleware that matches the path:

ts
// This looks right and is not enough.
if (c.req.path.startsWith('/dashboard') && !session) {
  return c.redirect('/login');
}

c.req.path only sees document requests. A client-side navigation to /dashboard requests /RSC/R/dashboard.txt instead, so the middleware never matches and the protected route's payload is served:

text
GET /dashboard                    -> 302  Location: /login
GET /RSC/R/dashboard.txt?query=   -> 200  the rendered dashboard

A visitor with no session who clicks a <Link to="/dashboard"> gets the page. The check passes the obvious manual test (loading the URL redirects) and fails on the path users actually take.

unstable_parseRouterRequest reads either url as the route it addresses, which is why the middleware above matches route.path. Redirecting the RSC request to /login would then send the fetch a document, which the client fails to decode as a Flight payload, so unstable_formatRouterRequest builds the destination in the shape the caller asked for:

text
GET /dashboard             -> 303  Location: /login?callbackUrl=%2Fdashboard
GET /RSC/R/dashboard.txt   -> 303  Location: /RSC/R/login.txt?query=callbackUrl%3D%252Fdashboard

A route match is still an optimistic redirect, not a boundary. A server action dispatched from the client carries no route β€” it reports { type: 'action' } β€” so no path rule covers it, which is why the data layer checks for itself.

Waku has no cookie-writing API. Server components and server functions can read the request; only middleware owns the response. Setting a cookie from a sign-in action therefore takes a small bridge: a request-scoped jar that the action writes into and middleware empties onto the response.

ts
// ./src/lib/cookie-jar.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import * as cookie from 'cookie';

type CookieJar = { setCookies: string[] };

const storage = new AsyncLocalStorage<CookieJar>();

export const runWithCookieJar = <T>(fn: () => Promise<T>) =>
  storage.run({ setCookies: [] }, fn);

export const getCookieJar = () => storage.getStore();

export const queueSetCookie = (value: string) => {
  const jar = storage.getStore();
  if (!jar) {
    throw new Error(
      'No cookie jar in scope. Is the cookies middleware registered?',
    );
  }
  jar.setCookies.push(value);
};

// A cookie queued earlier in this same request, so a render that follows the
// write sees it. See "The read-through" below for why this matters.
export const readPendingCookie = (name: string): string | undefined => {
  const queued = storage.getStore()?.setCookies ?? [];
  for (let i = queued.length - 1; i >= 0; i -= 1) {
    const parsed = cookie.parseSetCookie(queued[i]!);
    if (parsed?.name === name) {
      return parsed.value;
    }
  }
  return undefined;
};
ts
// ./src/middleware/cookies.ts
import type { MiddlewareHandler } from 'hono';
import { getCookieJar, runWithCookieJar } from '../lib/cookie-jar';

const cookies = (): MiddlewareHandler => {
  return async (c, next) => {
    await runWithCookieJar(async () => {
      await next();
      const jar = getCookieJar();
      if (!jar?.setCookies.length || !c.res) {
        return;
      }
      const headers = new Headers(c.res.headers);
      for (const value of jar.setCookies) {
        headers.append('set-cookie', value);
      }
      c.res = new Response(c.res.body, {
        status: c.res.status,
        statusText: c.res.statusText,
        headers,
      });
    });
  };
};

export default cookies;

The sign-in action verifies the credentials, queues the cookie, and redirects:

ts
// ./src/lib/session.ts (continued)
import { SignJWT } from 'jose';
import { queueSetCookie } from './cookie-jar';

export const signIn = async (userId: string) => {
  const token = await new SignJWT({})
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setExpirationTime('7d')
    .sign(getSecret());

  queueSetCookie(
    cookie.stringifySetCookie({
      name: 'session',
      value: token,
      httpOnly: true,
      path: '/',
      sameSite: 'lax',
      // Secure by default; only plain-http local development opts out. Do not
      // key this on NODE_ENV === 'production': Cloudflare Workers set no NODE_ENV.
      secure: getEnv('NODE_ENV') !== 'development',
      maxAge: 60 * 60 * 24 * 7,
    }),
  );
};
ts
// ./src/lib/actions.ts
'use server';

import { unstable_redirect as redirect } from 'waku/router/server';
import { signIn } from './session';
import { verifyPassword } from './users';

export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  const user = await verifyPassword(
    String(formData.get('email')),
    String(formData.get('password')),
  );
  if (!user) {
    return 'Invalid credentials.';
  }
  await signIn(user.id);
  // 303, so that a browser following the redirect without JavaScript requests
  // the destination with GET. The default 307 re-sends the form POST β€” with the
  // action encoded in it β€” to the destination.
  redirect(dashboardPath(String(formData.get('redirectTo') ?? '')), 303);
}

// Anyone can write a callbackUrl into the login page's query string, so follow
// it only to a same-origin path under /dashboard, which is all the middleware
// sends. The cast is needed because redirect() is typed against the app's
// routes.
const dashboardPath = (url: string) => {
  try {
    const { origin, pathname, search } = new URL(url, 'http://localhost');
    if (origin === 'http://localhost' && /^\/dashboard(\/|$)/.test(pathname)) {
      return `${pathname}${search}` as Parameters<typeof redirect>[0];
    }
  } catch {
    // Not a URL at all.
  }
  return '/dashboard';
};

The login form reads callbackUrl from useRouter().query and posts it back in a hidden redirectTo field.

The read-through

redirect() resolves on the server: the destination renders inside the same request that ran the action. At that point getRequest().headers still reports the cookies the browser sent, which do not include the one just queued, so a protected destination would render as signed out. The cookie is set, the login worked, and it looks like it did not.

That is what readPendingCookie is for. Make auth() prefer a cookie queued in this request over the one from the headers:

ts
import { readPendingCookie } from './cookie-jar';

export const auth = async (): Promise<Session | null> => {
  const pending = readPendingCookie('session');
  if (pending !== undefined) {
    return verifySessionToken(pending || undefined);
  }
  const cookies = cookie.parseCookie(getRequest().headers.get('cookie') ?? '');
  return verifySessionToken(cookies.session);
};

An empty pending value is a cookie being cleared, which is why it verifies as no session.

What does not work

Hono's c.header() cannot be used from server code to set the cookie, even with hono/context-storage making the context reachable. Waku's handler returns its own Response, which replaces c.res and discards headers prepared on the context, so the write is silently lost. Rebuilding the response in middleware after next(), as above, is the approach that works.

Signing out

Queue an expired cookie and redirect. Because auth() reads through the jar, the redirect destination already renders signed out:

ts
export const signOut = () => {
  queueSetCookie(
    cookie.stringifySetCookie({
      name: 'session',
      value: '',
      httpOnly: true,
      path: '/',
      sameSite: 'lax',
      maxAge: 0,
    }),
  );
};
tsx
<form
  action={async () => {
    'use server';
    signOut();
    redirect('/', 303);
  }}
>
  <button>Sign out</button>
</form>

Sessions on static pages

A page that reads the session cannot be prerendered, but a page that merely shows something session-dependent can be. Put the session-dependent part in a lazy dynamic slice: the page is built once, and the slice is rendered per request with the request's cookies.

tsx
// ./src/pages/_slices/account-menu.tsx
import { auth } from '../../lib/session';

export default async function AccountMenu() {
  const session = await auth();
  return session ? (
    <a href="/account">Account</a>
  ) : (
    <a href="/login">Sign in</a>
  );
}

export const getConfig = async () => {
  return {
    render: 'dynamic',
  } as const;
};
tsx
// ./src/pages/index.tsx
import { Slice } from 'waku';

export default async function HomePage() {
  return (
    <>
      <Slice id="account-menu" lazy fallback={<span />} />
      {/* the rest of the page is static */}
    </>
  );
}

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

Keep session reads as far down the tree as they can go. A root layout that reads the session makes every page dynamic.

Choosing a library

The code above is a complete, small session, and it is a reasonable place to stop for an app with a password form. If you want OAuth providers, email links, or session storage, any framework-agnostic library fits the same shape: it verifies credentials in a server action or API route, and it needs auth() to read its cookie. Writing one depends on where it runs: a server action needs the cookie jar above, while an API route can return the library's own Response unchanged, Set-Cookie and all. Auth.js (@auth/core, not a framework wrapper) and Better Auth are two options. A library that is built on another framework's internals, such as one that imports that framework's request or header helpers, will not port.

Runtime notes

The cookie jar uses AsyncLocalStorage, which Waku itself already depends on, so it runs wherever Waku runs: Node.js, Bun, Deno, and Cloudflare Workers with the nodejs_als compatibility flag that the Cloudflare guide sets. Keep the jar request-scoped and small; it exists only to carry headers from where they are decided to where they are written.
Read configuration through getEnv from waku rather than process.env, which Node.js provides and the other runtimes do not.


Guides Integrations Explicit Caching (`docs/guides/integrations/explicit-caching.mdx`)


slug: explicit-caching title: Explicit Caching 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.

An instant navigation drops the etags of dynamic slots; see Instant Navigation for when that costs a render and how to avoid it.

What stays uncached

During client-side navigation, the router reuses prefetched RSC payloads so that moving between pages is fast. This is a browser-side navigation optimization: it does not create a server data cache. Static payloads can be reused freely because they change only with a deploy. An explicitly prefetched dynamic response may also be reused until its prefetch TTL expires (60 seconds by default), so a navigation shortly after a prefetch may not issue a new request; whenever a request does reach the server, it renders fresh. See Navigation and Prefetching for the details and the experimental tuning APIs.

Beyond the application, other caches exist with their own controls: the browser cache and CDNs (driven by HTTP headers), static build output (typically served with long-lived caching by hosts), and database drivers or API SDKs that cache internally. Waku does not control these caches; they can affect whether a request reaches your server at all, and what data your dependencies return when it does.

Common misconceptions

  • "Dynamic means uncached." Dynamic means uncached by Waku. You can still cache expensive work explicitly, and infrastructure layers can cache responses if you tell them to.
  • "Static output is a cache." Static output is a build artifact. It has no keys, no TTL, and no invalidation API; it is replaced wholesale by the next build.
  • "Client navigation reuse makes server data stale." It only affects what the browser shows during navigation; the server's answer to any request it receives is computed fresh.
  • "Server components run once." Static server components execute at build time; dynamic server components execute on every request. Neither runs in the browser.
  • "Every framework needs a revalidation model." An invalidation responsibility appears when a cache is added. Waku without an explicit cache has nothing to invalidate.

Guides Integrations State Across Client Server (`docs/guides/integrations/state-across-client-server.mdx`)


slug: state-across-client-server title: State Across Client and Server category: Integrations order: 30 tags: [Experimental]

When to Use `unstable_allowServer`

Sometimes a value must be declared in a client module, but server code still needs to import that one value. unstable_allowServer marks a specific export from a 'use client' module as safe for Waku's server build.

Use it for shared definitions, not live state:

  • Jotai atom definitions that must be colocated with a client component
  • small constants used by both server and client code
  • pure factory results that do not touch browser APIs

Do not use it for:

  • React components
  • hooks
  • functions that read window, document, localStorage, or browser-only globals
  • mutable singletons that should be request-scoped
  • secrets, database clients, or server-only resources
  • a shortcut to import an entire client module from server code

The API is highly experimental and may change.

Colocated Jotai Atom

This example follows the pattern used by the Waku Jotai examples. The atom is declared in the same client module as the component, and only the atom export is marked as server-safe.

tsx
// src/components/counter.tsx
'use client';

import { useTransition } from 'react';
import { atom, useAtom } from 'jotai';
import { unstable_allowServer as allowServer } from 'waku/client';

export const countAtom = allowServer(atom(1));

export const Counter = () => {
  const [count, setCount] = useAtom(countAtom);
  const [isPending, startTransition] = useTransition();

  const increment = () => {
    startTransition(() => {
      setCount((count) => count + 1);
    });
  };

  return (
    <button onClick={increment}>
      Count: {count}
      {isPending ? '...' : ''}
    </button>
  );
};

Because countAtom is wrapped with allowServer(...), server code can import that one export from the client module and use it with the Jotai store:

tsx
// src/pages/index.tsx
import { getStore } from 'waku-jotai/router';
import { Counter, countAtom } from '../components/counter';

export default async function Page() {
  const store = await getStore();

  return (
    <>
      <Counter />
      <p>Initial count: {store.get(countAtom)}</p>
    </>
  );
}

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

For file-system-router apps, wrap the route tree with RouterProvider:

tsx
// src/pages/_layout.tsx
import type { ReactNode } from 'react';
import { RouterProvider } from 'waku-jotai/router';

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

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

Every page or layout that reads getStore() or mounts RouterProvider must render dynamically: the store is request-scoped and the atom values arrive with each RSC request, so a static render would bake in build-time values.

If you use the Minimal API, use Provider and getStore from waku-jotai/minimal. Unlike RouterProvider, this Provider needs the RSC request identity as props:

tsx
<Provider rscPath="" rscParams={rscParams}>
  {children}
</Provider>

Prefer a Neutral Module When Possible

If the shared definition does not need to live in a client module, put it in a module without a 'use client' directive. React Server Components can import ordinary server-safe modules without unstable_allowServer.

For example, a Jotai atom definition can often live in a shared module:

ts
// src/state/count.ts
import { atom } from 'jotai/vanilla';

export const countAtom = atom(1);

Then client components can import that shared value from a client module:

tsx
// src/components/counter.tsx
'use client';

import { useAtom } from 'jotai';
import { countAtom } from '../state/count';

export const Counter = () => {
  const [count, setCount] = useAtom(countAtom);
  return (
    <button onClick={() => setCount((count) => count + 1)}>{count}</button>
  );
};

Server code can also import countAtom from src/state/count.ts if it has a real server-side use for the atom definition. Use this pattern when you can because it keeps the client/server boundary obvious.

What Waku Transforms

unstable_allowServer is an identity function at runtime. Its main purpose is to tell Waku's Vite plugin which expression from a client module should remain importable in the RSC build.

In the RSC environment, Waku transforms a 'use client' module so that:

  • the wrapped allowServer(...) export is preserved
  • dependencies used by that wrapped expression are preserved
  • other exports are not made callable server functions
  • the wrapper call itself is removed from the emitted server-side expression

This means the server can import countAtom, but it does not make every export in counter.tsx server-safe.

unstable_allowServer must receive exactly one argument:

tsx
export const countAtom = allowServer(atom(1));

What It Does Not Do

unstable_allowServer itself only gives the atom a shared, server-safe identity. When the waku-jotai provider is mounted, wrapped atom values are synchronized from the client to the server during RSC refetches: the client subscribes to the allowed atoms and refetches the route with their current values, which the server reads through the request-scoped store. The synchronization is one way; the server does not independently push atom updates back to the browser.

It also does not make browser-only code safe on the server. This is unsafe:

tsx
'use client';

import { unstable_allowServer as allowServer } from 'waku/client';

export const theme = allowServer(localStorage.getItem('theme'));

The server has no localStorage, so this module cannot be evaluated safely in the RSC environment.

Safer Alternatives

Before using unstable_allowServer, consider these alternatives:

  • Move shared definitions to a neutral module without 'use client'.
  • Pass serializable values from server components to client components as props.
  • Put request-specific data in request context or provider state.
  • Keep browser-only behavior inside client components and hooks.
  • Use provider boundaries to initialize client state from server-rendered values.

Use unstable_allowServer only for the narrow case where a client module must expose one server-safe definition and moving that definition to a neutral module is worse for the integration.


Guides Learn Building For Production (`docs/guides/learn/building-for-production.mdx`)


slug: building-for-production title: Building for Production 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:

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 (`docs/guides/learn/concept.mdx`)


slug: concept title: The Mental Model 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 (`docs/guides/learn/pages-and-layouts.mdx`)


slug: pages-and-layouts title: Pages and Layouts 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. When a layout and a page both declare a title, description, or scalar Open Graph tag, the page's wins. See Metadata.
  • <Link> performs client-side navigation between pages. Use it instead of <a> for internal links. For programmatic navigation, there is also a useRouter hook.

Layouts

A _layout.tsx file wraps every page in its directory and below. The starter's root layout at src/pages/_layout.tsx renders the header, footer, and global styles around all pages.

Layouts nest. Let's build a blog section with its own sub-layout. Create src/pages/blog/_layout.tsx:

tsx
// ./src/pages/blog/_layout.tsx
import type { ReactNode } from 'react';
import { Link } from 'waku';

export default async function BlogLayout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <div>
      <nav className="mb-4">
        <Link to="/blog" className="underline">
          Blog
        </Link>
      </nav>
      {children}
    </div>
  );
}

And a blog index page at src/pages/blog/index.tsx:

tsx
// ./src/pages/blog/index.tsx
export default async function BlogIndexPage() {
  return (
    <div>
      <title>Blog</title>
      <h1 className="text-4xl font-bold tracking-tight">Blog</h1>
      <p>Posts will appear here.</p>
    </div>
  );
}

Visiting /blog now renders the root layout, then the blog layout, then the page, outermost to innermost.

Dynamic segments

Square brackets in a file name declare a route segment whose value comes from the URL. Create src/pages/blog/[slug].tsx:

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

export default async function BlogPostPage({
  slug,
}: PageProps<'/blog/[slug]'>) {
  return (
    <div>
      <title>{slug}</title>
      <h1 className="text-4xl font-bold tracking-tight">{slug}</h1>
    </div>
  );
}

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

Visit /blog/hello-world and the page receives slug: 'hello-world' as a prop, typed via PageProps.

The getConfig export is required here: a segment page must declare how its concrete paths come about: either it renders on demand (render: 'dynamic', as above) or you list the paths to prerender at build time. Rendering modes are the subject of Static and Dynamic Rendering, 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 (`docs/guides/learn/server-and-client-components.mdx`)


slug: server-and-client-components title: Server and Client Components 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 (`docs/guides/learn/static-and-dynamic-rendering.mdx`)


slug: static-and-dynamic-rendering title: Static and Dynamic Rendering 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.


--- METRICS ---