Skip to content

RSC Integration

olegivanov edited this page May 10, 2026 · 3 revisions

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


Why a Plugin, Not a Framework

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.


The Mental Model

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.


The Two-Endpoint Architecture

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 — cloneRouterusePlugin(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
  • /__rsc branch — return the Flight stream directly with Content-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.


Server Setup with @vitejs/plugin-rsc

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.


The RSC Entry Point — entry.rsc.tsx

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:

  • cloneRouter per 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.rsc is a ReactNode tree containing functions and symbols; it cannot be JSON-encoded. The excludeContext option (added in core for this purpose) strips the namespace before transport. Without it, JSON.stringify would crash or emit garbage.
  • import.meta.viteRsc.loadModule('ssr', 'index') — the official cross-env bridge. Server Components only resolve correctly under the react-server condition (i.e., in the rsc env); cross-importing them from the ssr env would yield the common React build and produce a broken Flight render. loadModule is dev-mode-aware and turns into a static import in production builds.

The HTML Renderer — entry.ssr.tsx

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 /ssr flavor of createFromReadableStream desuspends 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 in App.tsx — see below. entry.ssr.tsx only sets up the streams and renders <App>; App itself 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.tsx which only exists in dev.
  • router.dispose() after await renderToReadableStream — safe because renderToReadableStream waits for the shell to be ready (which, with use(payload) at the App level, means the entire Server Component tree has desuspended).

The Client Mount — entry.browser.tsx

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.


Single Root, Single Source of Truth — App.tsx

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 Without a Re-render

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.


Mixing with @real-router/ssr-data-plugin

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.


Server Actions via rscActionPluginFactory

@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 factoryrscActionPluginFactory(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:

  • getResult is validated at factory time as a function (eager fail before the namespace is claimed); its return value is validated per start to be undefined or a plain object — non-Promise, non-array, non-primitive. The most common consumer mistake is wiring an async getResult; the runtime guard surfaces that as a typed error pointing back at the call site.
  • Returning undefined skips the write — useful for GET requests where there's no action to surface. state.context.rscAction stays undefined.
  • The action result is JSON-friendly (no ReactNode), so it serialises via serializeRouterState(state) without needing excludeContext — unless the result contains server-only secrets, in which case pass excludeContext: ["rsc", "rscAction"].
  • Plugin order doesn't affect outcome — rsc and rscAction are independent namespaces.

For the full reference of RscActionResult<TReturn, TFormState>, RscPayload<TReturn, TFormState>, and buildRscPayload(state, rootOverride?) see the plugin page.


Per-Request Isolation, By Construction

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.


What This Doesn't Cover

  • Server Action transportdecodeAction / decodeReply / loadServerAction / decodeFormState come from @vitejs/plugin-rsc (or the equivalent in your bundler) and stay outside the router. The plugin's contribution is rscActionPluginFactory — 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-server condition 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.

Reference Implementation

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.


See Also

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