Skip to content

Streaming SSR

olegivanov edited this page May 3, 2026 · 3 revisions

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.


Mental Model

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.


Minimal Setup

Three files, no router-specific streaming wrappers.

Loader — critical data only (src/router/loaders.ts)

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.

Suspense component with use(promise) (src/components/Reviews.tsx)

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:

  1. Per-render memoized promise via useMemo. A fresh promise on every server request. Avoids React.lazy's singleton cache that would make streaming visible only on the first request.
  2. 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.
  3. Client returns Promise.resolve(value)use() resolves synchronously on the client, no hydration flash.

Server entry (src/entry-server.tsx)

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.

Express integration (server/index.ts)

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.

Client entry (src/entry-client.tsx)

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.


Comparison with Other Routers

React Router v7 — defer() + <Await>

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

Real-Router — direct React 19 primitives

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


Vue Counterpart

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.


When to Use Streaming

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 await it 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 with IntersectionObserver for true lazy rendering.
  • Static content — if data is sync-available, <Suspense> adds overhead with no benefit.

Reference Implementation

A complete working example:

examples/web/react/ssr-examples/ssr-streaming/

  • Express + Vite SSR setup
  • ssr-data-plugin for 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.


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