---
slug: minimal-api
title: Minimal API
description: Low-level Waku primitives for library authors and custom integrations.
category: Low-level APIs
order: 10
---
## When to Use the Minimal API
The minimal API is the lowest-level public surface for building on top of Waku. It is intended for:
- library authors
- custom runtimes and integrations
- advanced users who need direct control over routing, request dispatch, and build output
If you are building an application, use `waku/router` instead. The minimal API deliberately does not provide:
- config-based routing / filesystem routing
- automatic route-to-component mapping
- automatic prerender planning
- page conventions such as `layout.tsx`, `page.tsx`, or `404.tsx`
If you need programmatic routing while keeping Waku's router behavior, see the [`createPages` reference](/create-pages) or [Custom Router](/guides/custom-router). If you are implementing a deployment/runtime adapter, start with [Adapter Authoring](/guides/adapter-authoring).
Waku Router's use of these primitives is described in [Router Protocol](/guides/router-protocol). Its route IDs and navigation policy belong to Router, not Minimal.
Exports marked `unstable_*` or `*_UNSTABLE` in this document may still change.
### API Surface
The Minimal API consists of server handler contracts and client rendering primitives:
| Entry point | Surface | Use when |
| --------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `waku/minimal/server` | `Unstable_Handlers` | Defining `handleRequest` and `handleBuild` for an adapter. |
| `waku/minimal/server` | `Unstable_HandleRequest`, `Unstable_HandleBuild` | Typing individual handlers. |
| `waku/minimal/server` | `Unstable_RenderRsc`, `Unstable_RenderHtml`, `Unstable_RenderHtmlFallback`, `Unstable_Etags` | Typing rendering utilities and validators. |
| `waku/minimal/server` | `unstable_createCustomError`, `unstable_getErrorInfo` | Creating and reading status and redirect errors. |
| `waku/minimal/client` | `Root_UNSTABLE` | Installing the minimal client runtime. |
| `waku/minimal/client` | `Slot_UNSTABLE` | Rendering a server element by RSC ID. |
| `waku/minimal/client` | `Children_UNSTABLE` | Placing client-side `Slot` children inside a server-rendered element. |
| `waku/minimal/client` | `useFetchRsc_UNSTABLE` | Fetching and decoding an RSC payload. |
| `waku/minimal/client` | `useMergeElements_UNSTABLE` | Merging an RSC payload into the current element map. |
| `waku/minimal/client` | `useElementsPromise_UNSTABLE` | Reading the current element map. |
| `waku/minimal/client` | `useRegisterRscEnhancer_UNSTABLE` | Deprecated effect-based registration, retained through rc.4. |
| `waku/minimal/client` | `useRegisterRscReloadListener_UNSTABLE` | Choosing the RSC input to refresh during development reloads. |
| `waku/minimal/client` | `unstable_combineElements` | Combining element maps without losing their etags. |
| `waku/minimal/client` | `unstable_getErrorInfo` | Reading status, redirect and network-error information. |
| `waku/minimal/client` | `unstable_formatRscUrl` | Constructing an RSC payload URL. |
Examples below often import these as shorter names.
`waku/adapter-builders` exports `Unstable_ServerEntry` for adapter authors
who own a complete server entry. Waku's bootstrap and HTML renderer use private
implementation modules.
Advanced composition APIs remain available to any Minimal-based framework:
- `unstable_combineElements(a, b, { filter })` selects keys from `b`
while preserving their validators.
- `unstable_isImmutableElement(elements, slotId)` inspects a slot's immutable
status without exposing its validator.
- `useMergeElements_UNSTABLE` accepts a merge callback for custom updates to
the Root's queued element promise.
- `unstable_buildElements`, `Unstable_ElementSource`, and
`Unstable_BuiltElements` build payloads that omit slots the client already
holds, using the etags supplied to `handleRequest`.
- `unstable_parseRequest(request)` inspects HTTP, RSC, and server-call URLs
without consuming their bodies. `unstable_formatRscUrl(rscPath, baseUrl)`
constructs a payload URL using the configured base path and RSC endpoint.
The formatter is available from both `waku/minimal/server` and
`waku/minimal/client`; it preserves the base URL's query and hash.
These support middleware in Minimal-based frameworks; request inspection
does not validate actions or authorize access.
### Mental Model
At this level, Waku gives you rendering primitives and very little policy.
- `handleRequest` decides how each incoming request is handled.
- `renderRsc` renders a React Server Components payload as a `ReadableStream`.
- `renderHtml` renders the HTML shell that boots the client.
- `Root` fetches and owns the current RSC payload on the client.
- `Slot` renders a named element from the RSC payload.
- `handleBuild` decides which files are emitted during `waku build`.
Two terms are important:
- `RSC ID`: the key of an element returned by `renderRsc`.
- `rscPath`: an application-defined string that identifies which RSC payload to fetch. Waku treats it as opaque.
If the server returns:
```tsx
return renderRsc({
App: ,
Sidebar: ,
});
```
then the client can render those elements with:
```tsx
```
### 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: });
}
if (input.type === 'http' && input.pathname === '/') {
const rscPath = '';
return renderHtml(
await renderRsc({ App: }),
,
{ rscPath },
);
}
return null;
},
handleBuild: async ({
renderRsc,
renderHtml,
rscPath2pathname,
generateFile,
}) => {
const rscPath = '';
const stream = await renderRsc({ App: });
const [rscStream, htmlStream] = stream.tee();
await generateFile(rscPath2pathname(rscPath), rscStream);
const html = await renderHtml(htmlStream, , { 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 = (
);
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 ``.
4. The browser loads `src/waku.client.tsx`.
5. `` fetches the RSC payload for `rscPath === ''`.
6. `` 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: ,
});
}
```
`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: }, { 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: }),
,
{
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: }),
,
{ rscPath: '' },
);
}
if (input.type === 'http' && input.pathname === '/api/hello') {
return new Response('world');
}
```
The `utils` argument provides these helpers:
| Utility | Purpose |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `renderRsc(elements, options?)` | Render an RSC payload as a `ReadableStream`. Object keys become RSC IDs; use `options.value` for a server function result. |
| `renderHtml(elementsStream, html, options)` | Render the full HTML response that boots the client. |
| `renderHtmlFallback(options?)` | Return the client-rendered HTML shell, without rendering an RSC tree. |
| `loadBuildMetadata(key)` | Read metadata saved during `handleBuild`. |
`handleRequest` may return any of the following:
| Return value | Meaning |
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
| `ReadableStream` | Waku wraps it in `new Response(stream)`. |
| `Response` | Returned as-is. |
| `'fallback'` | Ask Waku to serve its fallback HTML response. |
| `null` or `undefined` | No direct response. For `/`, Waku still falls back to HTML. For other paths, the adapter receives no response. |
Important details:
- `renderHtml` expects an options object such as `{ rscPath: '' }`.
- `renderHtmlFallback({ nonce })` sets the CSP nonce on the client bootstrap script without rendering a route. Use the nonce from your response's CSP header for request-time fallback HTML, including dynamic routes with document SSR disabled. Returning `'fallback'` uses the default shell without a nonce. See [Configure CSP](/guides/csp).
- `renderHtml` and `renderHtmlFallback` accept `unstable_extraScriptContent` for additional bootstrap code. `generateDefaultHtml(fileName, options)` accepts the fallback options for build-time bootstrap code; use request-specific nonces only with request-time HTML, not static files.
- RSC IDs starting with `_` are reserved for Waku internals.
- `rscPath` and `rscParams` are opaque application data. Waku does not assign them semantics.
- Each `fetchRsc` call issues a new request. A router-like abstraction can own prefetching and response reuse.
#### Status And Redirect Errors
Use `createCustomError` to attach status or redirect information to an error
that survives Flight serialization:
```ts
import { unstable_createCustomError as createCustomError } from 'waku/minimal/server';
throw createCustomError('Not found', { status: 404 });
```
Throwing a status error directly from `handleRequest` produces an HTTP
response with that status. A redirect uses a `location` and a redirect status
such as `307`. Document requests become HTTP redirects, while RSC and server
function redirects return a readable payload with status `200` and surface
the redirect on the client. Payload consumers can inspect it and choose how
to navigate:
```ts
import { unstable_getErrorInfo as getErrorInfo } from 'waku/minimal/client';
const handleError = (error: unknown) => {
const info = getErrorInfo(error);
if (info?.location) {
window.location.assign(info.location);
}
};
```
`getErrorInfo` returns `null` for errors without valid Waku information. It is
also available from `waku/minimal/server` for server-side error handling.
#### `handleBuild(utils)`
`handleBuild` runs during `waku build`. It does not return a manifest or instruction list. It is responsible for emitting any static files you want in the build output.
The build utilities are:
| Utility | Purpose |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `renderRsc(elements, options?)` | Render an RSC payload stream. Use `options.value` for a server function result. |
| `renderHtml(elementsStream, html, options)` | Render HTML from an RSC stream and client shell. |
| `rscPath2pathname(rscPath)` | Convert an RSC path into the correct output pathname for the RSC payload. |
| `saveBuildMetadata(key, value)` | Persist metadata that will later be available through `loadBuildMetadata(...)` in `handleRequest`. |
| `generateFile(fileName, body)` | Write a file to `dist/public`. Accepts `ReadableStream` or `string`. |
| `generateDefaultHtml(fileName, options?)` | Write the default client-rendered HTML shell to `dist/public`, optionally with extra bootstrap code. |
The most common patterns are:
Dynamic SSR only:
```tsx
handleBuild: async () => {},
```
Prerender one HTML page and one RSC payload:
```tsx
handleBuild: async ({
renderRsc,
renderHtml,
rscPath2pathname,
generateFile,
}) => {
const rscPath = '';
const stream = await renderRsc({ App: });
const [rscStream, htmlStream] = stream.tee();
await generateFile(rscPath2pathname(rscPath), rscStream);
const html = await renderHtml(htmlStream, , { 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: (
),
});
}
return null;
},
```
Render during build:
```tsx
handleBuild: async ({ renderRsc, generateFile, rscPath2pathname }) => {
const body = await renderRsc({ App: });
await generateFile(rscPath2pathname(''), body);
},
```
At build time there is no real request. If your render reads request-scoped data through your own `AsyncLocalStorage`, wrap the render and seed it with a synthetic `Request`.
### Client API
The minimal client API lives in `waku/minimal/client`.
#### `Root`
`Root` is the top-level provider for the minimal client runtime.
```tsx
import { Root_UNSTABLE as Root } from 'waku/minimal/client';
```
Props:
- `initialRscPath?: string`
- `initialRscParams?: unknown`
- `rscEnhancers?: readonly Unstable_RscEnhancer[]`: a stable array defining the mount-time [request pipeline](#request-enhancers).
- `children: ReactNode`
Important behavior:
- `initialRscPath` defaults to `''`.
- `Root` is required for `Slot`, `Children`, `useElementsPromise`, `useMergeElements`,
`useRegisterRscEnhancer`, and `useRegisterRscReloadListener`. Outside a
`Root`, `useFetchRsc` still works but runs no enhancers.
- Each `Root` owns its client store. Bare server action calls use the `Root`
that was most recently mounted when the request starts.
- `Root` injects a `generator` meta tag. Charset and viewport are yours to
declare.
- For SSR or SSG, use `hydrateRoot(document, rootElement)` when `globalThis.__WAKU_HYDRATE__` is set. Hydration requires the streamed RSC payload supplied with Waku's rendered HTML; use `createRoot` for a fallback shell without it.
- For purely client-side rendering, use `createRoot(document).render(rootElement)`.
A standard bootstrap looks like this:
```tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import {
Root_UNSTABLE as Root,
Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';
const rootElement = (
);
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
```
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: (
),
});
```
Client:
```tsx
A client element
```
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
{Object.keys(elements).join(', ')}
;
};
```
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 (
);
};
```
Important behavior:
- `refetch(...)` requests a new RSC payload for the given `rscPath` and optional `rscParams`.
- The returned payload is merged into the current element map by key.
- Each slot's etag stays with the element map Minimal made it in. Passing a map as `unstable_base` to `fetchRsc` sends its etags, so the server can skip the slots it holds. A map made by `combineElements` keeps them; a copy made with a spread or `Object.assign` does not.
- If you return only `InnerApp`, previously rendered elements such as `App` stay mounted.
- If the refetch fails, the existing element map stays in place.
With `unstable_base`, `fetchRsc` sends the base's validators and returns only
the response entries. Omitted slots are not filled from the base. Passing this
patch to `mergeElements` preserves the Root's queued values and removals.
For a standalone snapshot, combine the retained base and response explicitly:
```tsx
import { unstable_combineElements as combineElements } from 'waku/minimal/client';
const patch = await fetchRsc(rscPath, rscParams, {
unstable_base: retainedElements,
});
const snapshot = combineElements(retainedElements, patch);
```
Do not merge this request-time snapshot into a Root that may have queued other
updates. Merge the patch with its latest elements.
#### Custom Merges
The optional second argument to `mergeElements` chooses the Root's next element
promise. It receives the latest queued promise, including updates that have not
rendered yet, and the incoming payload promise:
Use `startTransition` when merging pending work to retain visible content.
An urgent merge can show the nearest Suspense fallback if an earlier queued
elements promise is still pending.
```tsx
mergeElements(response, (prevPromise, patchPromise) =>
Promise.all([prevPromise, patchPromise]).then(([prev, patch]) =>
combineElements(prev, combineElements(patch, clientElements)),
),
);
```
Return `previous` directly to leave the Root unchanged without introducing a
pending promise. The callback must be pure; React can replay it with a different
queued predecessor. Minimal reuses its result when the predecessor is the same.
The incoming promise passed to the callback recovers a failed payload to an
empty record. The promise returned by `mergeElements` is the original payload
promise, so callers can handle its rejection. It does not wait for the custom
merge or for React to render the update. To apply client entries only on success,
transform the response before merging:
```tsx
mergeElements(
response.then((received) => combineElements(received, clientElements)),
);
```
Use `combineElements` when retaining server entries so their etags follow their
values. Minimal does not impose an eager rendering or stale-while-revalidate
policy; Waku Router implements instant navigation with the merge callback.
#### Development Reloads
Development RSC reloads refetch the Root's initial `rscPath` and `rscParams` by
default. Fetching and merging a different input does not change that target.
`useRegisterRscReloadListener_UNSTABLE` returns a registrar for the enclosing
Root. Register a callback that fetches and merges the current input with
`{ replace: true }` to refresh that input after an edit. Without `replace`,
listeners run alongside the Root's refetch. The registrar returns an unregister
function for effect cleanup and is a no-op in production.
#### Request enhancers
Pass a fixed array of `Unstable_RscEnhancer` functions to `Root_UNSTABLE.rscEnhancers`. An enhancer can rewrite inputs, wrap the supplied transport, or transform decoded results. The last array entry is outermost: `[A, B]` sends requests through `B → A → server` and returns results through `server → A → B`.
Configure the array in your [client entry](/create-pages#client-entry-point):
```tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import {
Root_UNSTABLE as Root,
Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';
import type { Unstable_RscEnhancer } from 'waku/minimal/client';
const createLocaleEnhancer =
(getLocale: () => string): Unstable_RscEnhancer =>
(next) =>
(path, params, options) =>
next(path, params, {
...options,
fetch: (input, init) => {
const headers = new Headers(
init?.headers ??
(input instanceof Request ? input.headers : undefined),
);
headers.set('accept-language', getLocale());
return options.fetch(input, { ...init, headers });
},
});
const enhancers = [createLocaleEnhancer(() => navigator.language)];
const app = (
);
if (globalThis.__WAKU_HYDRATE__) {
hydrateRoot(document, app, defaultRootOptions);
} else {
createRoot(document, defaultRootOptions).render(app);
}
```
- Treat the array as immutable and keep its reference stable across renders, including retries before the first commit. Create it before rendering React, as above: `useState` and `useMemo` inside an initially suspending component can be discarded on retry. Root copies the array for its mounted pipeline; remount with a new array to change it. Enhancers can read live state on every request without changing their membership.
- The configured pipeline is available before descendant layout effects. `options.type` is `rsc` for a payload fetch and `call` for a server function call. Forward `options`, including the supplied `fetch` and abort signal.
- Results contain `{ elements, value }`, where `value` is a server function's return value. Elements exclude Minimal's reserved keys. Use `unstable_combineElements` to change an element map while preserving its etags; a spread loses validators and prevents the server from skipping retained slots.
- Client bootstrap fetches run the configured enhancers during render. Descendants can suspend on the result, showing an enclosing Suspense fallback. The bootstrap cache deduplicates Suspense retries by the initial path, params reference, and enhancer-array reference; separate Roots with the same cache identity may share a request. Use distinct stable enhancer arrays when independent bootstrap requests are required. If a bootstrap request fails before Root commits, resetting an enclosing error boundary with the same cache identity may reuse the rejected promise.
- A streamed hydration payload bypasses enhancers: its request began before the client Root existed. Enhancers also run for subsequent Root-bound fetches, server actions received by that Root, and its development reloads. A bare server action goes to the Root mounted last, which need not be the one containing its caller.
For the file-based Router, create the same stable array before rendering React and pass it as `` in `src/waku.client.tsx`. These enhancers form the inner part of Router's pipeline; its private HTTP redirect and action-routing enhancers wrap them. They can delay or transform results before Router handles them. A decoded-result cache in this inner list does not retain metadata reconciled by Router. Minimal's array has no private Router layers, so custom routers own their full composition.
##### Migrating effect-based integrations
In rc.4, `useRegisterRscEnhancer_UNSTABLE` remains available as a deprecated one-argument registrar returning cleanup. Legacy registrations are Root-scoped and always inside configured enhancers; later registrations wrap earlier registrations. Each request snapshots its sequence, so cleanup affects future requests only. Effect registration retains its timing limitations. There is no numeric priority argument.
Move pipeline installation to the client entry and keep subscriptions, atom discovery, and HMR listeners in companion components. For example, a future `waku-jotai` integration factory could expose a per-app store, a stable enhancer, and a Provider sharing that store:
```tsx
// src/waku.client.tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import { Router } from 'waku/router/client';
import { createJotaiIntegration } from 'waku-jotai/router/client';
const jotai = createJotaiIntegration();
const enhancers = [jotai.rscEnhancer];
const app = (
);
if (globalThis.__WAKU_HYDRATE__) {
hydrateRoot(document, app, defaultRootOptions);
} else {
createRoot(document, defaultRootOptions).render(app);
}
```
This is a proposed ecosystem API: current `waku-jotai` does not export `createJotaiIntegration`. Its existing hook-based integration remains supported in rc.4. Minimal applications would pass the same integration's enhancer to Root's `rscEnhancers` prop.
The companion must publish discovered atom IDs to the runtime captured by the enhancer, using the same Jotai store as application components. Each request reads current values. Installation does not guarantee that asynchronous atom discovery has finished: unknown paths must pass through unchanged. Router prefetch keys also do not include atom values, so prefetched results can become stale when values change; declarative installation does not solve cache invalidation.
The registration hook is scheduled for removal in rc.5, after a migrated `waku-jotai` release and migration guidance are available. Other hook consumers must migrate before upgrading to rc.5.
### Common Patterns
#### Dynamic SSR without prerendering
Use `renderHtml(...)` for document requests and leave `handleBuild` empty:
```tsx
export default adapter({
handleRequest: async (input, { renderRsc, renderHtml }) => {
if (input.type === 'rsc') {
return renderRsc({ App: });
}
if (input.type === 'http' && input.pathname === '/') {
return renderHtml(
await renderRsc({ 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: }, { 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: }),
,
{
rscPath: '',
formState,
},
);
}
```
### Pitfalls
- `renderHtml(...)` consumes its stream. Call `tee()` if you also need to emit that same payload as a file.
- `Slot` IDs must exactly match the keys returned by `renderRsc(...)`.
- `undefined` is invalid for slot values. Use `null` for an intentionally empty element.
- `rscPath2pathname(...)` should be used instead of hardcoding RSC payload file names.
- `handleBuild` emits nothing unless you explicitly call `generateFile(...)` or `generateDefaultHtml(...)`.
- Returning `'fallback'` is not the same as returning `null`. `'fallback'` explicitly asks Waku to generate the fallback HTML response.
- The client bootstrap for SSR uses `hydrateRoot(document, ...)`, not `createRoot(document.getElementById('root')!)`.
- This API is intentionally low-level. If you find yourself rebuilding routing conventions, `waku/router` is probably the better fit.
### Adapter Authors
Adapter authors own server entries, request processing, and build processing.
`waku/adapter-builders` exports `Unstable_ServerEntry` for entries authored
directly. Minimal applications normally pass their handlers to an adapter.
For adapter implementation details, see [Adapter Authoring](/guides/adapter-authoring).
### Examples
- [Minimal SSR and prerendered root route](https://github.com/wakujs/waku-examples/tree/main/minimal-api/basic)
- [Using `Children` to compose client content into a server tree](https://github.com/wakujs/waku-examples/tree/main/minimal-api/promise)
- [Server functions returning a value and updated elements](https://github.com/wakujs/waku-examples/tree/main/minimal-api/functions)
- [Nested slots, `useRefetch`, `runWithRequest`, and fallback HTML generation](https://github.com/wakujs/waku-examples/tree/main/minimal-api/nesting)
- [Server actions and `formState`](https://github.com/wakujs/waku-examples/tree/main/minimal-api/form)
- [Custom API endpoints alongside minimal SSR](https://github.com/wakujs/waku-examples/tree/main/minimal-api/api)
- [SPA-style build using `generateDefaultHtml`](https://github.com/wakujs/waku-examples/tree/main/minimal-api/spa)