Skip to content

SSR Hydration

olegivanov edited this page May 18, 2026 · 4 revisions

SSR Hydration

Transport server-resolved State to the client for hydration (#563)

Why

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).

Quick Start

Server (entry-server.tsx)

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

Client (entry-client.tsx)

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

Same flow in Vue 3

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).

Same flow in Solid

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 (hydraterender, <RouteView.NotFound> hydration-key gap, vite-plugin-solid({ ssr: true }) requirement).

Same flow in Svelte 5

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 (hydratemount, {#await} ships pending UI not real content, <svelte:head> head injection).

Same flow in Angular 21

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).

What gets serialized

serializeRouterState(state) keeps:

  • state.name
  • state.params
  • state.path — canonical URL (server's source of truth)
  • state.context — plugin context namespaces (e.g. state.context.data from ssr-data-plugin)

serializeRouterState(state) strips:

  • state.transition — per-navigation TransitionMeta. Regenerated on commit.

  • Any namespace listed in options.excludeContext (second optional argument). Use this when a plugin populates state.context.<ns> with non-JSON-serializable values (e.g. @real-router/rsc-server-plugin writes a ReactNode to state.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>.

Reading server-side context payloads

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).

Non-JSON types via devalue / superjson

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

Round-trip Date / Map / Set with devalue

// 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.

superjson alternative

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 safety with custom serializers

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.

When to use which

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.

Composability with excludeContext

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

Why path-only?

state.path is the canonical URL produced by the server's full pipeline (matchPathforwardStatebuildPath). When the client calls router.start(state.path):

  • The same URL is fed to the same route tree → matchPath produces the same name + params.
  • Client-side forwardState and buildPath interceptors 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.

Related

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