Repository navigation
ssr utils
Router-level SSR/SSG/hydration helpers. Serialize state for transport across the server/client boundary, hydrate a fresh router from that transport, enumerate static paths for SSG pre-rendering, and isolate per-request router clones.
@real-router/ssr-utils holds the router-level SSR, SSG and hydration helpers. Core is a pure router with no SSR-specific code, so the SSR surface lives outside it in two places: this package, published standalone, and shared/ssr — plugin-level scaffolding inlined into @real-router/ssr-data-plugin / @real-router/rsc-server-plugin.
Five functions, all isomorphic (no platform-specific globals):
| Function | Side | Purpose |
|---|---|---|
serializeState(data, opts?) |
server | XSS-safe JSON serialization for embedding in HTML <script> tags |
serializeRouterState(state, opts?) |
server | XSS-safe State serializer — strips transition, keeps context
|
hydrateRouter(router, source, opts?) |
client | Hydrate a fresh router from server-serialized state |
getStaticPaths(router, entries?) |
build | Enumerate leaf routes and build URLs for SSG pre-rendering |
createRequestScope(request, base, deps?) |
server | Per-request SSR isolation via a cloned router |
npm install @real-router/ssr-utilsRequires @real-router/core as a peer dependency.
import {
serializeState,
serializeRouterState,
hydrateRouter,
getHydrationState,
getStaticPaths,
createRequestScope,
} from "@real-router/ssr-utils";
import type {
SerializedRouterState,
SerializeStateOptions,
SerializeRouterStateOptions,
HydrateRouterOptions,
Deserialize,
Serialize,
StaticPathEntries,
RequestScope,
RequestScopeSource,
IncomingMessageLike,
RequestLike,
} from "@real-router/ssr-utils";XSS-safe JSON serialization for embedding arbitrary data in HTML <script> tags. Escapes <, >, and & to their Unicode equivalents (<, >, &) to prevent injection via </script> or HTML entities inside inline scripts.
Signature:
function serializeState(data: unknown, options?: SerializeStateOptions): string;Parameters:
| Parameter | Type | Description |
|---|---|---|
data |
unknown |
Any JSON-serializable value (or a value handled by a custom serialize) |
options.serialize |
(data: unknown) => string |
Custom serializer (e.g. devalue.stringify). Defaults to JSON.stringify. Output is still XSS-escaped regardless of which serializer produced it |
Returns: string — XSS-escaped JSON string.
Example:
import { serializeState } from "@real-router/ssr-utils";
const json = serializeState({ name: "home", path: "/" });
const html = `<script>window.__STATE__=${json}</script>`;Example — non-JSON types via devalue:
import * as devalue from "devalue";
import { serializeState } from "@real-router/ssr-utils";
const json = serializeState(
{ date: new Date(), tags: new Set(["a", "b"]) },
{ serialize: devalue.stringify },
);XSS-safe State serializer for SSR → client transport. Strips state.transition (per-navigation TransitionMeta is meaningless after hydration — the client's hydration commit produces its own). Keeps name, params, search, path, and context (plugin-written namespaces travel to the client untouched, unless excluded).
Signature:
function serializeRouterState(
state: State,
options?: SerializeRouterStateOptions,
): string;Parameters:
| Parameter | Type | Description |
|---|---|---|
state |
State |
Router state to serialize |
options.excludeContext |
readonly string[] |
Plugin context namespaces to strip from the output. Use when a plugin populates state.context.<ns> with non-JSON-serializable values (e.g. an RSC ReactNode tree) |
options.serialize |
Serialize |
Custom serializer for non-JSON types in state.params / state.context.<ns> (Date/Map/Set/RegExp/BigInt). Pair with the matching deserialize on hydrateRouter
|
Returns: string — XSS-escaped JSON string. Parsing it (JSON.parse) yields a SerializedRouterState.
Example:
// Server
import { serializeRouterState } from "@real-router/ssr-utils";
const state = await router.start(req.url);
const html = `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`;Example — stripping a non-JSON plugin namespace (RSC):
const state = await router.start(url);
const json = serializeRouterState(state, { excludeContext: ["rsc"] });Example — custom serializer for non-JSON types:
import * as devalue from "devalue";
const json = serializeRouterState(state, { serialize: devalue.stringify });
// On the client:
await hydrateRouter(router, json, { deserialize: devalue.parse });Hydrates a fresh router from server-serialized state. Accepts either a JSON string (parsed via JSON.parse by default, or options.deserialize) or an already-parsed { path: string }-shaped object. Extracts state.path and delegates to router.start(state.path) — the canonical URL is the source of truth for the router on hydration. The parsed state is handed to the SSR loader plugins for that first start, so @real-router/ssr-data-plugin reuses the server's state.context.data instead of running the loader again.
Signature:
function hydrateRouter(
router: Router,
source: string | { path: string },
options?: HydrateRouterOptions,
): Promise<State>;Parameters:
| Parameter | Type | Description |
|---|---|---|
router |
Router |
A fresh, unstarted router instance |
source |
string | { path: string } |
Serialized state (JSON string) or an already-parsed state-shaped object |
options.deserialize |
(json: string) => unknown |
Custom deserializer matching the serialize passed to serializeRouterState. Defaults to JSON.parse. Ignored when source is already an object |
Returns: Promise<State> — resolves to the committed state (same as router.start()).
Example:
// Client
import { hydrateRouter } from "@real-router/ssr-utils";
const router = createAppRouter();
router.usePlugin(browserPluginFactory());
await hydrateRouter(router, window.__SSR_STATE__);Example — with a custom deserializer:
import * as devalue from "devalue";
await hydrateRouter(router, window.__SSR_STATE__, {
deserialize: devalue.parse,
});Hydration scratchpad. The full parsed state (including state.context.<namespace> payloads) is deposited into a one-shot scratchpad this package owns before start() runs, and the previous value is restored afterward. SSR loader plugins (@real-router/ssr-data-plugin, @real-router/rsc-server-plugin) read it through getHydrationState to skip their loader call when the server-resolved value for a namespace is already present — avoiding a redundant re-fetch on first paint. Single-shot: only the start() triggered by hydrateRouter reads a payload; subsequent start() calls run loaders normally.
hydrateRouter throws for a value that is not a router, for a Proxy over one (Vue reactive() — store the router with markRaw) and for a router built by another copy of @real-router/core: the scratchpad is keyed by router identity, and a plugin reads it with the router it was installed on.
For plugin authors. Returns what the in-flight hydrateRouter call deposited for router, or null outside one.
getHydrationState(router: Router): SerializedRouterState | nullRead it from a start interceptor: the value is restored when hydrateRouter's start() settles, so a later start() reads null. There is no way to write it — only hydrateRouter does, so an application cannot pre-populate it to skip a loader outside hydration.
import { getPluginApi } from "@real-router/core/api";
import { getHydrationState } from "@real-router/ssr-utils";
getPluginApi(router).addInterceptor("start", async (next, path) => {
const state = await next(path);
const hydrated = getHydrationState(router);
if (hydrated?.name === state.name) {
// reuse hydrated.context instead of loading again
}
return state;
});⚠ The plugin and
hydrateRoutermust resolve the same copy of@real-router/ssr-utils. Two copies hold two scratchpads, and the read returnsnullwithout an error — the loader simply runs again on first paint.@real-router/ssr-data-pluginand@real-router/rsc-server-plugindepend on this package directly so that one release line resolves to one copy.
Enumerates the router's leaf routes (routes with no children) and builds a URL for each — the input list for a static-site-generation build step.
Signature:
function getStaticPaths(
router: Router,
entries?: StaticPathEntries,
): Promise<string[]>;Parameters:
| Parameter | Type | Description |
|---|---|---|
router |
Router |
Router instance (need not be started) |
entries |
Record<string, () => Promise<StaticPathEntry[]>> |
Optional per-route-name functions returning the entry sets to build paths for. An entry names its CHANNELS — { params?, search? }, both optional — and there is deliberately no single-bag form, because which channel a key belongs to is the caller's contract everywhere else in the router. A leaf route without a matching entry is built once with empty params |
Returns: Promise<string[]> — one URL per leaf route (or per param set, for routes with an entries callback).
Example:
import { getStaticPaths } from "@real-router/ssr-utils";
const paths = await getStaticPaths(router);
// ["/", "/about", "/contact"]Example — dynamic segments via entries:
const paths = await getStaticPaths(router, {
// `params` for path slots, `search` for `?`-declared query names.
"users.profile": async () => [
{ params: { id: "1" } },
{ params: { id: "2" } },
{ params: { id: "3" } },
],
// `/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" } }],
});
// [..., "/users/1", "/users/2", "/users/3", "/list?sort=asc&page=1", ...]It throws rather than writing a short manifest. Two conditions, both about a page that would silently go missing:
-
A supplied key that cannot reach the URL — a name the route declares with
?handed inparams, or one it declares nowhere that the activequeryParamsModewill not print. Every entry differing only in that key would generate the same file. -
An enumerated leaf that declares
forwardTowhose target the manifest lacks — 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. Enumerate the target, or drop the source fromentriesand let the link resolve at runtime.
Builds a per-request router scope: clones base (via cloneRouter), attaches an AbortSignal tied to the request's lifetime, and exposes dispose() plus an async-disposal member for await using. Replaces the four-step boilerplate every server entry otherwise repeats (new AbortController, listen for close, cloneRouter with the signal injected, try/finally disposal).
Signature:
function createRequestScope<D extends DefaultDependencies = DefaultDependencies>(
request: RequestScopeSource,
base: Router<D>,
deps?: Partial<D>,
): RequestScope<D>;Parameters:
| Parameter | Type | Description |
|---|---|---|
request |
IncomingMessageLike | RequestLike |
Node's http.IncomingMessage (or anything with .on("close", …)), or a Web Request (or anything carrying an AbortSignal) |
base |
Router<D> |
The base/template router to clone per request |
deps |
Partial<D> |
Per-request dependency overrides, merged over base's dependencies. abortSignal is injected automatically and wins over any caller-supplied value |
Returns: RequestScope<D> — { router, signal, dispose }, plus an
async-disposal member holding the same function as dispose. Its key is a
symbol on every runtime, never a stray string key; how it is chosen is in the
createRequestScope JSDoc.
Example — explicit dispose (works on every runtime):
import { createRequestScope } from "@real-router/ssr-utils";
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();
}
}Example — await using (where the build makes the syntax available; the
JSDoc names the one toolchain it does not):
export async function render(url: string, req: IncomingMessage) {
await using scope = createRequestScope(req, baseRouter, { currentUser });
scope.router.usePlugin(ssrDataPluginFactory(loaders));
return await renderShell(scope.router, url);
}Example — Web runtime (signal already on the request):
async function handler(request: Request) {
const scope = createRequestScope(request, baseRouter, { db });
try {
scope.router.usePlugin(rscServerPluginFactory(loaders));
return await render(scope.router, request.url);
} finally {
await scope.dispose();
}
}The signal is injected into the router clone under abortSignal, so existing loaders that read getDep("abortSignal") and pass it to fetch/withTimeout keep working without changes.
The parsed shape produced by serializeRouterState() after JSON.parse. Identical to State<P> minus transition. Defined in @real-router/core/types (re-exported here for convenience) — core owns the shape of its own hydration scratchpad.
type SerializedRouterState<P extends Params = Params> = Omit<State<P>, "transition">;interface RequestScope<D extends DefaultDependencies = DefaultDependencies>
extends AsyncDisposable {
readonly router: Router<D>;
readonly signal: AbortSignal;
dispose: () => Promise<void>;
}type RequestScopeSource = IncomingMessageLike | RequestLike;
interface IncomingMessageLike {
on: (event: "close", listener: () => void) => unknown;
removeListener?: (event: "close", listener: () => void) => unknown;
}
interface RequestLike {
signal: AbortSignal;
}// Server (entry-server.ts)
import { serializeRouterState, createRequestScope } from "@real-router/ssr-utils";
export async function render(url: string, req: IncomingMessage) {
const scope = createRequestScope(req, baseRouter);
try {
const state = await scope.router.start(url);
const html = await renderToString(scope.router);
const ssrState = `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`;
return `${html}${ssrState}`;
} finally {
await scope.dispose();
}
}// Client (entry-client.ts)
import { hydrateRouter } from "@real-router/ssr-utils";
const router = createAppRouter();
router.usePlugin(browserPluginFactory());
await hydrateRouter(router, window.__SSR_STATE__);
hydrateRoot(document.getElementById("root")!, <App router={router} />);import { getStaticPaths } from "@real-router/ssr-utils";
const router = createAppRouter();
const paths = await getStaticPaths(router, {
"blog.post": async () =>
(await fetchAllPostSlugs()).map((slug) => ({ params: { slug } })),
});
for (const path of paths) {
const html = await renderPageAt(path);
await writeStaticFile(path, html);
}- @real-router/core — Core router
- @real-router/route-utils — Adapter-facing route tree queries (unrelated purpose — see the "Sibling, not merged" note in the package's own docs for why the two aren't the same thing)
- @real-router/ssr-data-plugin — Per-route data loading, composes with the hydration scratchpad
-
@real-router/rsc-server-plugin — Per-route RSC (
ReactNode) loading, same composition - SSR Overview — Server-side rendering guide
- SSR Hydration — Hydration lifecycle and gotchas
- Streaming SSR — Streaming responses
- View-Agnostic Design
- Core Concepts
- Navigation Lifecycle
- Guards
- Plugin Architecture
- Hash Fragment Support
- Accessibility (A11y)
- Server-Side Rendering
- Data Loading
- Streaming SSR
- SSR Cancellation
- RSC Integration
- Testing
- Glossary
Tree-shakeable functions — import only what you need.
- add (addRoute)
- remove (removeRoute)
- replace (replaceRoutes)
- clear (clearRoutes)
- get (getRoute)
- has (hasRoute)
- update (updateRoute)
- subscribeChanges (Routes Mutation Event)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildNavigationState
- forwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- putField / copyFields
- getOptions
- extendRouter
- getTree
- React Integration Guide
- Preact Integration Guide
- Solid Integration Guide
- Vue Integration Guide
- Svelte Integration Guide
- Ink (Terminal UI) Integration Guide
- Desktop (Electron, Tauri) Integration Guide
- useRouter
- useRoute
- useRouteNode
- useNavigator
- useRouteUtils
- useRouterTransition
- useRouteExit
- useRouteEnter
- useRouteStore (Solid only)
- useRouteNodeStore (Solid only)
SSR-feature subpath —
@real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.
- Lazy (Svelte only — dynamic component import)
- Await — read deferred SSR data by key
- Streamed — Suspense-style boundary
- ClientOnly — server fallback → client children swap
- ServerOnly — server children, removed after hydration
- HttpStatusCode — render-time HTTP status declaration
-
HttpStatusProvider — provides sink to descendant
<HttpStatusCode> - useDeferred — read deferred Promise by key