Skip to content

SSR Hydration

olegivanov edited this page May 3, 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) XSS-safe JSON of State, strips per-navigation transition meta
hydrateRouter(router, source) Convenience: parses JSON (if string), calls router.start(state.path)

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

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 (<, >, &<, >, &) 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).

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