Skip to content

ssr utils

olegivanov edited this page Oct 8, 2026 · 6 revisions

@real-router/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.

Overview

@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

Installation

npm install @real-router/ssr-utils

Requires @real-router/core as a peer dependency.


Import

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";

API Reference

serializeState(data, options?)

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 },
);

serializeRouterState(state, options?)

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 });

hydrateRouter(router, source, options?)

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.


getHydrationState(router)

For plugin authors. Returns what the in-flight hydrateRouter call deposited for router, or null outside one.

getHydrationState(router: Router): SerializedRouterState | null

Read 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 hydrateRouter must resolve the same copy of @real-router/ssr-utils. Two copies hold two scratchpads, and the read returns null without an error — the loader simply runs again on first paint. @real-router/ssr-data-plugin and @real-router/rsc-server-plugin depend on this package directly so that one release line resolves to one copy.


getStaticPaths(router, entries?)

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 in params, or one it declares nowhere that the active queryParamsMode will not print. Every entry differing only in that key would generate the same file.
  • An enumerated leaf that declares forwardTo whose 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 from entries and let the link resolve at runtime.

createRequestScope(request, base, deps?)

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.


Types

SerializedRouterState<P>

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">;

RequestScope<D>

interface RequestScope<D extends DefaultDependencies = DefaultDependencies>
  extends AsyncDisposable {
  readonly router: Router<D>;
  readonly signal: AbortSignal;
  dispose: () => Promise<void>;
}

RequestScopeSource

type RequestScopeSource = IncomingMessageLike | RequestLike;

interface IncomingMessageLike {
  on: (event: "close", listener: () => void) => unknown;
  removeListener?: (event: "close", listener: () => void) => unknown;
}

interface RequestLike {
  signal: AbortSignal;
}

Usage Examples

Full SSR round-trip

// 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} />);

SSG build step

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);
}

See Also

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

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

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally