-
-
Notifications
You must be signed in to change notification settings - Fork 0
rsc server plugin
- Name: RSC Server Plugin
-
Package:
@real-router/rsc-server-plugin -
Purpose: Per-route React Server Component (RSC) loading during server-side rendering. Intercepts
start()to call a matching loader after route resolution, making the resolvedReactNodeavailable viastate.context.rscbefore the bundler's Flight renderer runs. -
Bundler-agnostic: works with
@vitejs/plugin-rsc,react-server-dom-webpack,react-server-dom-turbopack,react-server-dom-parcel,react-server-dom-esm. The plugin never imports any of these — the caller chooses the renderer. -
Typical scenarios:
- Resolving Server Component for
/users/:idbefore Flight render - Per-route declarative dispatch from URL →
ReactNode - Combining router lifecycle with RSC payload generation in a single async pipeline
- Side-by-side with
@real-router/ssr-data-plugin(each plugin claims a different namespace:datavsrsc)
- Resolving Server Component for
The architecture is a one-to-one mirror of @real-router/ssr-data-plugin with three differences:
| Aspect | ssr-data-plugin |
rsc-server-plugin |
|---|---|---|
| Namespace claim | "data" |
"rsc" |
| Loader return type | Promise<unknown> |
Promise<ReactNode> | ReactNode (sync ok) |
| Generic on factory | only on DataLoaderFactoryMap
|
also on rscServerPluginFactory<Deps>()
|
The reasoning: a plugin is the right layer for per-route data publication via claim.write(). RSC payload is per-route data; the only architectural difference is the value's type (ReactNode instead of plain JSON), which has knock-on effects only at serialization time (see §5).
npm install @real-router/rsc-server-plugin
# or
pnpm add @real-router/rsc-server-pluginPeer dependencies: @real-router/core, react (>=19.0.0). No bundler dependency.
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"; // or react-server-dom-webpack/server.edge, etc.
const loaders: RscLoaderFactoryMap = {
home: () => () => <HomePage />,
"users.profile": () => async (params) => {
const user = await fetchUser(params.id);
return <UserProfile user={user} />;
},
};
// Per-request SSR
const router = cloneRouter(baseRouter, requestDeps);
router.usePlugin(rscServerPluginFactory(loaders));
const state = await router.start(req.url);
// state.context.rsc — ReactNode | undefined
// 1) Pipe Flight payload (renderer is YOURS)
if (state.context.rsc) {
const flightStream = renderToReadableStream(state.context.rsc);
// … write to HTTP response or inject inline into HTML
}
// 2) Serialize state for client hydration — strip "rsc" (not JSON-serializable)
const ssrJson = serializeRouterState(state, { excludeContext: ["rsc"] });
router.dispose();The plugin accepts a single parameter loaders of type RscLoaderFactoryMap. Each value is either a factory function (router, getDependency) => loaderFn (short form) or an { ssr?, loader? } object (long form, see §4a). The factory receives the router instance and a DI getter:
type RscLoaderFn = (
params: Params,
context?: { signal: AbortSignal },
) => Promise<ReactNode> | ReactNode;
type RscLoaderFnFactory<Deps> = (
router: Router<Deps>,
getDependency: <K extends keyof Deps>(key: K) => Deps[K],
) => RscLoaderFn;
type RscRouteEntry<Deps> =
| RscLoaderFnFactory<Deps>
| { ssr?: RscSsrMode | boolean | ((state: State) => RscSsrMode); loader?: RscLoaderFnFactory<Deps> };
type RscLoaderFactoryMap<Deps> = Record<string, RscRouteEntry<Deps>>;The context.signal second argument is supplied by the subscribeLeave revalidation handler (invalidate() → navigate({ reload: true }) path) so cancellation-aware loaders can abort their in-flight work when a newer navigation supersedes. The start interceptor calls the loader without a context — see §8a for the recommended pattern.
Keys are route names (e.g., "users.profile"), not paths. Loaders may return a ReactNode synchronously — Promise.resolve wrapping is not required:
const loaders: RscLoaderFactoryMap = {
home: () => () => <HomePage />, // sync
"users.profile": () => async (params) => { // async
const user = await fetchUser(params.id);
return <UserProfile user={user} />;
},
"posts.list": (_router, getDep) => async () => { // DI
const db = getDep("db");
return <PostsList posts={await db.posts.findAll()} />;
},
};Routes without a matching loader leave state.context.rsc as undefined and getSsrRscMode(state) falls back to "full".
rsc-server-plugin accepts a strict subset of SsrMode: "full" and "client-only". "data-only" is rejected at factory time (RSC has no semantically meaningful "data without component"):
const loaders: RscLoaderFactoryMap = {
home: () => () => <HomePage />, // short form, defaults to "full"
"admin.dashboard": { ssr: false }, // false → "client-only", no loader runs
"users.profile": {
ssr: "full",
loader: () => async (params) => <UserProfile user={await fetchUser(params.id)} />,
},
"docs.detail": { // function-form resolver, per-navigation
ssr: (state) => state.params.format === "pdf" ? "client-only" : "full",
loader: () => () => <Doc />,
},
};
ssr value |
mode marker | loader behaviour |
|---|---|---|
omitted / true / "full"
|
"full" |
runs |
false / "client-only"
|
"client-only" |
skipped unconditionally |
(state) => RscSsrMode |
resolver result | resolved per-navigation |
The mode is published to state.context.ssrRscMode (typed via module augmentation). Read it via getSsrRscMode(state):
import { getSsrRscMode } from "@real-router/rsc-server-plugin";
const mode = getSsrRscMode(state); // "full" | "client-only", "full" if no entry
if (mode === "full") {
const flight = renderToReadableStream(buildRscPayload(state));
// pipe Flight + SSR HTML
}
// mode === "client-only": no Server Component rendered server-side; the
// client requests the Flight stream over a separate /__rsc endpoint.The function-form resolver receives state before the mode is written to context, so resolvers should not read state.context.ssrRscMode. Branch on state.params, state.path, or state.name instead.
state.context.rsc is a ReactNode tree (functions, symbols) and cannot be JSON-serialized. Use serializeRouterState's excludeContext option (added in core for this purpose) 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.rscThe Flight payload travels via the bundler's stream renderer; the router state JSON travels alongside it. Inline-injection into HTML stream is a standard RSC pattern (see e.g. rsc-html-stream).
The plugin publishes a ReactNode, not a pre-rendered Flight Uint8Array. This keeps the plugin:
- Streaming-friendly — Flight rendering happens out-of-band, in parallel with HTML SSR. Storing pre-rendered bytes would block the loader on Flight render.
-
Bundler-agnostic —
react-server-dom-{webpack,turbopack,parcel,esm}have incompatiblerenderToReadableStreamsignatures (different manifest arguments, different stream types). The caller picks the right one. -
Aligned with industry — both React Router 7
unstable_RSCStaticRouterand TanStack StartrenderServerComponentuse the same model.
The Flight render itself is one line at the call site:
const flight = renderToReadableStream(state.context.rsc);Like ssr-data-plugin, 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 → HTTP
For client-side re-fetch on navigation, the application uses a /__rsc?route=… endpoint pattern (caller's responsibility — the plugin runs the same cloneRouter + usePlugin + start recipe per request).
CSR revalidation after a mutation or Server Action is the explicit channel
the plugin opens through a single subscribeLeave listener. Mark the
"rsc" namespace stale, then any 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(currentName, currentParams, { reload: true });The flag is preserved until a successful, non-cancelled loader write.
A navigation that lands on a route without an entry, a client-only
route, a mode-only entry, one cancelled mid-loader (newer navigate()
aborts the older controller), or whose loader rejects all leave the flag
set for the next attempt. Idempotent — multiple invalidate() calls
between refreshes collapse to a single 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.
Server-side per-request flow (separate from the CSR revalidation
channel above) remains the canonical SSR recipe: a fresh cloneRouter
per request, usePlugin(rscServerPluginFactory(loaders)), then
await router.start(url) for the new URL. Each new request gets a
fresh ReactNode without invalidate ever being involved.
The subscribeLeave revalidation handler passes the navigation's
AbortController.signal as the second loader argument so loaders can
abort their in-flight work (DB query, downstream fetch, 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 — SSR
boot path apps that need a request-scoped signal use
cloneRouter(base, { abortSignal }) + getDep("abortSignal") plus
withTimeout({ upstreamSignal }).
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 compile and work unchanged; they just don't observe cancellation.
For RSC apps that ship Server Actions, register
rscActionPluginFactory(getResult) alongside rscServerPluginFactory —
it claims a separate "rscAction" namespace so the action result
(returnValue / formState) becomes part of router state and can be
serialized, inspected, or read by Server Components.
import {
rscActionPluginFactory,
rscServerPluginFactory,
buildRscPayload,
type RscActionResult,
} from "@real-router/rsc-server-plugin";
import { decodeAction, decodeReply, loadServerAction } from "@vitejs/plugin-rsc/rsc";
let actionResult: RscActionResult | undefined;
if (request.method === "POST") {
// … decode + execute action …
actionResult = { returnValue: { ok: true, data: { saved: true } } };
}
const router = cloneRouter(baseRouter, requestDeps);
router.usePlugin(
rscServerPluginFactory(loaders),
rscActionPluginFactory(() => actionResult), // closure captures live mutation
);
const state = await router.start(pathname);
state.context.rsc; // ReactNode — Flight-stream
state.context.rscAction; // RscActionResult — JSON-serializable
const flight = renderToReadableStream(buildRscPayload(state));Rules:
-
getResultmust be a function — validated at factory time. Passingnull/undefined/non-function throwsTypeErrorsynchronously (consistent withrscServerPluginFactory(loaders)validation). -
getResult()is invoked once perstart(), afterawait next(path), before the caller readsstate. Returningundefinedskips the write —state.context.rscActionstaysundefined. - The result type is
RscActionResult<TReturn, TFormState> = { returnValue?: { ok: boolean; data: TReturn }, formState?: TFormState }. Both fields optional; typical flows write one or the other:-
returnValue— set by the hydrated client path (setServerCallback→loadServerAction→decodeReply). Threaded intouseActionStateon the client. -
formState— set by progressive enhancement (<form action={fn}>POST without JS) viadecodeAction(formData)+decodeFormState(...).
-
- Coexists with
rscServerPluginFactoryon the same router (distinct namespaces). Plugin order does not affect outcome. - Double-registration on the same router throws
RouterError(CONTEXT_NAMESPACE_ALREADY_CLAIMED).
Removes the repeated { root, returnValue, formState } boilerplate.
Reads state.context.rsc and state.context.rscAction; returns a
RscPayload<TReturn, TFormState>. returnValue and formState are
omitted (not set to undefined) when their source is missing —
type-checks under exactOptionalPropertyTypes: true.
import { buildRscPayload } from "@real-router/rsc-server-plugin";
// Default — root = state.context.rsc:
const flight = renderToReadableStream(buildRscPayload(state));
// With wrapping override (Server Component composition):
const wrapped = (
<>
<NotificationBanner action={state.context.rscAction} />
{state.context.rsc}
</>
);
const payload = buildRscPayload<MyData, ReactFormState>(state, wrapped);Pass null as rootOverride to render nothing — null is a valid
ReactNode and is preserved as-is, not treated as "fall back to
default" (the implementation uses === undefined rather than ??).
The plugin is HTTP-agnostic — it only awaits the loader and writes the
resulting ReactNode to state.context.rsc. To bridge loader failures
to HTTP semantics (404, 30x, 504), import typed error classes from the
/errors subpath and let your handler catch them. Same shape and
structural code discriminator as the
@real-router/ssr-data-plugin/errors counterparts
(shared source under shared/ssr/errors.ts):
import {
LoaderNotFound,
LoaderRedirect,
LoaderTimeout,
withTimeout,
} from "@real-router/rsc-server-plugin/errors";
const loaders: RscLoaderFactoryMap = {
"users.profile": (_router, getDep) => (params) => {
const upstreamSignal = (
getDep as unknown as (k: string) => AbortSignal | undefined
)("abortSignal");
return withTimeout(
"users.profile",
250,
async ({ signal }) => {
const user = await fetchUser(params.id, { signal });
if (!user) throw new LoaderNotFound(`user:${params.id}`);
return <UserProfile user={user} />;
},
{ upstreamSignal },
);
},
"users.legacy": () => (params) => {
throw new LoaderRedirect(`/users/${params.id}`, 301);
},
};
// 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 });
if (error?.code === "LOADER_REDIRECT") return Response.redirect(error.target, error.status);
if (error?.code === "LOADER_TIMEOUT") return new Response("Timeout", { status: 504 });
throw error;
}withTimeout races the loader against a deadline and passes a composed
AbortSignal so the loader can cancel cooperatively. options.upstreamSignal
composes via AbortSignal.any (Node 20.3+) — typically the request-scoped
signal threaded through cloneRouter(base, { abortSignal }).
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).
cloneRouter() produces a router with its own contextClaimRecords — claims, interceptors, and state are per-instance. Concurrent SSR requests cannot leak state.context.rsc across each other. The package's stress suite verifies this with 500 concurrent cloneRouter + start + dispose cycles, plus another 500-cycle composition stress for rscServerPluginFactory + rscActionPluginFactory running side-by-side.
claim.write() happens after await next(path) in the start interceptor. By that time, subscribe() callbacks have already fired with the resolved state. The rsc field is populated only when control returns to the await router.start(url) caller.
JSON-serializing state.context.rsc will crash or produce garbage (functions, symbols inside ReactNode trees). Always pass excludeContext when transporting the router state to the client. state.context.rscAction is JSON-friendly and serializes without ceremony — strip it explicitly only if it carries server-only secrets.
Both "rsc" and "rscAction" namespaces are exclusive (collision detection in claimContextNamespace). Registering two plugins of the same kind on the same router throws RouterError(CONTEXT_NAMESPACE_ALREADY_CLAIMED).
rscActionPluginFactory(getResult) throws TypeError synchronously if getResult isn't a function. The check runs before any namespace is claimed — a bad call site can't poison the router. Mirror of the loaders-map validation in rscServerPluginFactory and ssrDataPluginFactory.
If a loader throws (sync or async), the error propagates through the start() promise — caller's try/catch handles it. Same for getResult in rscActionPluginFactory. With invalidate(...), a loader rejection on the next navigation rejects that navigate() promise and leaves the stale flag set so a retry re-runs the loader. See §10 for HTTP-status mapping via the typed errors.
invalidate() returns void. The flag is consumed in the awaited LEAVE_APPROVE phase of the next navigation. An in-flight transition completes unchanged; the following navigation refreshes. This preserves "one transition = one state.context snapshot". Survives cloneRouter() boundaries — each clone has its own flag set (the registry is WeakMap<Router, Set<string>>).
The plugin honours state.context.rsc from the post-hydration scratchpad (#596) — but RSC apps typically excludeContext: ["rsc"] on the SSR JSON. With rsc stripped, the scratchpad has no entry and the loader runs as today. The skip path matters only when the bundler-specific Flight pipeline arranges to thread an already-resolved ReactNode through hydration. See §11.
Vitest config uses ["text", { skipFull: true }] — files at 100% coverage are omitted from the printed table. An empty % Stmts table means all source files are at 100%, not that coverage is missing. The full report is written to coverage/ (lcov, json, json-summary).
For the full integration recipe — @vitejs/plugin-rsc setup, two-endpoint architecture (HTML + /__rsc), Flight injection via rsc-html-stream, client mount, and revalidation pattern — see the RSC Integration guide.
The reference implementation is examples/web/react/ssr-examples/ssr-rsc/: Express server with dev/prod modes, three Server Components, Client Components for navigation and revalidation, plus a 27-scenario Playwright e2e suite spanning initial HTML load, client navigation, revalidation roundtrip (happy path + in-flight defer), 404 routing, per-request isolation under concurrent load, /__rsc Flight content-type, 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 → validation errors → NotificationBanner reflection via state.context.rscAction). The example wires RevalidateButton to invalidate(router, "rsc") and demonstrates the cloneRouter + usePlugin + start recipe per request.
- Data Loading — overall data-loading patterns (router + store + UI)
- @real-router/ssr-data-plugin — sibling plugin for plain JSON data
-
SSR Hydration —
serializeRouterState+hydrateRouterlifecycle -
Plugin Architecture —
claimContextNamespace,addInterceptor
- 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)
- 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