-
-
Notifications
You must be signed in to change notification settings - Fork 0
SSR Hydration
Transport server-resolved State to the client for hydration (#563)
In SSR, the server resolves a URL into a State (running matchPath, forwardState, buildPath) and renders HTML. The client needs to commit the same State on hydration so that the first React/Vue render matches server-rendered HTML.
The canonical URL — state.path — is the source of truth. Real-router's start(path) deterministically reproduces the server's State on the client, provided your interceptors are deterministic on URL alone. If they read async client-only state (auth that loads asynchronously, A/B group from a non-cookie source, persistent params from localStorage), that's a design issue: server and client must see the same input given the same URL, otherwise hydration mismatches will appear regardless of how you start the router.
This page covers two helpers in @real-router/core/utils for the SSR transport layer:
| Helper | Purpose |
|---|---|
serializeRouterState(state, options?) |
XSS-safe JSON of State, strips per-navigation transition meta. options.serialize plugs a custom serializer (devalue.stringify / superjson.stringify) for non-JSON types — see Non-JSON types. |
hydrateRouter(router, source, options?) |
Convenience: parses JSON (if string), calls router.start(state.path). options.deserialize matches the custom serializer (devalue.parse / superjson.parse). |
import { UNKNOWN_ROUTE } from "@real-router/core";
import { cloneRouter } from "@real-router/core/api";
import { serializeRouterState } from "@real-router/core/utils";
import { renderToString } from "react-dom/server";
export async function render(url: string, ctx: RenderContext) {
const router = cloneRouter(baseRouter, ctx);
router.usePlugin(ssrDataPluginFactory(loaders));
try {
const state = await router.start(url);
const html = renderToString(
<RouterProvider router={router}>
<App />
</RouterProvider>,
);
return {
html,
// Inject server-resolved State for client hydration
script: `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`,
statusCode: state.name === UNKNOWN_ROUTE ? 404 : 200,
};
} finally {
router.dispose();
}
}import { browserPluginFactory } from "@real-router/browser-plugin";
import { hydrateRouter } from "@real-router/core/utils";
import { hydrateRoot } from "react-dom/client";
declare global {
interface Window {
__SSR_STATE__?: { path: string };
}
}
const router = createAppRouter({ /* client deps */ });
router.usePlugin(browserPluginFactory());
const ssrState = window.__SSR_STATE__;
if (ssrState) {
await hydrateRouter(router, ssrState);
} else {
await router.start();
}
hydrateRoot(document.getElementById("root")!, <App />);hydrateRouter extracts state.path and calls router.start(state.path) — a one-line wrapper around JSON.parse + start. You can equivalently write the call inline if preferred:
await router.start(JSON.parse(rawJson).path);The hydration contract is framework-agnostic — serializeRouterState and hydrateRouter know nothing about React. The Vue 3 version just swaps the renderer and the mount call:
// entry-server.ts
import { createSSRApp, h } from "vue";
import { renderToString } from "vue/server-renderer";
import { RouterProvider } from "@real-router/vue";
export async function render(url: string, ctx: RenderContext) {
const router = cloneRouter(baseRouter, ctx);
router.usePlugin(ssrDataPluginFactory(loaders));
try {
const state = await router.start(url);
const app = createSSRApp({
render: () => h(RouterProvider, { router }, { default: () => h(App) }),
});
const html = await renderToString(app);
return {
html,
script: `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`,
statusCode: state.name === UNKNOWN_ROUTE ? 404 : 200,
};
} finally {
router.dispose();
}
}// entry-client.ts
import { createSSRApp, h } from "vue";
import { hydrateRouter } from "@real-router/core/utils";
import { RouterProvider } from "@real-router/vue";
const router = createAppRouter({ /* client deps */ });
router.usePlugin(browserPluginFactory());
const ssrState = window.__SSR_STATE__;
if (ssrState) {
await hydrateRouter(router, ssrState);
} else {
await router.start();
}
createSSRApp({
render: () => h(RouterProvider, { router }, { default: () => h(App) }),
}).mount("#root");The same invariant holds: await hydrateRouter(...) must complete before mount("#root"), otherwise Vue's first render reads an unstarted router and you'll see hydration mismatches in the console. See Vue Integration — Server-Side Rendering for Vue-specific gotchas (per-request createSSRApp, blocking <Suspense> in SSR, SSG dev-mode factory detection).
The hydration contract is identical for Solid; what changes is the renderer (solid-js/web) and the mandatory generateHydrationScript() injection that Solid requires in <head> ahead of the body:
// entry-server.tsx
import { generateHydrationScript, renderToString } from "solid-js/web";
import { RouterProvider } from "@real-router/solid";
export async function render(url: string, ctx: RenderContext) {
const router = cloneRouter(baseRouter, ctx);
router.usePlugin(ssrDataPluginFactory(loaders));
try {
const state = await router.start(url);
const html = renderToString(() => (
<RouterProvider router={router}>
<App />
</RouterProvider>
));
return {
html,
hydrationScript: generateHydrationScript(),
script: `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`,
statusCode: state.name === UNKNOWN_ROUTE ? 404 : 200,
};
} finally {
router.dispose();
}
}// entry-client.tsx
import { hydrate } from "solid-js/web"; // separate function, NOT render(...)
import { hydrateRouter } from "@real-router/core/utils";
import { RouterProvider } from "@real-router/solid";
const router = createAppRouter({ /* client deps */ });
router.usePlugin(browserPluginFactory());
const ssrState = window.__SSR_STATE__;
if (ssrState) {
await hydrateRouter(router, ssrState);
} else {
await router.start();
}
hydrate(
() => (
<RouterProvider router={router}>
<App />
</RouterProvider>
),
document.querySelector("#root")!,
);Two ordering invariants combine on the client: await hydrateRouter(...) must complete before hydrate(...), and the server must inject the generateHydrationScript() output into <head> before any body chunk that contains hydration markers. Without the script, _$HY is undefined and the streamed <template id="..."> patches have nothing to splice into. See Solid Integration — Server-Side Rendering for Solid-specific gotchas (hydrate ≠ render, <RouteView.NotFound> hydration-key gap, vite-plugin-solid({ ssr: true }) requirement).
The hydration contract is identical for Svelte 5; what changes is the renderer (svelte/server.render), the separate hydrate() function (Svelte 5 has no mount({ hydrate: true }) opt-in), and the head field that ships <svelte:head> content from rendered components:
// entry-server.ts
import { render } from "svelte/server";
import App from "./App.svelte";
export async function renderPage(url: string, ctx: RenderContext) {
const router = cloneRouter(baseRouter, ctx);
router.usePlugin(ssrDataPluginFactory(loaders));
try {
const state = await router.start(url);
// RenderOutput is `SyncRenderOutput & PromiseLike<SyncRenderOutput>` —
// `await` covers both sync and async paths.
const { head, body } = await render(App, { props: { router } });
return {
html: body,
head,
script: `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`,
statusCode: state.name === UNKNOWN_ROUTE ? 404 : 200,
};
} finally {
router.dispose();
}
}// entry-client.ts
import { hydrate } from "svelte"; // ← separate function, NOT mount({ hydrate: true })
import { hydrateRouter } from "@real-router/core/utils";
import App from "./App.svelte";
const router = createAppRouter({ /* client deps */ });
router.usePlugin(browserPluginFactory());
const ssrState = window.__SSR_STATE__;
if (ssrState) {
await hydrateRouter(router, ssrState);
} else {
await router.start();
}
hydrate(App, { target: document.querySelector("#root")!, props: { router } });The same invariant holds: await hydrateRouter(...) must complete before hydrate(App, ...), otherwise the first render reads an unstarted router and Svelte's hydration emits [svelte] hydration_* warnings. SSG dual-mode mount must branch explicitly (if (firstElementChild) hydrate(...) else mount(...)) — mount({ hydrate: true }) does not exist in Svelte 5.
A second platform-specific constraint: do not override resolve.conditions in vite.config.ts (e.g. with ["development"]). Vite's defaults include "browser" for client builds, which is what routes import { hydrate } from "svelte" to the client runtime. Replacing the default conditions makes the client build resolve to index-server.js and throw lifecycle_function_unavailable at boot. See Svelte Integration — Server-Side Rendering for Svelte-specific gotchas (hydrate ≠ mount, {#await} ships pending UI not real content, <svelte:head> head injection).
The hydration contract is identical for Angular 21, but the router-side wiring uses the new provideRealRouterFactory (added in #582) instead of provideRealRouter. Reason: AngularNodeAppEngine owns the per-request lifecycle and exposes REQUEST: InjectionToken<Request | null>; a single useValue router cannot satisfy per-request scope.
// app.config.ts — shared providers
import { provideZonelessChangeDetection } from "@angular/core";
import { provideRealRouterFactory } from "@real-router/angular";
import { browserPluginFactory } from "@real-router/browser-plugin";
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
const baseRouter = createBaseRouter();
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRealRouterFactory({
baseRouter,
// Function form — server-only plugins differ from client.
plugins: (request) =>
request
? [ssrDataPluginFactory(loaders)]
: [browserPluginFactory(), ssrDataPluginFactory(loaders)],
// Per-request deps from cookies (server) or document.cookie (client).
deps: (request) => ({
currentUser: request
? parseCookies(request.headers.get("cookie") ?? "")
: parseCookies(typeof document !== "undefined" ? document.cookie : ""),
}),
}),
],
};// main.server.ts — server bootstrap accepts BootstrapContext
import { bootstrapApplication, type BootstrapContext } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { serverConfig } from "./app.config.server";
const bootstrap = (context: BootstrapContext) =>
bootstrapApplication(AppComponent, serverConfig, context);
export default bootstrap;// server.ts — Express + AngularNodeAppEngine
import {
AngularNodeAppEngine,
createNodeRequestHandler,
writeResponseToNodeResponse,
} from "@angular/ssr/node";
import express from "express";
export const app = express();
const angularApp = new AngularNodeAppEngine();
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) =>
response ? writeResponseToNodeResponse(response, res) : next(),
)
.catch((error: unknown) => {
// Real-Router's CANNOT_ACTIVATE bubbles up from `provideAppInitializer`
// → `bootstrapApplication` rejection. Translate to 302 redirect at the
// Express layer (Angular SSR has no built-in redirect-on-guard hook).
if ((error as { code?: string } | null)?.code === "CANNOT_ACTIVATE") {
res.redirect(302, "/");
return;
}
next(error);
});
});
export const reqHandler = createNodeRequestHandler(app);// main.ts — client entry
import {
bootstrapApplication,
provideClientHydration,
withIncrementalHydration,
} from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { appConfig } from "./app.config";
bootstrapApplication(AppComponent, {
...appConfig,
providers: [
...(appConfig.providers ?? []),
provideClientHydration(withIncrementalHydration()),
],
}).catch((err: unknown) => console.error(err));The same hydration invariant holds, but Angular handles it implicitly: provideAppInitializer (registered by provideRealRouterFactory internally) runs await router.start(url) BEFORE the first component renders — so <route-view> already sees the resolved state when Angular claims the server-rendered DOM. No external await hydrateRouter(...) step needed; the factory wraps it. For SSG (build-time render), the ssg-build.ts script boots the same server.mjs in-process on a build-only port and fetch-es each URL, then writes static HTML — see examples/web/angular/ssr-examples/ssg/ for the pattern (avoids the NG0201 trap of renderApplication direct + platformProviders REQUEST mismatch).
A platform-specific constraint: @angular/router must be a peer dep with a stub path: "**" route to satisfy @angular/ssr's URL matching pipeline; the actual app routing uses Real-Router via <route-view>, so @angular/router is purely a placeholder. See Angular Integration — Server-Side Rendering for Angular-specific gotchas (provideRealRouterFactory vs provideRealRouter decision matrix, BootstrapContext requirement, withRoutes + withAppShell shape, security.allowedHosts: ["localhost"] requirement).
serializeRouterState(state) keeps:
state.namestate.params-
state.path— canonical URL (server's source of truth) -
state.context— plugin context namespaces (e.g.state.context.datafromssr-data-plugin)
serializeRouterState(state) strips:
-
state.transition— per-navigationTransitionMeta. Regenerated on commit. -
Any namespace listed in
options.excludeContext(second optional argument). Use this when a plugin populatesstate.context.<ns>with non-JSON-serializable values (e.g.@real-router/rsc-server-pluginwrites aReactNodetostate.context.rsc):const json = serializeRouterState(state, { excludeContext: ["rsc"] });
XSS escapes (<, >, & → \u003c, \u003e, \u0026) are inherited from the underlying serializeState, so the JSON is safe to embed inside <script>.
hydrateRouter only consumes state.path. If your app uses state.context.<namespace> for SSR data transport (e.g., ssr-data-plugin writes state.context.data on the server), read that value separately from window.__SSR_STATE__ before discarding the JSON:
const ssrState = window.__SSR_STATE__ as
| { path: string; context?: { data?: unknown } }
| undefined;
const initialData = ssrState?.context?.data;
// hand initialData to your store / cache / data layer
if (ssrState) await hydrateRouter(router, ssrState);The router doesn't carry state.context across hydration automatically — plugins write context on the client during their own lifecycle hooks. If you want server-rendered data to bypass a re-fetch on the client, manage that at the data layer (TanStack Query dehydrate/hydrate, store rehydration, or a plugin's own initial-data option).
JSON.stringify silently loses Map, Set, RegExp (replaced with {}), pre-converts Date to ISO string, and throws on BigInt. Real-router's defaults match this contract — fine for plain JSON-shaped payloads, insufficient when your loader returns Map / Set / Date / BigInt and you want them back as live types on the client.
Both helpers accept a custom serializer pair via options.serialize / options.deserialize. The defaults are JSON.stringify / JSON.parse; pass devalue.stringify / devalue.parse (compact, broad type support) or superjson.stringify / superjson.parse (readable {json, meta} shape) — both produce valid JSON strings that travel through the same XSS-escape pipeline (#606).
devalue and superjson are not bundled. Install whichever you prefer as a peer dependency.
pnpm add devalue
# or
pnpm add superjson// entry-server.ts
import * as devalue from "devalue";
import { serializeRouterState } from "@real-router/core/utils";
const state = await router.start(url);
// state.context.data was produced by ssr-data-plugin's loader and may contain
// Date / Map / Set / RegExp instances — devalue preserves their types.
const json = serializeRouterState(state, { serialize: devalue.stringify });
return {
html,
script: `<script>window.__SSR_STATE__=${json}</script>`,
};// entry-client.ts
import * as devalue from "devalue";
import { hydrateRouter } from "@real-router/core/utils";
const router = createAppRouter(/* ... */);
router.usePlugin(browserPluginFactory());
await hydrateRouter(router, window.__SSR_STATE__, {
deserialize: devalue.parse,
});After hydration, state.context.data.fetchedAt instanceof Date === true, state.context.data.tags instanceof Set === true, etc.
import { stringify, parse } from "superjson";
// Server
const json = serializeRouterState(state, { serialize: stringify });
// Client
await hydrateRouter(router, window.__SSR_STATE__, { deserialize: parse });superjson's output shape is { json, meta } — a typed envelope that's easier to inspect manually but ~20–30% larger on the wire than devalue for typical payloads. Both round-trip identically through real-router.
XSS-escape (< / > / & → \u003c / \u003e / \u0026) is applied to the serializer's output regardless of which serializer produced it. JSON.parse natively decodes those escapes back inside string values, so neither devalue.parse nor superjson.parse see the encoded form — the round-trip is lossless. The escape pass exists purely to make the <script> tag injection-safe; it does not require coordination with the serializer choice.
| Concern | Default JSON
|
devalue |
superjson |
|---|---|---|---|
Date |
Lost (ISO string only) | ✓ Date instance preserved |
✓ Date instance preserved |
Map / Set / RegExp
|
Lost (becomes {}) |
✓ preserved | ✓ preserved |
BigInt |
Throws TypeError
|
✓ preserved | ✓ preserved |
undefined round-trip |
becomes null
|
✓ undefined preserved ([-1] encoding) |
✓ undefined preserved (json: undefined) |
| Wire size | smallest | ~10–15% larger | ~20–30% larger |
| Inspection | familiar JSON | flat array (compact, harder to read by hand) |
{ json, meta } (readable) |
| Cycles | throws | ✓ supported via reference-by-index | ✗ throws |
| Peer-dep weight | 0 | ~1.5 KB gzip | ~3 KB gzip |
If your loader payload is JSON-shape (no Date/Map/Set/BigInt), stick with the default — fewer dependencies, smaller wire.
options.excludeContext filtering runs before the custom serializer. When a plugin populates a namespace with non-serializable values that even devalue can't handle (e.g. @real-router/rsc-server-plugin writes a ReactNode to state.context.rsc), strip the namespace first:
const json = serializeRouterState(state, {
excludeContext: ["rsc"], // applied first
serialize: devalue.stringify, // sees state without `rsc`
});state.path is the canonical URL produced by the server's full pipeline (matchPath → forwardState → buildPath). When the client calls router.start(state.path):
- The same URL is fed to the same route tree →
matchPathproduces the same name + params. - Client-side
forwardStateandbuildPathinterceptors run; if they're deterministic on URL (the only correct design), they produce identical results to the server's. - Activation guards run normally — that's where current-world checks belong (live session, permissions).
Bypassing those interceptors on the client (an alternative considered and rejected) would mask non-idempotent interceptor design rather than fix it. A forwardFn that depends on async client state is a bug; the fix is to make it synchronous on a shared input (cookie, header) — not to skip it on hydration.
-
#525 —
navigateToStateprimitive (used bystart(path)since #525) - Plugin Architecture
-
@real-router/ssr-data-plugin — Per-route data loading on
start() -
@real-router/rsc-server-plugin — Per-route ReactNode (RSC payload) loading on
start() - Server-Side Rendering — Full SSR overview
- 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)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildState
- buildNavigationState
- forwardState
- getForwardState
- setForwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- getOptions
- addBuildPathInterceptor
- 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