Minimal API
Low-level Waku primitives for library authors and custom integrations.
When to Use the Minimal API
The minimal API is the lowest-level public surface for building on top of Waku. It is intended for:
- library authors
- custom runtimes and integrations
- advanced users who need direct control over routing, request dispatch, and build output
If you are building an application, use waku/router instead. The minimal API deliberately does not provide:
- config-based routing / filesystem routing
- automatic route-to-component mapping
- automatic prerender planning
- page conventions such as layout.tsx, page.tsx, or 404.tsx
If you need programmatic routing while keeping Waku's router behavior, see the createPages reference or Custom Router. If you are implementing a deployment/runtime adapter, start with Adapter Authoring.
Exports marked unstable_* or *_UNSTABLE in this document may still change.
API Surface
The minimal API is small, but it exposes both server-side handlers and client-side rendering primitives:
| Entry point | Surface | Use when |
|---|---|---|
| waku/minimal/server | unstable_defineHandlers | Defining handleRequest and handleBuild. |
| waku/minimal/server | unstable_defineServerEntry | Writing server entries directly, usually for adapters. |
| waku/minimal/client | Root_UNSTABLE | Installing the minimal client runtime. |
| waku/minimal/client | Slot_UNSTABLE | Rendering a server element by RSC ID. |
| waku/minimal/client | Children_UNSTABLE | Placing client-side Slot children inside a server-rendered element. |
| waku/minimal/client | 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 | useRegisterRscEnhancer_UNSTABLE | Extending the RSC requests a Root makes. |
| waku/minimal/client | unstable_combineElements | Combining element maps without losing their etags. |
| waku/minimal/client | unstable_* helpers | Building router-like abstractions. |
Examples below often import these as shorter names. This guide does not document every exported helper from waku/minimal/client; undocumented exports are for Waku internals or custom integrations.
Mental Model
At this level, Waku gives you rendering primitives and very little policy.
- handleRequest decides how each incoming request is handled.
- renderRsc renders a React Server Components payload as a ReadableStream.
- renderHtml renders the HTML shell that boots the client.
- Root fetches and owns the current RSC payload on the client.
- Slot renders a named element from the RSC payload.
- handleBuild decides which files are emitted during waku build.
Two terms are important:
- RSC ID: the key of an element returned by renderRsc.
- rscPath: an application-defined string that identifies which RSC payload to fetch. Waku treats it as opaque.
If the server returns:
return renderRsc({
App: <App />,
Sidebar: <Sidebar />,
});then the client can render those elements with:
<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:
import { unstable_defineHandlers as defineHandlers } from 'waku/minimal/server';
import adapter from 'waku/adapters/default';
import { Slot_UNSTABLE as Slot } from 'waku/minimal/client';
import App from './components/App.js';
const handlers = defineHandlers({
handleRequest: async (input, { renderRsc, renderHtml }) => {
if (input.type === 'rsc') {
return renderRsc({ App: <App name={input.rscPath || 'Waku'} /> });
}
if (input.type === 'http' && input.pathname === '/') {
const rscPath = '';
return renderHtml(
await renderRsc({ App: <App name="Waku" /> }),
<Slot id="App" />,
{ rscPath },
);
}
return null;
},
handleBuild: async ({
renderRsc,
renderHtml,
rscPath2pathname,
generateFile,
}) => {
const rscPath = '';
const stream = await renderRsc({ App: <App name="Waku" /> });
const [rscStream, htmlStream] = stream.tee();
await generateFile(rscPath2pathname(rscPath), rscStream);
const html = await renderHtml(htmlStream, <Slot id="App" />, { rscPath });
await generateFile('index.html', html.body!);
},
});
export default adapter(handlers);src/waku.client.tsx:
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import {
Root_UNSTABLE as Root,
Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';
const rootElement = (
<StrictMode>
<Root>
<Slot id="App" />
</Root>
</StrictMode>
);
if ((globalThis as any).__WAKU_HYDRATE__) {
hydrateRoot(document, rootElement);
} else {
createRoot(document).render(rootElement);
}unstable_defineHandlers is optional. It is currently an identity helper that gives you a typed place to define handlers before passing them to an adapter. Most examples in this repository pass the handler object directly to adapter(...).
Request Flow
In the example above, a request to / looks like this:
- The adapter receives GET / and calls handleRequest with input.type === 'http'.
- handleRequest renders an RSC payload with renderRsc(...).
- handleRequest passes that payload to renderHtml(...) together with an HTML tree containing <Slot id="App" />.
- The browser loads src/waku.client.tsx.
- <Root> fetches the RSC payload for rscPath === ''.
- <Slot id="App" /> renders the server element stored under the App key.
Server API
unstable_defineHandlers
unstable_defineHandlers is the main server-side entry point from waku/minimal/server.
import { unstable_defineHandlers as defineHandlers } from 'waku/minimal/server';Use it when you want to:
- define handlers separately from the adapter call
- compose handlers before exporting them
- keep type checking close to the handler object
If you do not need that, this is equivalent:
import adapter from 'waku/adapters/default';
export default adapter({
handleRequest: async () => null,
handleBuild: async () => {},
});handleRequest(input, utils)
handleRequest is the runtime dispatcher. It receives every request that reaches the minimal server.
The input argument is one of the following shapes:
| input.type | When it is used | Extra fields |
|---|---|---|
| rsc | RSC payload fetches initiated by Root or fetchRsc | rscPath, rscParams |
| call | Server function calls | fn, args |
| http | Ordinary HTTP requests such as document requests, custom endpoints, and form submissions | tryAction? |
Every input also includes:
- pathname: pathname from the request URL
- req: the original Request object
input.req is the request for this handler. The Minimal API does not provide an ambient unstable_getRequest() (that is a router feature); pass input.req where you need it, or wrap the body in your own AsyncLocalStorage to reach it from deeper components and server functions.
Typical handling patterns:
rsc requests usually return an RSC payload:
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:
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:
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:
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:
if (input.type === 'http' && input.pathname === '/') {
return renderHtml(
await renderRsc({ App: <App name="Waku" /> }),
<Slot id="App" />,
{ rscPath: '' },
);
}
if (input.type === 'http' && input.pathname === '/api/hello') {
return new Response('world');
}The utils argument provides these helpers:
| Utility | Purpose |
|---|---|
| renderRsc(elements, options?) | Render an RSC payload as a ReadableStream. Object keys become RSC IDs; use options.value for a server function result. |
| renderHtml(elementsStream, html, options) | Render the full HTML response that boots the client. |
| loadBuildMetadata(key) | Read metadata saved during handleBuild. |
handleRequest may return any of the following:
| Return value | Meaning |
|---|---|
| ReadableStream | Waku wraps it in new Response(stream). |
| Response | Returned as-is. |
| 'fallback' | Ask Waku to serve its fallback HTML response. |
| null or undefined | No direct response. For /, Waku still falls back to HTML. For other paths, the adapter receives no response. |
Important details:
- renderHtml expects an options object such as { rscPath: '' }.
- RSC IDs starting with _ are reserved for Waku internals.
- rscPath and rscParams are opaque application data. Waku does not assign them semantics.
- Each fetchRsc call issues a new request. A router-like abstraction can own prefetching and response reuse.
- Throwing from handleRequest turns into an HTTP error response, and a custom error keeps its status. A location is a redirect a document request answers with a 3xx, but a fetch cannot follow one it is unable to read, so an RSC or server function request answers 200 and the client throws an error whose info carries the location and unstable_leave, for you to navigate to.
handleBuild(utils)
handleBuild runs during waku build. It does not return a manifest or instruction list. It is responsible for emitting any static files you want in the build output.
The build utilities are:
| Utility | Purpose |
|---|---|
| renderRsc(elements, options?) | Render an RSC payload stream. Use options.value for a server function result. |
| renderHtml(elementsStream, html, options) | Render HTML from an RSC stream and client shell. |
| rscPath2pathname(rscPath) | Convert an RSC path into the correct output pathname for the RSC payload. |
| saveBuildMetadata(key, value) | Persist metadata that will later be available through loadBuildMetadata(...) in handleRequest. |
| generateFile(fileName, body) | Write a file to dist/public. Accepts ReadableStream or string. |
| generateDefaultHtml(fileName) | Write Waku's default fallback HTML to dist/public. |
The most common patterns are:
Dynamic SSR only:
handleBuild: async () => {},Prerender one HTML page and one RSC payload:
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:
handleBuild: async ({ generateDefaultHtml }) => {
await generateDefaultHtml('index.html');
},Persist build metadata and read it at request time:
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:
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.
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, 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:
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.
import { Slot_UNSTABLE as Slot } from 'waku/minimal/client';<Root>
<Slot id="App" />
</Root>Rules:
- id must match a key returned from renderRsc(...).
- Missing keys throw Invalid element: <id>.
- undefined is not allowed. If an element is intentionally empty, return null.
- Slot must be rendered under Root.
Children
Children lets a server element render the client children passed to Slot.
import { Children_UNSTABLE as Children } from 'waku/minimal/client';Server:
return renderRsc({
App: (
<App>
<Children />
</App>
),
});Client:
<Slot id="App">
<h3>A client element</h3>
</Slot>This is useful for nested layouts and composition patterns where the server tree decides where client-provided children should appear.
Refetching
The Minimal API exposes fetching and merging separately. Define a hook for the behavior your application needs:
import { useCallback, useTransition } from 'react';
import {
useFetchRsc_UNSTABLE as useFetchRsc,
useMergeElements_UNSTABLE as useMergeElements,
useRegisterRscReloadListener_UNSTABLE as useRegisterRscReloadListener,
} from 'waku/minimal/client';
const useRefetch = () => {
const fetchRsc = useFetchRsc();
const mergeElements = useMergeElements();
const registerRscReloadListener = useRegisterRscReloadListener();
return useCallback(
(rscPath: string, rscParams?: unknown) => {
const refetch = () => mergeElements(fetchRsc(rscPath, rscParams));
registerRscReloadListener(
() => {
void refetch();
},
{ replace: true },
);
return refetch();
},
[fetchRsc, mergeElements, registerRscReloadListener],
);
};
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.
- The reload registration keeps development HMR on the refetched RSC path and has no effect in production.
- If you return only InnerApp, previously rendered elements such as App stay mounted.
- If the refetch fails, the existing element map stays in place.
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:
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:
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:
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:
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:
if (input.type === 'http' && input.pathname === '/') {
const result = input.tryAction ? await input.tryAction() : undefined;
const formState = result?.action ? result.formState : undefined;
return renderHtml(
await renderRsc({ App: <App name="Waku" /> }),
<Slot id="App" />,
{
rscPath: '',
formState,
},
);
}Pitfalls
- renderHtml(...) consumes its stream. Call tee() if you also need to emit that same payload as a file.
- Slot IDs must exactly match the keys returned by renderRsc(...).
- undefined is invalid for slot values. Use null for an intentionally empty element.
- rscPath2pathname(...) should be used instead of hardcoding RSC payload file names.
- handleBuild emits nothing unless you explicitly call generateFile(...) or generateDefaultHtml(...).
- Returning 'fallback' is not the same as returning null. 'fallback' explicitly asks Waku to generate the fallback HTML response.
- The client bootstrap for SSR uses hydrateRoot(document, ...), not createRoot(document.getElementById('root')!).
- This API is intentionally low-level. If you find yourself rebuilding routing conventions, waku/router is probably the better fit.
Adapter Authors
waku/minimal/server also exports unstable_defineServerEntry. That is a lower-level hook than unstable_defineHandlers and is meant for adapter authors who need to work directly with server entries, request processing, and build processing. Most advanced users of the minimal API do not need it.
For adapter implementation details, see Adapter Authoring.
Examples
- Minimal SSR and prerendered root route
- Using Children to compose client content into a server tree
- Server functions returning a value and updated elements
- Nested slots, useRefetch, runWithRequest, and fallback HTML generation
- Server actions and formState
- Custom API endpoints alongside minimal SSR
- SPA-style build using generateDefaultHtml

