Skip to content

rsc server plugin

olegivanov edited this page May 2, 2026 · 5 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 a factory function (router, getDependency) => loaderFn that receives the router instance and a DI getter:

type RscLoaderFn = (params: Params) => Promise<ReactNode> | ReactNode;
type RscLoaderFnFactory<Deps> = (
  router: Router<Deps>,
  getDependency: <K extends keyof Deps>(key: K) => Deps[K],
) => RscLoaderFn;
type RscLoaderFactoryMap<Deps> = Record<string, RscLoaderFnFactory<Deps>>;

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.

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

Revalidation after Server Actions or stale data is available via the existing NavigationOptions.reload flag:

// Triggers fresh transition pipeline (bypasses SAME_STATES check),
// re-runs all interceptors including rsc-server-plugin's start interceptor
// when used in a per-request `cloneRouter + start(...)` recipe on the server.
router.navigate(currentName, currentParams, { reload: true });

Because the rsc-server-plugin interceptor is registered on start() (not navigate()), the production-grade revalidation pattern is: a fresh cloneRouter per request, usePlugin(rscServerPluginFactory(loaders)), then await router.start(url) for the new URL. Each new request gets a fresh ReactNode.

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

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

One rsc-server-plugin per router

The "rsc" namespace is exclusive (collision detection in claimContextNamespace). Registering two rscServerPluginFactory plugins on the same router throws RouterError(CONTEXT_NAMESPACE_ALREADY_CLAIMED).

Loader errors propagate

If a loader throws (sync or async), the error propagates through the start() promise — caller's try/catch handles it. Same as ssr-data-plugin.

11. 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-rsc/: Express server with dev/prod modes, three Server Components, Client Components for navigation and revalidation, plus a 5-scenario Playwright e2e suite (initial HTML load, client navigation, revalidation roundtrip, 404 handling, per-request isolation under concurrent load).

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