ssr - greydragon888/real-router GitHub Wiki
Real-router's core has no browser dependencies — no window, no history, no DOM. This isn't an accident; it's a design choice that makes SSR work naturally. There's no "SSR mode" to enable, no framework-specific adapter to install. The same router, the same guards, the same plugins — they just work on the server.
This guide explains the mental model, the primitives you need, and the patterns that connect them.
Most routers are built around browser APIs. They read window.location, push to history, listen to popstate. When you want SSR, they need escape hatches — mock browsers, special render modes, conditional imports.
Real-router inverts this. The core router operates on data: you give it a path string, it gives you a state object. Browser integration is a plugin (browser-plugin), not a core concern. On the server, you skip the plugin and pass the URL directly:
// Server — no browser APIs needed
const state = await router.start("/users/42");
// => { name: "users.profile", params: { id: "42" }, path: "/users/42" }Everything that works on the client — guards, dependency injection, plugins — works identically on the server. No conditional logic, no platform checks.
SSR with real-router uses three operations. All exist in the core — nothing extra to install.
Every HTTP request needs its own router instance. You don't want request A's state leaking into request B. cloneRouter creates an independent copy that shares route definitions but has its own state, guards, and dependencies:
import { cloneRouter } from "@real-router/core/api";
// Created once at server startup
const baseRouter = createRouter(routes, {
defaultRoute: "home",
allowNotFound: true,
});
// Per-request handler
app.get("/{*path}", async (req, res) => {
const router = cloneRouter(baseRouter, {
isAuthenticated: checkAuth(req),
});
// ... render ... dispose
});The second argument merges dependencies into the clone. This is how you inject per-request context — auth state, user locale, feature flags — into guards and plugins without global mutable state.
See cloneRouter for full API details.
start() takes a URL path, matches it against the route tree, runs activation guards, and returns the resolved state. On the server, this is your entry point:
const state = await router.start(req.originalUrl);
// state.name => "users.profile"
// state.params => { id: "42" }If guards block (e.g., auth required), start() rejects — you catch it and redirect:
try {
const state = await router.start(url);
// render...
} catch {
res.redirect("/");
}If allowNotFound: true and the URL doesn't match any route, start() resolves with UNKNOWN_ROUTE — you return HTTP 404:
import { UNKNOWN_ROUTE } from "@real-router/core";
const statusCode = state.name === UNKNOWN_ROUTE ? 404 : 200;See start for full API details.
After rendering, dispose the per-request router. This tears down all plugins, removes interceptors, and releases resources:
try {
const state = await router.start(url);
const html = renderToString(<App />);
res.send(html);
} finally {
router.dispose(); // always clean up
}Always use finally — you want cleanup even if rendering throws.
Guards work identically on server and client. The key pattern is dependency injection: define the guard once, inject different values per environment.
const routes = [
{
name: "dashboard",
path: "/dashboard",
canActivate: (_router, getDep) => () => getDep("isAuthenticated") === true,
},
];The guard calls getDep("isAuthenticated") — it doesn't know or care where the value comes from.
const router = cloneRouter(baseRouter, {
isAuthenticated: checkAuth(req), // from cookie, session, JWT
});const router = createAppRouter({
isAuthenticated: document.cookie.includes("auth=1"),
});Same guard factory, same route config, different dependency values. No if (typeof window !== 'undefined') anywhere.
Routes often need data before rendering — a user profile, a list of items, page metadata. The @real-router/ssr-data-plugin handles this via a start() interceptor:
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
const loaders = {
"users.profile": async (params) => fetchUser(params.id),
"users.list": async () => fetchUsers(),
};
router.usePlugin(ssrDataPluginFactory(loaders));
const state = await router.start(url); // route resolved + data loaded
const data = state.context.data; // written by the plugin's start interceptorThe interceptor wraps start(): after route resolution, it calls the matching loader and writes the result to state.context.data. By the time the start() promise resolves, data is available on the returned state object.
SSR-only by design. The plugin does not intercept navigate(). Client-side data fetching is the application's responsibility — React Query, Suspense, useEffect, or whatever fits your stack.
See ssr-data-plugin for full API and configuration.
After server rendering, the client needs the data that was loaded on the server. Embed it in the HTML using serializeState from @real-router/core/utils:
import { serializeState } from "@real-router/core/utils";
const data = state.context.data;
const script = `<script>window.__SSR_DATA__=${serializeState({ data })}</script>`;serializeState escapes <, >, and & to their Unicode equivalents — preventing XSS via </script> injection or HTML entity attacks inside inline scripts. It's framework-agnostic: works with React, Vue, Svelte, or vanilla HTML.
On the client, create a fresh router (not a clone — the server's router was disposed), add browser-plugin for URL sync, and start before hydration:
import { browserPluginFactory } from "@real-router/browser-plugin";
const router = createAppRouter({
isAuthenticated: document.cookie.includes("auth=1"),
});
router.usePlugin(browserPluginFactory());
await router.start(); // browser-plugin injects window.location
hydrateRoot(document.getElementById("root"), <App />);The key: router.start() must complete before hydrateRoot(). This ensures the client router resolves to the same state as the server — no hydration mismatch.
browser-plugin makes the path argument optional — it injects window.location via a start interceptor. On the server, you pass the URL explicitly; on the client, the browser provides it.
RouterProvider and all hooks use useSyncExternalStore — SSR-safe by design. RouteView works in renderToString() too: the Activity wrapper (used for keepAlive on the client) renders children transparently during SSR.
import { RouterProvider, RouteView, Link } from "@real-router/react";
function App() {
return (
<RouterProvider router={router}>
<nav>
<Link routeName="home">Home</Link>
<Link routeName="users.list">Users</Link>
</nav>
<RouteView nodeName="">
<RouteView.Match segment="home">
<Home />
</RouteView.Match>
<RouteView.Match segment="users">
<UsersLayout />
</RouteView.Match>
<RouteView.NotFound>
<NotFound />
</RouteView.NotFound>
</RouteView>
</RouterProvider>
);
}No special SSR components. No conditional rendering. The same component tree works on both server and client.
Server (per request):
cloneRouter(base, deps)
→ usePlugin(ssrDataPluginFactory(loaders))
→ start(url)
→ route matched → guards run → state resolved → data loaded
→ renderToString(<RouterProvider><App /></RouterProvider>)
→ serializeState(data) → embed in HTML
→ dispose()
Client (once):
createRouter(routes, deps)
→ usePlugin(browserPluginFactory())
→ start() ← browser-plugin injects window.location
→ hydrateRoot(<RouterProvider><App /></RouterProvider>)
| Common SSR concern | Real-router's answer |
|---|---|
| SSR mode / config flag | None — core is platform-agnostic by default |
| Mock browser for server | None — start(url) takes a string, no browser needed |
| Framework-specific SSR adapter | None — RouterProvider uses useSyncExternalStore
|
| Special SSR components | None — RouteView, Link, hooks work in renderToString
|
| Request isolation mechanism |
cloneRouter() — built into core |
| Data loading framework integration |
ssr-data-plugin — optional, uses standard interceptors |
SSG is SSR at build time — instead of rendering per request, you pre-render all routes once and deploy static HTML files. Real-router provides getStaticPaths() in @real-router/core/utils to enumerate all leaf routes:
import { getStaticPaths } from "@real-router/core/utils";
const paths = await getStaticPaths(router, {
"users.profile": async () => [{ id: "1" }, { id: "2" }, { id: "3" }],
});
// → ["/", "/users", "/users/1", "/users/2", "/users/3"]getStaticPaths walks the route tree, finds all leaf routes (routes with no children), and builds their URLs. For static routes (no parameters), paths are built directly. For dynamic routes (:id), you provide an entries map that returns the parameter sets to pre-render.
The build script is a loop over getStaticPaths output — typically ~30 lines:
const paths = await getStaticPaths(router, entries);
for (const url of paths) {
const ssgRouter = cloneRouter(baseRouter);
ssgRouter.usePlugin(ssrDataPluginFactory(loaders));
await ssgRouter.start(url);
const html = renderToString(<RouterProvider router={ssgRouter}><App /></RouterProvider>);
writeFileSync(`dist${url === "/" ? "/index.html" : `${url}/index.html`}`, html);
ssgRouter.dispose();
}Client entry detects SSG content and hydrates instead of fresh-rendering:
const rootElement = document.querySelector("#root")!;
const app = <RouterProvider router={router}><App /></RouterProvider>;
if (rootElement.firstElementChild) {
hydrateRoot(rootElement, app); // SSG build — hydrate
} else {
createRoot(rootElement).render(app); // dev mode — fresh render
}See the SSG Example for a complete working setup with React + Vite.
- Core Concepts — Route tree, state, transitions
- Guards — Activate/deactivate guards, execution order
-
Plugin Architecture — Hooks, interceptors,
extendRouter - cloneRouter — Per-request router cloning
- ssr-data-plugin — Per-route data loading API
- start — Router start method
- SSR Example — Full working SSR example with React + Vite + Express
- SSG Example — Full working SSG example with React + Vite