Skip to content

rsc server plugin

olegivanov edited this page May 18, 2026 · 4 revisions

@real-router/rsc-server-plugin

1. Overview

  • 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 resolved ReactNode available via state.context.rsc before 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/:id before 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: data vs rsc)

2. Why a sibling of ssr-data-plugin?

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

3. Installation and Setup

npm install @real-router/rsc-server-plugin
# or
pnpm add @real-router/rsc-server-plugin

Peer 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();

4. Configuration

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

4a. Per-route SSR mode

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.

5. Serialization

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

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

6. Why ReactNode, not Flight bytes?

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-agnosticreact-server-dom-{webpack,turbopack,parcel,esm} have incompatible renderToReadableStream signatures (different manifest arguments, different stream types). The caller picks the right one.
  • Aligned with industry — both React Router 7 unstable_RSCStaticRouter and TanStack Start renderServerComponent use the same model.

The Flight render itself is one line at the call site:

const flight = renderToReadableStream(state.context.rsc);

7. SSR-Only by Design

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

8. Revalidation with invalidate(router, "rsc")

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.

8a. Cancellation-aware loaders

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.

9. Server Actions with rscActionPluginFactory

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:

  • getResult must be a function — validated at factory time. Passing null/undefined/non-function throws TypeError synchronously (consistent with rscServerPluginFactory(loaders) validation).
  • getResult() is invoked once per start(), after await next(path), before the caller reads state. Returning undefined skips the write — state.context.rscAction stays undefined.
  • 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 (setServerCallbackloadServerActiondecodeReply). Threaded into useActionState on the client.
    • formState — set by progressive enhancement (<form action={fn}> POST without JS) via decodeAction(formData) + decodeFormState(...).
  • Coexists with rscServerPluginFactory on the same router (distinct namespaces). Plugin order does not affect outcome.
  • Double-registration on the same router throws RouterError(CONTEXT_NAMESPACE_ALREADY_CLAIMED).

9a. buildRscPayload(state, rootOverride?) — wire-format helper

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

10. Typed loader errors and withTimeout

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

11. Post-hydration loader skip (#596)

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

12. Per-request Isolation

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.

13. Gotchas

Subscribers see state.context.rsc === undefined

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.

Serialization requires excludeContext: ["rsc"]

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.

One rscServerPluginFactory (and one rscActionPluginFactory) per router

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

getResult is validated at factory time

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.

Loader errors propagate

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(router, "rsc") is fire-and-forget

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

Post-hydration scratchpad rarely fires for RSC

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.

Coverage report appears empty in stdout

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

14. End-to-end integration

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.

15. Related

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

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

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally