-
-
Notifications
You must be signed in to change notification settings - Fork 1
Streaming SSR
React 19 added native streaming server-side rendering: renderToReadableStream from react-dom/server emits an HTML shell first, then streams <Suspense> boundary content as their promises resolve. The client picks up streamed chunks via inline <script> tags that swap fallback HTML for resolved content — no router-specific transport, no waiting for the slowest data fetch before the first byte is sent.
Real-Router has no streaming-specific API. The router's job is the same as in classical SSR: per-request cloneRouter(), route resolution via start(url), and per-route critical data via @real-router/ssr-data-plugin. Everything streaming-related is React 19 native: renderToReadableStream + <Suspense> + use(promise). This page documents the pattern.
For the alternative — RSC streaming via Flight protocol — see RSC Integration.
A typical streaming SSR page has two kinds of data:
| Kind | Where it lives | When it resolves | Example |
|---|---|---|---|
| Critical |
state.context.data (via ssr-data-plugin loader) |
Before shell renders — await router.start(url)
|
Product name, price, description |
| Deferred | Component-internal useMemo(() => fetch(...))
|
After shell — Suspense streams when promise settles | Reviews, related items, comments |
Critical data blocks shell delivery. Deferred data doesn't. The user sees critical content immediately and watches deferred sections fill in.
t=0ms request received, loader runs
t=10ms critical data resolved, renderToReadableStream returns
t=10ms shell streamed: <article><h1>Product</h1>... + Suspense fallbacks
t=600ms reviews promise resolves → React emits chunk, browser swaps fallback
t=1200ms related promise resolves → React emits chunk, browser swaps fallback
t=1200ms stream closes
The first byte (TTFB) is bounded by critical data resolution time, not by the slowest deferred promise.
Three files, no router-specific streaming wrappers.
import { getProduct } from "../db";
import type { DataLoaderFactoryMap } from "@real-router/ssr-data-plugin";
export const loaders: DataLoaderFactoryMap = {
"products.detail": () => (params) =>
Promise.resolve({
product: getProduct(params.id as string),
}),
};The loader returns sync-resolvable critical data. Heavy data goes inside Suspense components, not into state.context.data.
import { use, useMemo } from "react";
const SERVER_DELAY_MS = 600;
function fetchReviews(productId: string): Promise<Review[]> {
if (typeof globalThis.window === "undefined") {
return new Promise((resolve) => {
setTimeout(() => resolve(mockReviews[productId] ?? []), SERVER_DELAY_MS);
});
}
return Promise.resolve(mockReviews[productId] ?? []);
}
export function Reviews({ productId }: { productId: string }) {
const reviewsPromise = useMemo(() => fetchReviews(productId), [productId]);
const reviews = use(reviewsPromise);
return <section>...</section>;
}Three constraints:
-
Per-render memoized promise via
useMemo. A fresh promise on every server request. AvoidsReact.lazy's singleton cache that would make streaming visible only on the first request. -
Server delay via
setTimeout— without this, promises resolve too fast and React renders content inline (no streaming visible). Real apps don't need this; production data sources have natural latency. -
Client returns
Promise.resolve(value)—use()resolves synchronously on the client, no hydration flash.
import { UNKNOWN_ROUTE } from "@real-router/core";
import { cloneRouter } from "@real-router/core/api";
import { serializeRouterState } from "@real-router/core/utils";
import { RouterProvider } from "@real-router/react";
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
import { renderToReadableStream } from "react-dom/server";
const baseRouter = createAppRouter();
export async function render(url: string) {
const router = cloneRouter(baseRouter);
router.usePlugin(ssrDataPluginFactory(loaders));
const state = await router.start(url);
const stream = await renderToReadableStream(
<RouterProvider router={router}>
<App />
</RouterProvider>,
);
return {
stream,
ssrJson: serializeRouterState(state),
statusCode: state.name === UNKNOWN_ROUTE ? 404 : 200,
cleanup: () => router.dispose(),
};
}await renderToReadableStream(...) returns when the shell is ready (post-use(promise) first-render but pre-Suspense-resolution). The stream emits chunks lazily as each <Suspense> boundary resolves.
app.get("/{*path}", async (req, res) => {
const { stream, ssrJson, statusCode, cleanup } = await module.render(req.originalUrl);
const ssrScript = `<script>window.__SSR_STATE__=${ssrJson}</script>`;
const templateWithState = template.replace("<!--ssr-state-->", ssrScript);
const [headPart, footerPart] = templateWithState.split("<!--ssr-outlet-->");
res.status(statusCode);
res.set("Content-Type", "text/html; charset=utf-8");
res.set("Transfer-Encoding", "chunked");
res.write(headPart);
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
}
res.write(footerPart);
res.end();
cleanup();
});The server splits the HTML template at <!--ssr-outlet-->, writes the head immediately, pipes React's streamed chunks into the body, then writes the footer (closing tags + entry-client.tsx bootstrap script). Browsers begin parsing as bytes arrive.
import { hydrateRouter } from "@real-router/core/utils";
import { RouterProvider } from "@real-router/react";
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
import { hydrateRoot } from "react-dom/client";
const router = createAppRouter();
router.usePlugin(ssrDataPluginFactory(loaders));
const ssrState = window.__SSR_STATE__;
await hydrateRouter(router, ssrState);
hydrateRoot(
document.querySelector("#root")!,
<RouterProvider router={router}>
<App />
</RouterProvider>,
);ssr-data-plugin runs on the client too — hydrateRouter(router, ssrState) calls router.start(state.path) which triggers the loader, repopulating state.context.data with the same critical data the server resolved. Hydration sees identical state.
// loader
export async function loader() {
return defer({
critical: await fetchCritical(),
deferred: fetchDeferred(),
});
}
// component
const data = useLoaderData<typeof loader>();
return (
<>
<Critical data={data.critical} />
<Suspense fallback={<Spinner />}>
<Await resolve={data.deferred}>
{(value) => <Deferred value={value} />}
</Await>
</Suspense>
</>
);defer() is a marker that tells RR7 to wait on critical fields and stream deferred ones. <Await> is a render-prop wrapper around use(promise).
// loader — critical only
export const loaders: DataLoaderFactoryMap = {
"products.detail": () => (params) =>
Promise.resolve({ product: getProduct(params.id as string) }),
};
// component
const { product } = useRouteData();
return (
<>
<Critical product={product} />
<Suspense fallback={<Spinner />}>
<Deferred productId={product.id} />
</Suspense>
</>
);
function Deferred({ productId }: { productId: string }) {
const promise = useMemo(() => fetchDeferred(productId), [productId]);
const value = use(promise);
return <div>{value.title}</div>;
}No defer() marker — split the data manually between loader (critical) and component (deferred). No <Await> wrapper — use(promise) directly inside the component.
The trade-off: RR7 packages everything into one loader return. Real-Router pushes the deferred fetch into the component itself. For data tied to a specific UI section, the component-local pattern co-locates the data lifecycle with the rendering. For data shared across multiple components in the same route, the loader pattern centralizes.
A Vue 3 port of this example lives at examples/web/vue/ssr-examples/ssr-streaming/ — same cloneRouter() per request, same ssrDataPluginFactory(loaders) for critical data, same serializeRouterState + hydrateRouter round-trip. The router-side code is identical; what changes is the framework's streaming primitive.
| React 19 (this page) | Vue 3 (counterpart) | |
|---|---|---|
| Streaming primitive | renderToReadableStream |
vue/server-renderer.renderToWebStream |
| Deferred-data API |
useMemo(() => fetchX()) + use(promise)
|
async setup() with top-level await fetchX()
|
<Suspense> semantics in SSR |
Non-blocking — emits fallback marker (<!--$?-->), real content follows in a later chunk; client swaps via inline <script>
|
Blocking — render of content after the boundary waits for every async setup() inside it to resolve before more HTML is emitted |
| Selective hydration | Yes — hydrates resolved islands as chunks arrive | No — app.mount("#root") hydrates the whole tree atomically |
| Error boundary |
componentDidCatch (class component) |
onErrorCaptured (returning false stops propagation) |
What this means in practice: the Vue example streams chunks of HTML as the render tree resolves (better TTFB than buffered renderToString), and <Suspense> provides the canonical Vue pattern for awaitable deferred data inside a route. But you don't get the "fallback now, real content later, hydrate it independently" model that React 19 ships. True out-of-order streaming + lazy hydration in Vue is on the Vapor mode roadmap; the current example is honest about that gap rather than papering over it.
For everything that doesn't depend on streaming-primitive semantics — per-request isolation, plugin-driven critical data, hydration round-trip — the React and Vue examples are interchangeable. The Vue port required zero changes to @real-router/vue or @real-router/ssr-data-plugin.
Good fit:
- Slow non-critical sections — comments, related products, recommendations, analytics widgets. Critical content (the page's reason for existing) renders immediately; secondary sections fill in.
- Time-to-interactive optimization — the user can start reading before all data arrives.
- Cascading data — primary data resolves fast, secondary data depends on primary and is slower.
Bad fit:
-
Render-blocking critical data — just
awaitit in the loader. No benefit to deferring data the user must see. -
Below-the-fold content —
<Suspense>is render-time; streaming doesn't help if the user has to scroll. Combine withIntersectionObserverfor true lazy rendering. -
Static content — if data is sync-available,
<Suspense>adds overhead with no benefit.
A complete working example:
examples/web/react/ssr-examples/ssr-streaming/
- Express + Vite SSR setup
-
ssr-data-pluginfor critical product data - Suspense +
use(promise)for deferred reviews and related items - Server-only artificial delays (600 ms reviews, 1200 ms related items) to make streaming observable
- 5 Playwright scenarios covering shell timing, streaming markers, deferred visibility, hydration, and full-reload navigation
Run pnpm test:e2e from the example directory to see the streaming behavior verified end-to-end.
-
Server-Side Rendering — classical (non-streaming) SSR primitives —
cloneRouter,start,dispose -
SSR Hydration —
serializeRouterState+hydrateRouterlifecycle -
Data Loading — overall data-loading patterns including
ssr-data-plugin -
RSC Integration — React Server Components alternative for streaming, via
@real-router/rsc-server-plugin - @real-router/ssr-data-plugin — per-route critical data loading reference
- React 19 docs:
renderToReadableStream,use(promise),<Suspense>
- 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