@real-router/ssr-data-plugin
v0.6.0
Published
SSR per-route data loading plugin for Real-Router
Readme
@real-router/ssr-data-plugin
Per-route data loading for SSR with Real-Router. Intercepts
start()to load data before server rendering.
// Without plugin:
const state = await router.start(url);
const data = await loadRouteData(state.name, state.params); // manual
// With plugin:
router.usePlugin(ssrDataPluginFactory(loaders));
const state = await router.start(url);
const data = state.context.data; // loaded automaticallyInstallation
npm install @real-router/ssr-data-pluginPeer dependencies: @real-router/core
Quick Start
import { createRouter } from "@real-router/core";
import { cloneRouter } from "@real-router/core/api";
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
import type { DataLoaderFactoryMap } from "@real-router/ssr-data-plugin";
import { routes } from "./routes"; // your app's route tree
const loaders: DataLoaderFactoryMap = {
"users.profile": () => async ({ params }) => fetchUser(params.id),
"users.list": () => async () => fetchUsers(),
};
// Base router — created once at module load
const baseRouter = createRouter(routes, { defaultRoute: "home", allowNotFound: true });
// Per-request SSR
const router = cloneRouter(baseRouter, { isAuthenticated: true });
router.usePlugin(ssrDataPluginFactory(loaders));
const state = await router.start(url);
const data = state.context.data; // data loaded by matching loader
const html = renderToString(<App />);
router.dispose();Configuration
Entries are keyed by route name (not path). Each value is either a factory function (router, getDependency) => loaderFn (short form) or an object { ssr?, loader? } with optional per-route SSR mode. The factory runs once at plugin registration; the returned loader is cached. Each loader receives { params, search } and returns Promise<unknown> | unknown:
import type { DataLoaderFactoryMap } from "@real-router/ssr-data-plugin";
const loaders: DataLoaderFactoryMap = {
// Short form — defaults to ssr: "full"
home: () => async () => ({ featured: await fetchFeatured() }),
// Object form — opt out of server rendering for this route
"admin.dashboard": { ssr: false },
// Object form — server fetches data, app ships shell + JSON
"users.profile": {
ssr: "data-only",
loader: () => async ({ params }) => ({ user: await fetchUser(params.id) }),
},
// Function-form resolver — mode resolved per-navigation
"docs.detail": {
ssr: (state) => state.search.format === "pdf" ? "client-only" : "full",
loader: () => async ({ params }) => ({ doc: await fetchDoc(params.id) }),
},
};Routes without a matching entry produce no data — state.context.data is undefined and getSsrDataMode(state) falls back to "full".
Per-route SSR mode
Three modes are supported. The plugin publishes the resolved mode to state.context.ssrDataMode; read it via getSsrDataMode(state):
| ssr value | mode marker | loader behaviour |
| ---------------------------- | ----------------- | ------------------------- |
| omitted / true / "full" | "full" | runs (composes with #596) |
| "data-only" | "data-only" | runs (composes with #596) |
| false / "client-only" | "client-only" | skipped unconditionally |
| (state) => SsrMode | resolver result | resolved per-navigation |
"client-only" is symmetric: the loader is skipped on every start() call (server and client). The application reads getSsrDataMode(state) and triggers its own client-side fetch (React Query, useEffect, Suspense). This keeps the plugin free of environment detection.
import { getSsrDataMode } from "@real-router/ssr-data-plugin";
const state = await router.start(url);
const mode = getSsrDataMode(state); // "full" | "data-only" | "client-only"
if (mode === "full") {
return renderToString(<App router={router} />);
}
return `<div data-ssr-mode="${mode}"></div>`;The function-form resolver receives state before the mode is written, so it should not read state.context.ssrDataMode. Branch on state.params, state.search, state.path, or state.name.
See examples/web/react/ssr-examples/ssr-mixed/ for a hybrid pipeline that demonstrates all three modes from a single entry-server.tsx.
Accessing Data
After await router.start(url), data is available on the returned state's context:
const state = await router.start(url);
const data = state.context.data; // loaded data, or undefined if no loader matchedThe plugin claims the "data" namespace on state.context via the claim-based API. Module augmentation on @real-router/core/types provides type safety for state.context.data.
SSR-Only by Design (with explicit CSR revalidation channel)
This plugin intercepts start() only — not navigate(). In SSR, the flow is:
cloneRouter → usePlugin → start(url) → data loaded → state.context.data → renderToStringClient-side navigation does not re-run the loader by default — application-layer fetching (React Query, Suspense, useEffect) owns CSR data. The one explicit exception is the invalidate() revalidation channel below.
Client-side revalidation (invalidate)
After a mutation, mark the "data" namespace stale on the router. The next navigation (including a same-route reload) re-runs the loader for the destination route and overwrites state.context.data before TRANSITION_SUCCESS fires — so subscribers see the fresh payload.
import { invalidate } from "@real-router/ssr-data-plugin";
// Fire-and-forget — stale until the user navigates somewhere.
invalidate(router, "data");
// Explicit await — pair with a same-route reload.
invalidate(router, "data");
await router.navigate(state.name, state.params, state.search, { reload: true });The flag is preserved until a successful, non-cancelled loader write. So a navigation that lands on a route without a loader entry, a client-only route, a mode-only entry, or one that gets cancelled mid-loader (newer navigate() aborts the older controller) all leave the flag set for the next attempt.
Failure semantics. The refresh loader runs in the awaited LEAVE_APPROVE phase with no internal
try/catch, so a rejecting loader rejects the consumingnavigate()— one that would have succeeded withoutinvalidate. The flag stays set (cleared only after a successful write), so every subsequent navigation to a loader-bearing route re-runs the loader and fails again until it recovers — the degradation escalates from "stale data" to "cannot navigate." Catch thenavigate()rejection on the caller side, or make the loader infallible (catch→ previous payload).
Idempotent — multiple invalidate() calls between refreshes collapse to one re-run. Survives cloneRouter() boundaries: each clone has its own flag set. Surgical for multi-namespace routes — only "data" re-runs. Nothing is cached across states: state.context is rebuilt empty for every navigation, so state.context.rsc is absent unless @real-router/rsc-server-plugin's own invalidate() was also called on the same transition.
Cancellation-aware loaders
The leave handler passes the navigation's AbortController.signal as the second loader argument so loaders can abort their in-flight work (fetch, DB query, …) when a newer navigation supersedes:
"users.profile": () => async ({ params }, ctx) => {
// Network layer cancels on rapid double-click — second click aborts
// the first nav's controller, fetch sees `signal.aborted` and rejects.
const response = await fetch(`/api/user/${params.id}`, {
signal: ctx?.signal,
});
return response.json();
},The start interceptor calls the loader without a context — SSR boot path apps thread a request-scoped signal via cloneRouter(base, { abortSignal }) + getDep("abortSignal") + withTimeout({ upstreamSignal }).
Robust loaders check signal.aborted upfront — a signal aborted before addEventListener("abort", …) does NOT auto-fire the listener. Pattern documented in the home loader of every ssr-mixed/ example.
Non-breaking via TypeScript contravariance — existing ({ params }) => … loaders without the second arg continue to work; they just don't observe cancellation.
Post-hydration loader skip
When the application uses hydrateRouter() from @real-router/ssr-utils, the parsed server-serialized state is briefly deposited on a one-shot internal scratchpad before start() runs. The plugin reads this scratchpad and reuses the server-resolved value if state.context.data is already present for the same route name — skipping the redundant client-side loader call on first paint.
// Server: state.context.data populated by the loader, serialized into HTML
const html = `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`;
// Client: hydrateRouter feeds the scratchpad, plugin sees it and skips re-load
await hydrateRouter(router, window.__SSR_STATE__);
// loader was NOT called — state.context.data === server's valueThe skip is single-shot — only the first start() triggered by hydrateRouterconsumes the scratchpad. Subsequent navigations run the loader normally. Composes with per-route mode: "client-only" skips the loader regardless of scratchpad contents (mode wins).
Mode marker is always written. Even on a scratchpad-hit the plugin still calls claim.write(state, mode) for state.context.ssrDataMode before the loader-skip branch runs. So a route configured ssr: "full" keeps getSsrDataMode(state) === "full" on the client after hydration even when the loader was skipped — UI conditionals that branch on ssrDataMode don't need to special-case the post-hydration first paint. Symmetric on the rsc-server-plugin side (state.context.ssrRscMode).
Typed Loader Errors (@real-router/ssr-data-plugin/errors)
The plugin is HTTP-agnostic — it only awaits the loader and writes the result to state.context.data. To bridge loader failures to HTTP semantics (404, 30x, 504), import typed error classes from the errors subpath and let your handler catch them:
import {
LoaderNotFound,
LoaderRedirect,
LoaderTimeout,
withTimeout,
} from "@real-router/ssr-data-plugin/errors";
const loaders: DataLoaderFactoryMap = {
"users.profile": (_router, getDep) => ({ params }) => {
const upstreamSignal = (
getDep as unknown as (k: string) => AbortSignal | undefined
)("abortSignal");
return withTimeout(
"users.profile",
250,
async ({ signal }) => {
// signal aborts on the 250 ms deadline OR on client disconnect
// (upstream); fetch propagates the abort to the network layer.
const user = await fetchUser(params.id, { signal });
if (!user) throw new LoaderNotFound(`user:${params.id}`);
return { user };
},
{ upstreamSignal },
);
},
"users.legacy": () => ({ params }) => {
throw new LoaderRedirect(`/users/${params.id}`, 301);
},
};
// In the handler:
try {
const state = await router.start(url);
return renderHtml(state);
} catch (error) {
if (error?.code === "LOADER_NOT_FOUND") return res.status(404).send("Not Found");
if (error?.code === "LOADER_REDIRECT") return res.redirect(error.status, error.target);
if (error?.code === "LOADER_TIMEOUT") return res.status(504).send("Timeout");
throw error;
}Discriminator is the code field — match structurally without instanceof. Identical errors are also re-exported from @real-router/rsc-server-plugin/errors (same shared source) so RSC apps don't need to add a ssr-data-plugin dependency just to throw LoaderNotFound.
Deferred data with defer() (#610)
Loaders may return a defer({ critical, deferred }) payload to split the response into a critical bundle (resolved before the shell renders) and a deferred record of named promises (streamed after via inline <script>__rrDefer__("key", json)</script> tags). React 19's <Suspense> + use(promise) and the cross-framework <Await> / useDeferred(key) adapters consume the deferred map natively:
import { defer, LoaderNotFound } from "@real-router/ssr-data-plugin";
"products.detail": () => ({ params }) => {
const product = getProduct(params.id);
if (!product) throw new LoaderNotFound(`product:${params.id}`);
return defer({
critical: { product }, // awaited, lands in state.context.data
deferred: { // streamed, lands in state.context.ssrDataDeferred
reviews: fetchReviews(params.id),
related: fetchRelated(params.id),
},
});
};The plugin writes:
state.context.data—critical(existing contract; consumers readingstate.context.datasee no change)state.context.ssrDataDeferred—Record<string, Promise<unknown>>. Server: real loader-returned promises. Client (post-hydration): registry-backed promises that resolve as inline settle scripts land.state.context.ssrDataDeferredKeys— declared key list. Included in the serialized SSR state so the client plugin can reconstruct the deferred map on hydration.
Reserved keys. defer() throws TypeError(/is reserved/) for __proto__, constructor, or prototype as deferred-map keys — defence-in-depth against prototype-chain corruption during client-side reconstruction.
Shallow-clone freeze. defer() freezes a shallow clone of the deferred map. The caller's reference stays mutable, but post-call mutations cannot smuggle entries past the reserved-key / thenable validation pass. An eagerly-rejected promise gets a defensive no-op .catch(() => {}) attached so a synchronous rejection doesn't trip process.on("unhandledRejection") before injectDeferredScripts attaches its real .then.
Streaming SSR pipeline (/server subpath)
Pair defer()-returning loaders with injectDeferredScripts on the server to interleave the React stream with <script>__rrDefer__(...)</script> settle tags as each promise resolves:
import { renderToReadableStream } from "react-dom/server";
import {
getDeferBootstrapScript,
injectDeferredScripts,
} from "@real-router/ssr-data-plugin/server";
const reactStream = await renderToReadableStream(<App />);
const deferred =
(state.context as { ssrDataDeferred?: Record<string, Promise<unknown>> })
.ssrDataDeferred ?? {};
// Wrap the React stream — settle scripts interleave in resolution order.
const stream = injectDeferredScripts(reactStream, deferred, {
bootstrap: false, // emit bootstrap separately for cleaner React hydration
});
// Embed the bootstrap once in <head>:
const bootstrap = `<script>${getDeferBootstrapScript()}</script>`;InjectDeferredScriptsOptions
| Option | Type | Default | Purpose |
| ---------------- | ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| serialize | Serializer = (v) => string | JSON.stringify | Custom value serializer. Pass devalue.stringify / superjson.stringify for Date / Map / Set / BigInt payloads. Output must JSON.parse cleanly on the client. |
| serializeError | (error: unknown) => string | JSON.stringify({ name, message }) | Custom error serializer. Output lands in __rrDeferError__("key", json). The bootstrap reconstructs an Error with { name, message }. |
| bootstrap | boolean | true | When true, prepends <script>${getDeferBootstrapScript()}</script> to the stream. Set false to embed the bootstrap separately in <head> (cleaner React hydration). |
Serializer is exported from @real-router/ssr-data-plugin/server so application code (e.g. wrappers around devalue / superjson) can type-annotate its custom serializer.
See examples/web/react/ssr-examples/ssr-streaming/ for the full pipeline and the Streaming SSR wiki guide for design background.
Cleanup
const unsubscribe = router.usePlugin(ssrDataPluginFactory(loaders));
// Later — releases "data" namespace claim and stops data loading
unsubscribe();In SSR, router.dispose() handles cleanup automatically.
Streaming SSR
Combine with React 19's <Suspense> + use(promise) for deferred sections that arrive after the shell. The loader resolves critical data; deferred fetches live inside Suspense components and stream in via renderToReadableStream. No router-specific wrapper API needed.
See examples/web/react/ssr-examples/ssr-streaming/ for a complete working example, or the Streaming SSR wiki guide for the design pattern.
Documentation
- ARCHITECTURE.md — Design decisions and data flow
- SSR Example — Full working example (classical, non-streaming)
- SSR Mixed-mode Example — Hybrid pipeline: full SSR + data-only + client-only on the same server, plus the canonical
mutation → invalidate → reloaddogfooding (Home page Refresh button) replicated across all six adapters with pairedhappy path+in-flight defere2e scenarios - Streaming SSR Example — React 19 native streaming with
<Suspense>+use(promise) - Streaming SSR wiki guide
Related Packages
| Package | Description |
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| @real-router/core | Core router (required peer dependency) |
| @real-router/rsc-server-plugin | Sibling plugin — same start() interceptor pattern but for ReactNode (RSC payload). Runs side-by-side on the same router with distinct namespaces (data vs rsc). |
| @real-router/browser-plugin | Browser History API integration |
| @real-router/react | React bindings |
