renderToPipeableStream
Streams a React tree as HTML into a Node.js writable stream, flushing Suspense content as it resolves.
const { pipe, abort } = renderToPipeableStream(reactNode, options?)- Added in React 18.0.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$reactNode | ReactNode | A React node you want to render to HTML. For example, a JSX element like <App />. It is expected to represent the entire document, so the App component should render the <html> tag. |
$optionsoptional | object | An object with streaming options. |
$options.bootstrapScriptContentoptional | string | If specified, this string will be placed in an inline <script> tag. |
$options.bootstrapScriptsoptional | string[] | An array of string URLs for the <script> tags to emit on the page. Use this to include the <script> that calls hydrateRoot. Omit it if you don't want to run React on the client at all. |
$options.bootstrapModulesoptional | string[] | Like bootstrapScripts, but emits <script type="module"> instead. |
$options.identifierPrefixoptional | string | A string prefix React uses for IDs generated by useId. Useful to avoid conflicts when using multiple roots on the same page. Must be the same prefix as passed to hydrateRoot. |
$options.namespaceURIoptional | string | A string with the root namespace URI for the stream. Defaults to regular HTML. Pass 'http://www.w3.org/2000/svg' for SVG or 'http://www.w3.org/1998/Math/MathML' for MathML. |
$options.nonceoptional | string | A nonce string to allow scripts for script-src Content-Security-Policy. |
$options.onAllReadyoptional | () => void | A callback that fires when all rendering is complete, including both the shell and all additional content. You can use this instead of onShellReady for crawlers and static generation. |
$options.onBootstrapScriptErroroptional | (error: Error) => void | Not part of the documented options object; error reporting for the render is handled by onError and onShellError. |
$options.onErroroptional | (error: Error, errorInfo: object) => void | A callback that fires whenever there is a server error, whether recoverable or not. By default, this only calls console.error. |
$options.onShellReadyoptional | () => void | A callback that fires right after the initial shell has been rendered. You can set the status code and call pipe here to start streaming. |
$options.onShellErroroptional | (error: Error) => void | A callback that fires if there was an error rendering the initial shell. It receives the error as an argument. No bytes were emitted from the stream yet, so you can send a fallback HTML shell here. |
$options.progressiveChunkSizeoptional | number | The number of bytes in a chunk. |
Return value
{ pipe: (destination: Writable) => Writable, abort: (reason?: any) => void }An object with two methods: pipe, which outputs the HTML into the provided Writable Node.js Stream (call it in onShellReady to stream, or in onAllReady for crawlers and static generation), and abort, which lets you abort server rendering and render the rest on the client.
Errors
It does not throw synchronously for render errors: errors inside the shell surface through onShellError, and errors inside a Suspense boundary surface through onError while React attempts to recover on the client.
Changelog
- PHP 18.0.0Introduced as the Node.js streaming SSR entry point, replacing renderToNodeStream and enabling Suspense-driven streaming and selective hydration.
- PHP 19.0.0The tree is expected to render the whole document, including <html> and <head>; stylesheets, scripts and metadata rendered anywhere in the tree are hoisted into <head> automatically.
renderToPipeableStream is the Node.js streaming server renderer. It splits your tree into the shell — everything outside any <Suspense> boundary — and the content inside those boundaries. The shell must finish before anything is sent; each boundary is then flushed into the same response as its data resolves, along with a small inline script that slots it into place.
When to reach for it
Use it for any real HTTP server on Node.js. It gives you a fast first paint, streams slow sections without blocking the rest of the page, and enables selective hydration so users can interact with the parts that have arrived. On Deno, Cloudflare Workers or other Web Streams runtimes, use renderToReadableStream instead.
What trips people up
Two things. First, once you call pipe you cannot change the status code or headers — set them inside onShellReady, before piping, and use onShellError for the 500 path. Second, everything you put outside a Suspense boundary lands in the shell and delays the first byte; if your page is slow to start streaming, the fix is almost always to wrap more of it in <Suspense>.
Only data read through a source that activates a boundary — a promise read with use — suspends. Fetching in an Effect does nothing on the server.
Example
import { renderToPipeableStream } from 'react-dom/server';
import express from 'express';
import App from './App.js';
const app = express();
const ABORT_DELAY_MS = 10_000;
app.get('/', (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
// Headers must be set BEFORE pipe() — after this, they are on the wire.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
// Nothing has been sent yet, so a clean error response is still possible.
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.send('<!DOCTYPE html><p>Something went wrong.</p>');
},
onError(error) {
didError = true;
console.error(error);
},
});
// Stop waiting on slow boundaries; the client renders the rest.
setTimeout(abort, ABORT_DELAY_MS);
});
app.listen(3000);When to use it
- Streaming an Express or Fastify HTML response so the header and nav paint before slow data resolves.
- Returning a 500 with a static fallback shell when the top-level layout throws, via onShellError.
- Waiting for the full page in onAllReady when the request comes from a crawler that ignores streamed chunks.
- Cutting off a hung render with abort() after a timeout and letting the client finish those boundaries.
Frequently asked questions
Why can't I set the status code after calling pipe?
Because pipe() writes the response head immediately, and HTTP headers cannot be changed once bytes are on the wire. Decide the status inside onShellReady before you call pipe, and use onShellError — which fires before anything is sent — to emit a 500 with a fallback shell.
What is the difference between onShellReady and onAllReady?
onShellReady fires when everything outside your Suspense boundaries has rendered, which is the earliest point you can start streaming; onAllReady fires only after every boundary has also resolved. Pipe in onShellReady for users and in onAllReady for crawlers or static generation.
Can I use renderToPipeableStream on Cloudflare Workers or Deno?
No — it is specific to Node.js and requires a Node writable stream. On Deno, Cloudflare Workers, Vercel Edge and similar Web Streams runtimes, use renderToReadableStream, which has the same shell-and-boundaries model but resolves to a ReadableStream.
More examples
Choosing what goes in the shell
The nav and heading are outside any boundary, so they are part of the shell and onShellReady fires as soon as they render — the browser starts painting immediately. The comments list is inside a Suspense boundary, so React sends 'Loading comments…' first, then later appends the real list plus an inline script that swaps it in. Moving <Comments /> out of the boundary would block the entire response until the comments promise resolved.
import { Suspense, use } from 'react';
function Comments({ commentsPromise }) {
const comments = use(commentsPromise);
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
);
}
export default function App({ commentsPromise }) {
return (
<html lang="en">
<head>
<title>Post</title>
</head>
<body>
{/* Everything here is the shell: it must finish before the
first byte is sent. Keep it cheap. */}
<nav>Home / Blog</nav>
<h1>A post</h1>
{/* Everything inside Suspense streams in later. */}
<Suspense fallback={<p>Loading comments…</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</body>
</html>
);
}Waiting for everything, for crawlers
Exactly one of the two callbacks pipes, decided by the isCrawler flag. onShellReady fires early and produces a streamed response with fallbacks that fill in progressively; onAllReady fires only after every Suspense boundary has resolved and produces one complete HTML document. Calling pipe in both would throw, since a stream can only be piped once.
import { renderToPipeableStream } from 'react-dom/server';
function handle(req, res, isCrawler) {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
// Streaming path: send the shell as soon as it is ready.
onShellReady() {
if (isCrawler) return;
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
// Crawler path: hold the response until every boundary resolved,
// so the HTML contains real content instead of fallbacks.
onAllReady() {
if (!isCrawler) return;
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onError(error) {
console.error(error);
},
});
}Aborting a slow render
abort() stops server rendering for any boundary that has not resolved. Those boundaries keep their fallback in the HTML and the client renders them after hydration, so the user sees a working page rather than a hung request. The abort reason is passed to onError, which is why the log line prints the timeout message. Clearing the timer on onAllReady and on response close avoids aborting a stream that already finished.
import { renderToPipeableStream } from 'react-dom/server';
const TIMEOUT_MS = 5_000;
function handle(req, res) {
let timeoutId;
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onAllReady() {
clearTimeout(timeoutId);
},
onError(error) {
console.error('[ssr]', error.message);
},
});
timeoutId = setTimeout(() => {
abort(new Error('SSR timed out after 5s'));
}, TIMEOUT_MS);
res.on('close', () => clearTimeout(timeoutId));
}
Discussion