@real-router/ssr-utils
v0.3.0
Published
Router-level SSR/SSG/hydration helpers for Real-Router
Readme
@real-router/ssr-utils
Router-level SSR/SSG/hydration helpers for Real-Router. Serialize state for transport, hydrate on the client, enumerate static paths, isolate per-request router clones.
Installation
npm install @real-router/ssr-utilsRequires @real-router/core as a peer dependency.
Quick Start
// Server
import { serializeRouterState } from "@real-router/ssr-utils";
const state = await router.start(req.url);
const html = `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`;
// Client
import { hydrateRouter } from "@real-router/ssr-utils";
await hydrateRouter(router, window.__SSR_STATE__);API
| Function | Description |
| ------------------------------------------ | ------------------------------------------------------------------ |
| serializeState(data, opts?) | XSS-safe JSON serialization for embedding in HTML <script> tags |
| serializeRouterState(state, opts?) | XSS-safe State serializer — strips transition, keeps context |
| hydrateRouter(router, source, opts?) | Hydrate a fresh router from server-serialized state |
| getHydrationState(router) | The state an in-flight hydrateRouter call deposited, or null |
| getStaticPaths(router, entries?) | Enumerate leaf routes and build URLs for SSG pre-rendering |
| createRequestScope(request, base, deps?) | Per-request SSR isolation via a cloned router |
serializeRouterState(state, options?)
const json = serializeRouterState(state);
// Strip a non-JSON-serializable plugin namespace (e.g. an RSC ReactNode tree)
const json = serializeRouterState(state, { excludeContext: ["rsc"] });
// Non-JSON types (Date / Map / Set / RegExp / BigInt) via devalue
import * as devalue from "devalue";
const json = serializeRouterState(state, { serialize: devalue.stringify });hydrateRouter(router, source, options?)
const router = createAppRouter();
router.usePlugin(browserPluginFactory());
await hydrateRouter(router, window.__SSR_STATE__);
// Pair with a custom serializer
await hydrateRouter(router, window.__SSR_STATE__, {
deserialize: devalue.parse,
});SSR loader plugins (@real-router/ssr-data-plugin, @real-router/rsc-server-plugin)
automatically skip their post-hydration re-fetch when the server-resolved
value is already present in the hydrated state — no extra wiring needed.
getHydrationState(router)
For plugin authors: returns what the in-flight hydrateRouter call deposited
for router, or null outside one. Read it from a start interceptor — the
value is restored when hydrateRouter's start() settles, so later starts
read null. There is no way to write it: only hydrateRouter does.
getPluginApi(router).addInterceptor("start", async (next, path) => {
const state = await next(path);
const hydrated = getHydrationState(router); // SerializedRouterState | null
if (hydrated?.name === state.name) {
// reuse hydrated.context instead of loading again
}
return state;
});⚠ The plugin and hydrateRouter must resolve the same copy of
@real-router/ssr-utils — two copies hold two scratchpads, and the read
returns null without an error.
getStaticPaths(router, entries?)
const paths = await getStaticPaths(router);
// ["/", "/about", "/users/1", "/users/2", ...]
// Per-route entry sets for dynamic segments. An entry names its CHANNELS —
// `params` for path slots, `search` for `?`-declared query names, both optional.
const paths = await getStaticPaths(router, {
"users.profile": async () => [
{ params: { id: "1" } },
{ params: { id: "2" } },
],
// `/list?sort&page` — the query channel varies the page
list: async () => [
{ search: { sort: "asc", page: "1" } },
{ search: { sort: "desc", page: "1" } },
],
// `/doc/:id?rev` — both channels at once
doc: async () => [{ params: { id: "a" }, search: { rev: "1" } }],
});A key that cannot reach the URL throws rather than silently collapsing pages: a
name the route declares with ? handed in params, or one it declares nowhere
that the active queryParamsMode will not print, would make every entry
differing only in it generate the same file (#1580).
A leaf that declares forwardTo throws too, unless the manifest also carries its
target. A <Link> renders where the click lands, so the href names the target's
URL — an entry supplied for the source alone writes a file nobody visits and
leaves the one they do visit missing (#2256). Enumerate the target, or drop the
source from entries and let the link resolve at runtime.
createRequestScope(request, base, deps?)
export async function render(url: string, req: IncomingMessage) {
const scope = createRequestScope(req, baseRouter, { currentUser });
try {
scope.router.usePlugin(ssrDataPluginFactory(loaders));
return await renderShell(scope.router, url);
} finally {
await scope.dispose();
}
}
// `await using` (Node 24+, Bun, Deno, modern browsers)
export async function render(url: string, req: IncomingMessage) {
await using scope = createRequestScope(req, baseRouter, { currentUser });
return await renderShell(scope.router, url);
}Binds an AbortSignal to the request lifetime (Node "close" event / Web
request.signal), injected into the clone's dependencies under
abortSignal — loaders read getDep("abortSignal") for cooperative
cancellation.
Documentation
Full documentation: Wiki — ssr-utils
Related Packages
| Package | Description |
| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| @real-router/core | Core router |
| @real-router/ssr-data-plugin | Per-route data loading — composes with the hydration scratchpad |
| @real-router/rsc-server-plugin | Per-route ReactNode (RSC) loading — same composition |
Contributing
See contributing guidelines for development setup and PR process.
