ssr - greydragon888/real-router GitHub Wiki

Server-Side Rendering

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.


Why It Works

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.


The Three Primitives

SSR with real-router uses three operations. All exist in the core — nothing extra to install.

1. cloneRouter(router, deps?) — Per-Request Isolation

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.

2. router.start(url) — Route Resolution

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.

3. router.dispose() — Cleanup

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 in SSR

Guards work identically on server and client. The key pattern is dependency injection: define the guard once, inject different values per environment.

Route Config

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.

Server: Inject from Request

const router = cloneRouter(baseRouter, {
  isAuthenticated: checkAuth(req), // from cookie, session, JWT
});

Client: Inject from Browser State

const router = createAppRouter({
  isAuthenticated: document.cookie.includes("auth=1"),
});

Same guard factory, same route config, different dependency values. No if (typeof window !== 'undefined') anywhere.


Data Loading

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 interceptor

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


State Serialization

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.


Client Hydration

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.


React Integration

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.


Full SSR Flow

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

What You Don't Need

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

Static Site Generation (SSG)

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.

SSG Build Script

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 Hydration

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.


See Also

⚠️ **GitHub.com Fallback** ⚠️