@real-router/rsc-server-plugin
v0.2.3
Published
RSC per-route ReactNode loading plugin for Real-Router (server-side, bundler-agnostic)
Downloads
395
Readme
@real-router/rsc-server-plugin
Per-route
ReactNode(RSC payload) loading for Real-Router. Interceptsstart()to load Server Components before Flight rendering. Bundler-agnostic — the plugin never imports a Flight renderer; the caller picks one of@vitejs/plugin-rsc,react-server-dom-webpack,react-server-dom-turbopack, orreact-server-dom-parcel. Examples in this README and in the wiki use the Vite import path (@vitejs/plugin-rsc/rsc); other bundlers expose the samerenderToReadableStreamshape under their own paths (react-server-dom-webpack/server.edge,react-server-dom-turbopack/server,react-server-dom-parcel/server) — swap the import, keep the call site.
// Without plugin: manual per-route Server Component dispatch
const state = await router.start(url);
const node = await getNodeForRoute(state.name, state.params); // manual
// With plugin:
router.usePlugin(rscServerPluginFactory(loaders));
const state = await router.start(url);
const node = state.context.rsc; // resolved automaticallyInstallation
npm install @real-router/rsc-server-pluginPeer dependencies: @real-router/core, react (>=19.0.0). No bundler dependency — the caller picks the Flight renderer.
Quick Start
import { createRouter } from "@real-router/core";
import { cloneRouter } from "@real-router/core/api";
import { serializeRouterState } from "@real-router/core/utils";
import { rscServerPluginFactory } from "@real-router/rsc-server-plugin";
import type { RscLoaderFactoryMap } from "@real-router/rsc-server-plugin";
import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc";
const loaders: RscLoaderFactoryMap = {
"users.profile": () => async (params) => {
const user = await fetchUser(params.id);
return <UserProfile user={user} />;
},
home: () => () => <HomePage />,
};
const baseRouter = createRouter(routes, { defaultRoute: "home", allowNotFound: true });
// Per-request SSR
const router = cloneRouter(baseRouter, { db: requestDb });
router.usePlugin(rscServerPluginFactory(loaders));
const state = await router.start(req.url);
// 1) Pipe RSC Flight payload (the bundler-specific renderer is *yours*)
if (state.context.rsc) {
const flightStream = renderToReadableStream(state.context.rsc);
// … pipe to HTTP response or inline-inject into HTML
}
// 2) Serialize state for client hydration — strip "rsc" (not JSON-serializable)
const ssrState = serializeRouterState(state, { excludeContext: ["rsc"] });
router.dispose();Configuration
Loaders are keyed by route name (not path). Each value is a factory function (router, getDependency) => loaderFn returning the compiled loader. The factory runs once at plugin registration; the returned loader is cached.
import type { RscLoaderFactoryMap } from "@real-router/rsc-server-plugin";
const loaders: RscLoaderFactoryMap = {
home: () => () => <HomePage />, // sync ReactNode
"users.profile": () => async (params) => { // async ReactNode
const user = await fetchUser(params.id);
return <UserProfile user={user} />;
},
"posts.list": (_router, getDep) => async () => { // DI via getDependency
const db = getDep("db");
const posts = await db.posts.findAll();
return <PostsList posts={posts} />;
},
};Routes without a matching entry leave state.context.rsc as undefined and getSsrRscMode(state) falls back to "full".
Per-route SSR mode
rsc-server-plugin accepts the same { ssr?, loader? } shape as ssr-data-plugin, but with a strict subset of SsrMode: only "full" and "client-only" are allowed. Passing "data-only" (RSC has no semantically meaningful "data without component") throws at factory time.
const loaders: RscLoaderFactoryMap = {
home: () => () => <HomePage />, // short form, defaults to "full"
"admin.dashboard": { ssr: false }, // false → "client-only"
"docs.detail": {
ssr: (state) => state.params.format === "pdf" ? "client-only" : "full",
loader: () => () => <Doc />,
},
};| ssr value | mode marker | loader behaviour |
| ---------------------------- | ----------------- | ------------------------- |
| omitted / true / "full" | "full" | runs (composes with #596) |
| false / "client-only" | "client-only" | skipped unconditionally |
| (state) => RscSsrMode | resolver result | resolved per-navigation |
Read the resolved mode via getSsrRscMode(state) (returns "full" for routes without an entry):
import { getSsrRscMode } from "@real-router/rsc-server-plugin";
const mode = getSsrRscMode(state); // RscSsrMode = "full" | "client-only"
if (mode === "full") {
const flight = renderToReadableStream(buildRscPayload(state));
// … pipe Flight + SSR HTML
}
// mode === "client-only" → no Server Component was rendered server-sideWhy ReactNode, not Flight bytes?
The plugin publishes a ReactNode, not a pre-rendered Flight Uint8Array. This keeps the plugin:
- Bundler-agnostic —
react-server-dom-{webpack,turbopack,parcel,esm}have incompatiblerenderToReadableStreamsignatures; the caller picks the right one - Streaming-friendly — Flight rendering happens out-of-band, in parallel with HTML SSR
- Aligned with industry — both React Router 7 (
unstable_RSCStaticRouter) and TanStack Start (renderServerComponent) use the same model
The Flight render itself is one line:
const flight = renderToReadableStream(state.context.rsc);Serialization
state.context.rsc is a ReactNode tree (functions, symbols) and cannot be JSON-serialized. Use serializeRouterState's excludeContext option to strip it before client transport:
import { serializeRouterState } from "@real-router/core/utils";
const ssrJson = serializeRouterState(state, { excludeContext: ["rsc"] });
// JSON contains state.context.data and other namespaces, but not state.context.rscSSR-Only by Design (with explicit CSR revalidation channel)
This plugin intercepts start() only — not navigate(). In SSR, the flow is:
cloneRouter → usePlugin → start(url) → ReactNode resolved → state.context.rsc
↓
renderToReadableStream(node)
↓
Flight stream → HTTPClient-side navigation does not re-run the RSC loader by default — application-layer fetching (React Query, Suspense, RSC /__rsc endpoint) owns CSR data. The one explicit exception is the invalidate() revalidation channel below.
Client-side revalidation (invalidate)
After a mutation, mark the "rsc" namespace stale on the router. The next navigation (including a same-route reload) re-runs the RSC loader for the destination route and overwrites state.context.rsc before TRANSITION_SUCCESS fires — so subscribers see the fresh ReactNode.
import { invalidate } from "@real-router/rsc-server-plugin";
// Fire-and-forget — stale until the user navigates somewhere.
invalidate(router, "rsc");
// Explicit await — pair with a same-route reload.
invalidate(router, "rsc");
await router.navigate(state.name, state.params, { reload: true });The flag is preserved until a successful, non-cancelled loader write. So a navigation that lands on a route without an 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. A loader rejection also leaves the flag set — retry re-runs the loader.
Idempotent — multiple invalidate() calls between refreshes collapse to one re-run. Surgical for multi-namespace routes — only "rsc" re-runs; a side-by-side @real-router/ssr-data-plugin keeps its cached state.context.data unless its own invalidate() was also called.
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 (DB query, RSC stream, …) when a newer navigation supersedes:
"users.profile": (_router, getDep) => async (params, ctx) => {
const db = getDep("db");
const user = await db.users.findById(params.id, { signal: ctx?.signal });
return <UserProfile user={user} />;
},The start interceptor calls the loader without a context. Robust loaders check signal.aborted upfront — a signal aborted before addEventListener("abort", …) does NOT auto-fire the listener.
Non-breaking via TypeScript contravariance — existing (params) => … loaders continue to compile and work unchanged.
Post-hydration loader skip
When the application uses hydrateRouter() from @real-router/core/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.rsc is already present for the same route name — skipping the redundant client-side ReactNode resolution on first paint.
In practice, RSC apps usually excludeContext: ["rsc"] from the JSON payload (a ReactNode tree contains functions/symbols and isn't JSON-serializable). In that case the scratchpad has no rsc namespace and the loader runs as today. The skip path matters when the bundler-specific Flight pipeline arranges to thread an already-resolved ReactNode through hydration.
The skip is single-shot — only the first start() triggered by hydrateRouter consumes the scratchpad. Composes with per-route mode: "client-only" skips the loader regardless of scratchpad contents (mode wins).
Typed Loader Errors (@real-router/rsc-server-plugin/errors)
Mirror of @real-router/ssr-data-plugin/errors — same shared source under shared/ssr/errors.ts. RSC apps can import error classes without adding ssr-data-plugin as a dependency:
import {
LoaderNotFound,
LoaderRedirect,
} from "@real-router/rsc-server-plugin/errors";
const loaders: RscLoaderFactoryMap = {
"users.profile": (_router, getDep) => async (params) => {
const user = await getDep("db").users.findById(params.id);
if (!user) throw new LoaderNotFound(`user:${params.id}`);
return <UserProfile user={user} />;
},
};
// In the RSC fetch handler:
try {
const state = await router.start(pathname);
return new Response(renderToReadableStream(buildRscPayload(state)));
} catch (error) {
if (error?.code === "LOADER_NOT_FOUND") {
return new Response("Not Found", { status: 404 });
}
throw error;
}LoaderNotFound, LoaderRedirect, LoaderTimeout, withTimeout — same shape and structural code discriminator as the data-plugin counterparts.
Cleanup
const unsubscribe = router.usePlugin(rscServerPluginFactory(loaders));
// Later — releases "rsc" namespace claim and stops the start interceptor
unsubscribe();In SSR, router.dispose() handles cleanup automatically.
Server Actions (rscActionPluginFactory)
For RSC apps that ship Server Actions, this package also exports a second factory — rscActionPluginFactory(getResult) — that publishes the action result (returnValue / formState) to state.context.rscAction. It claims a separate "rscAction" namespace, so it composes with rscServerPluginFactory and ssr-data-plugin on the same router. Action results are produced outside the loader pipeline (typically in the request fetch handler, before the router exists for that request), so they're surfaced via a closure-captured resolver rather than a per-route map.
import {
buildRscPayload,
rscActionPluginFactory,
rscServerPluginFactory,
type RscActionResult,
} from "@real-router/rsc-server-plugin";
// Vite path — swap for `react-server-dom-{webpack,turbopack,parcel}/server.*`
// when you use a different bundler. The plugin itself imports nothing here.
import {
decodeAction,
decodeFormState,
decodeReply,
loadServerAction,
renderToReadableStream,
} from "@vitejs/plugin-rsc/rsc";
let actionResult: RscActionResult | undefined;
if (request.method === "POST") {
const isFormPost = request.headers
.get("content-type")
?.includes("multipart/form-data");
if (isFormPost) {
// Progressive enhancement path — POST without JS.
const formData = await request.formData();
const decoded = await decodeAction(formData);
const result = await decoded();
const formState = await decodeFormState(result, formData);
actionResult = formState ? { formState } : undefined;
} else {
// Hydrated client path — setServerCallback dispatched the call.
const actionId = request.headers.get("rsc-action") ?? "";
const fn = await loadServerAction(actionId);
const args = await decodeReply(await request.text());
actionResult = { returnValue: { ok: true, data: await fn(...args) } };
}
}
const router = cloneRouter(baseRouter, requestDeps);
router.usePlugin(
rscServerPluginFactory(loaders),
rscActionPluginFactory(() => actionResult), // closure captures live mutation
);
const state = await router.start(new URL(request.url).pathname);
const flight = renderToReadableStream(buildRscPayload(state));Rules:
getResultis validated at factory time as a function — a TS-cast bypass that smugglesnull/asyncthrough throwsTypeErrorsynchronously, before the"rscAction"namespace is claimed.- The return value is validated per
start()— must beundefined(skip the write) or a plain object. Arrays, primitives, andPromise/thenables are rejected with a typed message pointing back at the call site. The most common consumer mistake is wiring anasyncgetResult; the runtime guard surfaces that explicitly. state.context.rscActionis JSON-friendly —serializeRouterState(state)works withoutexcludeContext. PassexcludeContext: ["rsc", "rscAction"]only if the result carries server-only secrets you don't want to ship to the client.- The two plugins coexist regardless of registration order; both namespaces are exclusive (double-registration throws
RouterError(CONTEXT_NAMESPACE_ALREADY_CLAIMED)). buildRscPayload(state, rootOverride?)readsstate.context.rsc+state.context.rscActionand returns the canonicalRscPayload<TReturn, TFormState>Flight shape.returnValue/formStateare omitted (not set toundefined) when their source is missing — type-safe underexactOptionalPropertyTypes: true.
For the full integration recipe (HTML + /__rsc endpoints, dev/prod bundler config, Flight injection), see the Wiki: RSC Integration guide.
Example
- examples/web/react/ssr-examples/ssr-rsc — End-to-end dogfooding example: Express +
@vitejs/plugin-rsc+ this plugin, with Flight injection, client navigation via/__rsc?route=…, revalidation, and Server Actions wired throughrscActionPluginFactory(seeentry.rsc.tsx+NotificationBanner.tsx). The Playwright suite covers 27 scenarios including initial HTML load, client nav, revalidation happy path + in-flight defer (Scenarios 3 + 3b), 404 routing, per-request isolation under concurrent load,/__rsccontent-type assertions, loader-driven HTTP status (404/500), search-param flow, browser back/forward, interleaved-click abort, per-route Cache-Control, ETag absence on streamed responses, and the full Server Action lifecycle (form rendering, mutation,useActionStatevalidation errors,NotificationBannercross-component reflection viastate.context.rscAction).RevalidateButtoncallsinvalidate(router, "rsc")for API symmetry — seesrc/client-components/RevalidateButton.tsx.
Documentation
- ARCHITECTURE.md — Design decisions and data flow
- INVARIANTS.md — Property-based invariants
- Wiki: RSC Integration — End-to-end integration guide
Related Packages
| Package | Description |
| ------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| @real-router/core | Core router (required peer dependency) |
| @real-router/ssr-data-plugin | Sibling plugin for plain JSON data (state.context.data) |
| @real-router/react | React bindings |
