-
-
Notifications
You must be signed in to change notification settings - Fork 0
RSC Integration
React Server Components (RSC) split your component tree across two runtimes — Server Components run on the server with react-server condition (no hooks, no client state), Client Components run in the browser. Real-router's role in this picture is the same as in classic SSR: map URLs to state. Everything else — bundling, Flight rendering, HTML streaming — is a bundler concern.
This guide explains how the pieces fit together, why the architecture looks the way it does, and how to wire up an end-to-end RSC application using @real-router/rsc-server-plugin plus a Vite-native RSC bundler (@vitejs/plugin-rsc). The complete reference implementation lives in examples/web/react/ssr-rsc/.
Real-router treats RSC the same way it treats any other server-resolved data: a plugin claims a context namespace, intercepts start(), runs a loader, writes the resolved value to state.context.<namespace>. For SSR data the namespace is "data" and the value is plain JSON; for RSC the namespace is "rsc" and the value is a ReactNode tree.
The router never imports react-server-dom-*. The plugin never invokes renderToReadableStream. Both stay bundler-agnostic — you pick the Flight renderer (@vitejs/plugin-rsc/rsc, react-server-dom-webpack/server.edge, react-server-dom-turbopack/server, react-server-dom-parcel/server) and pipe the published ReactNode through it. This mirrors the position taken by React Router 7 (unstable_RSCStaticRouter) and TanStack Start (renderServerComponent).
The result: a 1-2 KB plugin, no bundler lock-in, and the same router contract you already use for client routing.
An RSC application has three runtimes that need to agree on what to render:
| Environment | Conditions | Owns |
|---|---|---|
rsc |
react-server, import, node, module
|
Server Components, loaders, state.context.rsc
|
ssr |
import, node, module
|
HTML render via renderToReadableStream
|
client |
import, browser, module
|
Hydration + client-side navigation |
A bundler like @vitejs/plugin-rsc creates all three with one config and provides a loadModule bridge between them. The rsc env owns the route → ReactNode dispatch (that's where this plugin runs); the ssr env owns HTML rendering (where renderToReadableStream consumes the Flight stream); the client env owns hydration.
Real-router lives in all three. The same createAppRouter() factory is called in each environment, and cloneRouter() produces per-request instances on the server. Routes, guards, plugins — identical contract everywhere.
Every RSC application needs to handle two kinds of requests:
| Request | Response | Used for |
|---|---|---|
GET /:path (HTML) |
streamed HTML + inline Flight chunks | initial page load, deep links, SEO, no-JS fallback |
GET /__rsc?route=... |
pure Flight stream (text/x-component) |
client-side navigation after hydration, revalidation, refresh |
Both endpoints share the same per-route resolution logic — cloneRouter → usePlugin(rscServerPluginFactory) → await router.start(url) → state.context.rsc populated. The only difference is what wraps the resulting ReactNode:
-
HTML branch — render Flight + render HTML in parallel, inject Flight chunks inline as
<script>self.__FLIGHT_DATA.push(...)</script>so the client can pick them up without a second round trip -
/__rscbranch — return the Flight stream directly withContent-Type: text/x-component
Putting both branches in the same fetch handler (rather than splitting them across Express routes) keeps the framework integration minimal — Express becomes a thin Web↔Node bridge that forwards every request to one entry module.
The plugin requires Vite environments. A minimal vite.config.ts:
import rsc from "@vitejs/plugin-rsc";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
rsc({
// Express owns request routing — disable plugin's default middleware
serverHandler: false,
entries: {
client: "./src/entry.browser.tsx", // hydrateRoot
ssr: "./src/entry.ssr.tsx", // exports renderHTML(rscStream, opts)
rsc: "./src/entry.rsc.tsx", // default { fetch } — owns request routing
},
}),
],
});serverHandler: false is what lets you keep an Express app in front of the plugin — without it, the plugin auto-registers its own dev middleware and competes with Express for request handling.
This is the single source of request-routing truth. It runs in the rsc environment with the react-server condition, owns the per-request cloneRouter, and decides whether to return a Flight stream directly or delegate to the SSR environment for HTML wrapping.
import { UNKNOWN_ROUTE } 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 { renderToReadableStream as renderRscToReadableStream } from "@vitejs/plugin-rsc/rsc";
import { createAppRouter } from "./router/createAppRouter";
import { loaders } from "./router/loaders";
import { db } from "./db";
const baseRouter = createAppRouter();
async function handler(request: Request): Promise<Response> {
const url = new URL(request.url);
// Decide which router state to resolve
const pathname =
url.pathname === "/__rsc"
? (url.searchParams.get("route") ?? "/")
: url.pathname + url.search;
const router = cloneRouter(baseRouter, { db });
router.usePlugin(rscServerPluginFactory(loaders));
try {
const state = await router.start(pathname);
const statusCode = state.name === UNKNOWN_ROUTE ? 404 : 200;
const rscNode =
state.context.rsc ?? <p data-testid="not-found">Not Found</p>;
const flightStream = renderRscToReadableStream(rscNode);
// Branch 1: client navigation → pure Flight
if (url.pathname === "/__rsc") {
return new Response(flightStream, {
status: statusCode,
headers: { "Content-Type": "text/x-component" },
});
}
// Branch 2: HTML request → cross-env bridge to entry.ssr
const ssr = await import.meta.viteRsc.loadModule<
typeof import("./entry.ssr")
>("ssr", "index");
return ssr.renderHTML(flightStream, {
ssrState: serializeRouterState(state, { excludeContext: ["rsc"] }),
statusCode,
});
} finally {
router.dispose();
}
}
export default { fetch: handler };Three things to call out:
-
cloneRouterper request, never reuse — guarantees state isolation under concurrent load. The example's e2e suite verifies this with 10 concurrent/users/{0..9}requests. -
serializeRouterState(state, { excludeContext: ["rsc"] })—state.context.rscis a ReactNode tree containing functions and symbols; it cannot be JSON-encoded. TheexcludeContextoption (added in core for this purpose) strips the namespace before transport. Without it,JSON.stringifywould crash or emit garbage. -
import.meta.viteRsc.loadModule('ssr', 'index')— the official cross-env bridge. Server Components only resolve correctly under thereact-servercondition (i.e., in therscenv); cross-importing them from thessrenv would yield the common React build and produce a broken Flight render.loadModuleis dev-mode-aware and turns into a static import in production builds.
Lives in the ssr env (no react-server condition — full React DOM is available), exports a single renderHTML function that consumes a Flight stream and produces an HTML stream.
import { hydrateRouter } from "@real-router/core/utils";
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
import { renderToReadableStream } from "react-dom/server.edge";
import { injectRSCPayload } from "rsc-html-stream/server";
import type { ReactNode } from "react";
import { App } from "./App";
import { createAppRouter } from "./router/createAppRouter";
export async function renderHTML(
rscStream: ReadableStream<Uint8Array>,
{ ssrState, statusCode }: { ssrState: string; statusCode: number },
): Promise<Response> {
// tee() — one branch desuspends Server Components for HTML render,
// the other gets inline-injected into the HTML stream
const [flightForSsr, flightForBrowser] = rscStream.tee();
const payload = createFromReadableStream<ReactNode>(flightForSsr);
// Lightweight router instance for <RouterProvider> — no plugin, no DI.
// The actual rendered tree comes from `payload`, not from the router state.
const router = createAppRouter();
await hydrateRouter(router, ssrState);
const clientBootstrap =
await import.meta.viteRsc.loadBootstrapScriptContent("index");
const htmlStream = await renderToReadableStream(
<App router={router} payload={payload} />,
{
// __SSR_STATE__ travels alongside the bootstrap import — both inline.
bootstrapScriptContent: `window.__SSR_STATE__=${ssrState};\n${clientBootstrap}`,
},
);
const finalStream = htmlStream.pipeThrough(injectRSCPayload(flightForBrowser));
router.dispose();
return new Response(finalStream, {
status: statusCode,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}A few non-obvious details:
-
rscStream.tee()— split once on the server. The plugin-rsc/ssrflavor ofcreateFromReadableStreamdesuspends Server Components for the HTML pass; the other branch streams chunks for inline injection so the client doesn't have to round-trip for them. -
React.use(payload)is inApp.tsx— see below.entry.ssr.tsxonly sets up the streams and renders<App>;Appitself owns the Flight desuspend. -
loadBootstrapScriptContent("index")— resolves the client entry's hashed asset path (/assets/index-XXX.js) in production; in dev it returns the source path. Without it, you'd hard-code/src/entry.browser.tsxwhich only exists in dev. -
router.dispose()afterawait renderToReadableStream— safe becauserenderToReadableStreamwaits for the shell to be ready (which, withuse(payload)at the App level, means the entire Server Component tree has desuspended).
Runs in the client env. Reuses the same Flight chunks the server inlined.
import { browserPluginFactory } from "@real-router/browser-plugin";
import { hydrateRouter } from "@real-router/core/utils";
import { createFromReadableStream } from "@vitejs/plugin-rsc/browser";
import { hydrateRoot } from "react-dom/client";
import { rscStream } from "rsc-html-stream/client";
import type { ReactNode } from "react";
import { App } from "./App";
import { createAppRouter } from "./router/createAppRouter";
declare global {
interface Window {
__SSR_STATE__?: { path: string };
}
}
const router = createAppRouter();
router.usePlugin(browserPluginFactory());
const ssrState = window.__SSR_STATE__;
if (ssrState) {
await hydrateRouter(router, ssrState);
} else {
await router.start();
}
const initialPayload = createFromReadableStream<ReactNode>(rscStream);
hydrateRoot(document, <App router={router} payload={initialPayload} />);rscStream from rsc-html-stream/client is a pre-assembled ReadableStream — the package monkey-patches self.__FLIGHT_DATA.push so each inline Flight chunk drains into the stream automatically. No glue code on your side.
hydrateRouter synchronously rebuilds router state from the serialized JSON before hydrateRoot runs — the router has the correct state by the time React starts rendering.
Both SSR and client passes render the same <App> component. Marked 'use client' so it can use hooks (useState, useEffect), but the SSR pass renders a static tree (no effects fire until hydration). This is the standard RSC pattern with hydrateRoot(document, <App />) — Server Components can manage <html> / <head> / <body> content via the React 19 <title> / <meta> hoisting.
"use client";
import type { Router } from "@real-router/core";
import { createFromReadableStream } from "@vitejs/plugin-rsc/browser";
import type { ReactNode } from "react";
import { startTransition, use, useEffect, useState } from "react";
import { Layout } from "./client-components/Layout";
interface AppProps {
router: Router;
payload: Promise<ReactNode>;
}
export function App({ router, payload }: AppProps) {
const initialNode = use(payload);
const [node, setNode] = useState<ReactNode>(initialNode);
// Single source of truth for /__rsc re-fetches.
// Both <Link> clicks and `router.navigate({ reload: true })` flow through here.
useEffect(() => {
const unsubscribe = router.subscribe(({ route }) => {
fetch(`/__rsc?route=${encodeURIComponent(route.path)}`)
.then((response) => {
if (!response.body) throw new Error("RSC response missing body");
return createFromReadableStream<ReactNode>(response.body);
})
.then((newNode) => {
startTransition(() => setNode(newNode));
})
.catch((error: unknown) => {
console.error("[App] /__rsc fetch failed:", error);
});
});
return unsubscribe;
}, [router]);
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<title>real-router RSC example</title>
</head>
<body>
<div id="root">
<Layout router={router}>{node}</Layout>
</div>
</body>
</html>
);
}Subscribing to router.subscribe in one place is what avoids the classic dual-pathway race — every navigation (Link clicks, programmatic navigate, revalidation) commits router state first, then the listener observes the new state and fetches /__rsc. There's no separate code path that does its own fetch and conflicts with the listener.
Revalidation after a server-side mutation (e.g., a Server Action wrote to the database) reuses the existing NavigationOptions.reload flag — no separate API:
"use client";
import { useRouter } from "@real-router/react";
export function RevalidateButton() {
const router = useRouter();
return (
<button
onClick={() => {
const state = router.getState();
if (state) {
// reload: true bypasses SAME_STATES guard, emits TRANSITION_SUCCESS,
// which triggers App's router.subscribe listener — same fetch path
// as a Link click.
void router.navigate(state.name, state.params, { reload: true });
}
}}
>
Revalidate
</button>
);
}Because the rsc-server-plugin interceptor is registered on start() (not navigate()), the revalidation contract is: a fresh cloneRouter per request on the server, with usePlugin(rscServerPluginFactory(loaders)), then await router.start(newUrl). Each /__rsc fetch goes through that same recipe — no caching, no client-side stale-while-revalidate, the freshness guarantee comes from the request lifecycle itself.
The two plugins claim different namespaces ("data" vs "rsc") and run side-by-side on the same router. Use ssr-data-plugin for plain JSON state that hydrates into client React (theme, locale, feature flags); use rsc-server-plugin for the rendered Server Component tree.
router.usePlugin(
ssrDataPluginFactory({
"users.profile": () => async (params) => ({
preferences: await prefs.get(params.id),
}),
}),
rscServerPluginFactory({
"users.profile": () => async (params) => {
const user = await db.users.findById(params.id);
return <UserProfile user={user} />;
},
}),
);
const state = await router.start("/users/42");
state.context.data; // { preferences: {...} } — JSON, hydrate-safe
state.context.rsc; // <UserProfile user={...} /> — Flight-stream
const ssrJson = serializeRouterState(state, { excludeContext: ["rsc"] });
const flight = renderToReadableStream(state.context.rsc);excludeContext: ["rsc"] strips only the RSC namespace; the JSON payload keeps state.context.data.
@vitejs/plugin-rsc ships the bundler-level pieces for Server Actions
(the 'use server' boundary, decodeAction / decodeReply /
loadServerAction / decodeFormState). The plugin pairs that with a
second factory — rscActionPluginFactory(getResult) — that
publishes the action result to state.context.rscAction so it becomes
part of router state on the same start() call as the per-route
ReactNode.
Why a separate factory? Action results are produced outside the
loader pipeline (typically in the request fetch handler, before the
router exists for that request). They have no per-route mapping, so
they don't fit RscLoaderFactoryMap. Closing over a let actionResult
in the request handler is the natural API.
import {
buildRscPayload,
rscActionPluginFactory,
rscServerPluginFactory,
type RscActionResult,
} from "@real-router/rsc-server-plugin";
import {
decodeAction,
decodeFormState,
decodeReply,
loadServerAction,
} from "@vitejs/plugin-rsc/rsc";
import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc";
export async function handleRsc(request: Request): Promise<Response> {
let actionResult: RscActionResult | undefined;
if (request.method === "POST") {
const isFormRequest = request.headers
.get("content-type")
?.includes("multipart/form-data");
if (isFormRequest) {
// 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);
// buildRscPayload reads state.context.rsc + state.context.rscAction
// and omits returnValue/formState when undefined — type-safe under
// exactOptionalPropertyTypes.
const flight = renderToReadableStream(buildRscPayload(state));
router.dispose();
return new Response(flight, {
headers: { "Content-Type": "text/x-component" },
});
}Key points:
-
getResultis validated at factory time as a function (eager fail before the namespace is claimed); its return value is validated per start to beundefinedor a plain object — non-Promise, non-array, non-primitive. The most common consumer mistake is wiring anasyncgetResult; the runtime guard surfaces that as a typed error pointing back at the call site. - Returning
undefinedskips the write — useful for GET requests where there's no action to surface.state.context.rscActionstaysundefined. - The action result is JSON-friendly (no
ReactNode), so it serialises viaserializeRouterState(state)without needingexcludeContext— unless the result contains server-only secrets, in which case passexcludeContext: ["rsc", "rscAction"]. - Plugin order doesn't affect outcome —
rscandrscActionare independent namespaces.
For the full reference of RscActionResult<TReturn, TFormState>,
RscPayload<TReturn, TFormState>, and buildRscPayload(state, rootOverride?)
see the plugin page.
cloneRouter() produces a router with its own contextClaimRecords — claims, interceptors, and state are per-instance. Concurrent requests cannot leak state.context.rsc across each other. The plugin's stress suite verifies this with 500 concurrent cloneRouter + start + dispose cycles; the dogfooding example's e2e covers the HTTP-level case with 10 concurrent /users/{0..9} requests.
-
Server Action transport —
decodeAction/decodeReply/loadServerAction/decodeFormStatecome from@vitejs/plugin-rsc(or the equivalent in your bundler) and stay outside the router. The plugin's contribution isrscActionPluginFactory— see the Server Actions section for the threading recipe that turns the action's return value into part of router state. -
<Activity>API with RSC streaming — out of scope. If you want React 19's keep-alive / activity coordination with RSC, that's a future RFC. -
react-servercondition for@real-router/react— not needed for this plugin. The current setup keeps RouterProvider and hooks in the'use client'boundary. - CSR-only mounts without HTML SSR — the example assumes the standard SSR + RSC + hydration flow. Pure CSR with RSC is a future variant.
The complete working application — Express server, dev/prod modes, three Server Components, Client Components for navigation and revalidation, 5-scenario Playwright e2e suite — lives at:
examples/web/react/ssr-examples/ssr-rsc/
Read that example top-to-bottom before starting your own RSC app. It's deliberately small (~500 LOC including tests) and every architectural decision is annotated.
- @real-router/rsc-server-plugin — full plugin reference (config, gotchas, type signatures)
- @real-router/ssr-data-plugin — sibling plugin for plain JSON
-
Server-Side Rendering — classical SSR (no RSC) primitives —
cloneRouter,start,dispose -
SSR Hydration —
serializeRouterState+hydrateRouterlifecycle, includingexcludeContext - Data Loading — overall data-loading patterns (router + store + UI) for non-RSC apps
-
Plugin Architecture —
claimContextNamespace,addInterceptor - @vitejs/plugin-rsc — multi-environment RSC bundler used in the example
- rsc-html-stream — Flight injection library (server + client)
- 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