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.
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 | 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:
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 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:
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:
- 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
Handler Objects
Define handleRequest and handleBuild in the object passed to an adapter. Use Handlers when defining the object separately:
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:
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:
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. |
| 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:
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:
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:
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
- rscEnhancers?: readonly Unstable_RscEnhancer[]: a stable array defining the mount-time request pipeline.
- 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:
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.
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:
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:
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:
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.
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:
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:
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import {
Root_UNSTABLE as Root,
Slot_UNSTABLE as Slot,
} from 'waku/minimal/client';
import type { Unstable_RscEnhancer } from 'waku/minimal/client';
const createLocaleEnhancer =
(getLocale: () => string): Unstable_RscEnhancer =>
(next) =>
(path, params, options) =>
next(path, params, {
...options,
fetch: (input, init) => {
const headers = new Headers(
init?.headers ??
(input instanceof Request ? input.headers : undefined),
);
headers.set('accept-language', getLocale());
return options.fetch(input, { ...init, headers });
},
});
const enhancers = [createLocaleEnhancer(() => navigator.language)];
const app = (
<StrictMode>
<Root rscEnhancers={enhancers}>
<Slot id="App" />
</Root>
</StrictMode>
);
if (globalThis.__WAKU_HYDRATE__) {
hydrateRoot(document, app, defaultRootOptions);
} else {
createRoot(document, defaultRootOptions).render(app);
}- Treat the array as immutable and keep its reference stable across renders, including retries before the first commit. Create it before rendering React, as above: useState and useMemo inside an initially suspending component can be discarded on retry. Root copies the array for its mounted pipeline; remount with a new array to change it. Enhancers can read live state on every request without changing their membership.
- The configured pipeline is available before descendant layout effects. options.type is rsc for a payload fetch and call for a server function call. Forward options, including the supplied fetch and abort signal.
- Results contain { elements, value }, where value is a server function's return value. Elements exclude Minimal's reserved keys. Use unstable_combineElements to change an element map while preserving its etags; a spread loses validators and prevents the server from skipping retained slots.
- Client bootstrap fetches run the configured enhancers during render. Descendants can suspend on the result, showing an enclosing Suspense fallback. The bootstrap cache deduplicates Suspense retries by the initial path, params reference, and enhancer-array reference; separate Roots with the same cache identity may share a request. Use distinct stable enhancer arrays when independent bootstrap requests are required. If a bootstrap request fails before Root commits, resetting an enclosing error boundary with the same cache identity may reuse the rejected promise.
- A streamed hydration payload bypasses enhancers: its request began before the client Root existed. Enhancers also run for subsequent Root-bound fetches, server actions received by that Root, and its development reloads. A bare server action goes to the Root mounted last, which need not be the one containing its caller.
For the file-based Router, create the same stable array before rendering React and pass it as <Router unstable_rscEnhancers={enhancers} /> in src/waku.client.tsx. These enhancers form the inner part of Router's pipeline; its private HTTP redirect and action-routing enhancers wrap them. They can delay or transform results before Router handles them. A decoded-result cache in this inner list does not retain metadata reconciled by Router. Minimal's array has no private Router layers, so custom routers own their full composition.
Migrating effect-based integrations
In rc.4, useRegisterRscEnhancer_UNSTABLE remains available as a deprecated one-argument registrar returning cleanup. Legacy registrations are Root-scoped and always inside configured enhancers; later registrations wrap earlier registrations. Each request snapshots its sequence, so cleanup affects future requests only. Effect registration retains its timing limitations. There is no numeric priority argument.
Move pipeline installation to the client entry and keep subscriptions, atom discovery, and HMR listeners in companion components. For example, a future waku-jotai integration factory could expose a per-app store, a stable enhancer, and a Provider sharing that store:
// src/waku.client.tsx
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import { Router } from 'waku/router/client';
import { createJotaiIntegration } from 'waku-jotai/router/client';
const jotai = createJotaiIntegration();
const enhancers = [jotai.rscEnhancer];
const app = (
<StrictMode>
<jotai.Provider>
<Router unstable_rscEnhancers={enhancers} />
</jotai.Provider>
</StrictMode>
);
if (globalThis.__WAKU_HYDRATE__) {
hydrateRoot(document, app, defaultRootOptions);
} else {
createRoot(document, defaultRootOptions).render(app);
}This is a proposed ecosystem API: current waku-jotai does not export createJotaiIntegration. Its existing hook-based integration remains supported in rc.4. Minimal applications would pass the same integration's enhancer to Root's rscEnhancers prop.
The companion must publish discovered atom IDs to the runtime captured by the enhancer, using the same Jotai store as application components. Each request reads current values. Installation does not guarantee that asynchronous atom discovery has finished: unknown paths must pass through unchanged. Router prefetch keys also do not include atom values, so prefetched results can become stale when values change; declarative installation does not solve cache invalidation.
The registration hook is scheduled for removal in rc.5, after a migrated waku-jotai release and migration guidance are available. Other hook consumers must migrate before upgrading to rc.5.
Common Patterns
Dynamic SSR without prerendering
Use renderHtml(...) for document requests and leave handleBuild empty:
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
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
- 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

