-
-
Notifications
You must be signed in to change notification settings - Fork 1
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) |
XSS-safe JSON of State, strips per-navigation transition meta |
hydrateRouter(router, source) |
Convenience: parses JSON (if string), calls router.start(state.path)
|
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);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 (<, >, & → <, >, &) 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).
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)
- 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
- 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