-
-
Notifications
You must be signed in to change notification settings - Fork 1
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 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.
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).
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.
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.
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.
The "rsc" namespace is exclusive (collision detection in claimContextNamespace). Registering two rscServerPluginFactory plugins on the same router throws RouterError(CONTEXT_NAMESPACE_ALREADY_CLAIMED).
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.
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).
- 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)
- subscribeChanges (Routes Mutation Event)
- 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