renderToPipeableStream
renderToPipeableStream renders a React tree to a Node.js Writable stream. This is the recommended API for server-side rendering in Node.js environments with full support for Suspense, streaming, and selective hydration.
const { pipe , abort } = renderToPipeableStream ( element , options );
This is the modern replacement for renderToString in Node.js. It supports streaming, Suspense, and provides better performance and user experience.
Reference
renderToPipeableStream(element, options?)
Renders a React element to a pipeable stream with progressive HTML streaming.
import { renderToPipeableStream } from 'react-dom/server' ;
const { pipe } = renderToPipeableStream ( < App /> , {
onShellReady () {
pipe ( res ); // res is a Node.js http.ServerResponse
}
});
Parameters
element : ReactNode - The React element to render
options : Object (optional)
identifierPrefix : string - Prefix for IDs generated by useId
namespaceURI : string - Namespace URI for the document (e.g., SVG)
nonce : string | { script?: string, style?: string } - Nonce for Content Security Policy
bootstrapScriptContent : string - Inline script content to run before other scripts
bootstrapScripts : Array<string | BootstrapScriptDescriptor> - External scripts to load
bootstrapModules : Array<string | BootstrapScriptDescriptor> - External ES modules to load
progressiveChunkSize : number - Target size of progressive chunks
onShellReady : () => void - Called when the shell (initial UI) is ready
onShellError : (error: mixed) => void - Called if the shell fails to render
onAllReady : () => void - Called when all content, including Suspense boundaries, is ready
onError : (error: mixed, errorInfo: ErrorInfo) => ?string - Error handler for rendering errors
unstable_externalRuntimeSrc : string | BootstrapScriptDescriptor - External React runtime script
importMap : ImportMap - Import map for module scripts
formState : ReactFormState | null - Form state for progressive enhancement
onHeaders : (headers: HeadersDescriptor) => void - Callback for early hints
maxHeadersLength : number - Maximum length for early hints headers
Returns
Returns an object with two methods:
type PipeableStream = {
pipe < T extends Writable >( destination : T ) : T ;
abort ( reason : mixed ) : void ;
};
pipe(destination) : Pipes the HTML stream to a Node.js Writable stream (like http.ServerResponse)
abort(reason) : Aborts the render and puts remaining content into client-rendered mode
Caveats
Node.js only : This API uses Node.js stream APIs (Writable)
One-time pipe : Can only pipe to one destination stream
Call in onShellReady : Typically pipe in the onShellReady callback
Edge runtimes : For edge environments, use renderToReadableStream
Usage
Basic streaming in Express
import express from 'express' ;
import { renderToPipeableStream } from 'react-dom/server' ;
import App from './App' ;
const app = express ();
app . get ( '/' , ( req , res ) => {
const { pipe , abort } = renderToPipeableStream ( < App /> , {
bootstrapScripts: [ '/client.js' ],
onShellReady () {
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
},
onShellError ( error ) {
res . statusCode = 500 ;
res . send ( '<h1>Something went wrong</h1>' );
},
onError ( error ) {
console . error ( 'Rendering error:' , error );
},
});
// Abort if client disconnects
req . on ( 'close' , () => abort ());
});
app . listen ( 3000 );
Streaming with Suspense
import { Suspense } from 'react' ;
import { renderToPipeableStream } from 'react-dom/server' ;
function App () {
return (
< html >
< head >
< title > My App </ title >
</ head >
< body >
< nav >
< h1 > My Website </ h1 >
</ nav >
< main >
{ /* Shell - sent in onShellReady */ }
< h2 > Welcome! </ h2 >
{ /* Streamed progressively when ready */ }
< Suspense fallback = { < div > Loading posts... </ div > } >
< BlogPosts />
</ Suspense >
< Suspense fallback = { < div > Loading sidebar... </ div > } >
< Sidebar />
</ Suspense >
</ main >
</ body >
</ html >
);
}
// BlogPosts is an async server component
async function BlogPosts () {
const posts = await db . posts . findAll ();
return (
< ul >
{ posts . map ( post => (
< li key = { post . id } > { post . title } </ li >
)) }
</ ul >
);
}
app . get ( '/' , ( req , res ) => {
const { pipe } = renderToPipeableStream ( < App /> , {
bootstrapScripts: [ '/client.js' ],
onShellReady () {
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
},
});
});
onShellReady vs onAllReady
Choose when to start streaming based on your use case:
onShellReady (Users)
onAllReady (Crawlers)
// Best for interactive users - stream as soon as shell is ready
app . get ( '/' , ( req , res ) => {
const { pipe } = renderToPipeableStream ( < App /> , {
bootstrapScripts: [ '/client.js' ],
onShellReady () {
// Start streaming immediately
// Suspense boundaries will stream in later
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
},
});
});
// Timeline:
// 0ms: Shell HTML sent
// 50ms: First Suspense boundary resolves, streamed
// 100ms: Second Suspense boundary resolves, streamed
// Best for crawlers - wait for all content before streaming
app . get ( '/' , ( req , res ) => {
const isBot = /bot | crawler | spider/ i . test ( req . headers [ 'user-agent' ]);
const { pipe } = renderToPipeableStream ( < App /> , {
bootstrapScripts: [ '/client.js' ],
onShellReady () {
if ( ! isBot ) {
// Stream immediately for users
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
}
},
onAllReady () {
if ( isBot ) {
// Wait for everything for crawlers
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
}
},
});
});
// Timeline for bot:
// 0ms: (waiting...)
// 100ms: All content ready, send complete HTML
Error handling
import { renderToPipeableStream } from 'react-dom/server' ;
app . get ( '/' , ( req , res ) => {
let didError = false ;
const { pipe , abort } = renderToPipeableStream ( < App /> , {
bootstrapScripts: [ '/client.js' ],
onShellReady () {
// If shell rendered successfully, start streaming
res . statusCode = didError ? 500 : 200 ;
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
},
onShellError ( error ) {
// Shell failed - send error page
res . statusCode = 500 ;
res . setHeader ( 'Content-Type' , 'text/html' );
res . send ( '<h1>Something went wrong</h1>' );
},
onError ( error , errorInfo ) {
didError = true ;
// Log to error tracking service
console . error ( 'Rendering error:' , error );
console . error ( 'Component stack:' , errorInfo . componentStack );
// Return error digest for Error Boundary
return errorInfo . digest ;
},
});
// Abort if client disconnects
req . on ( 'close' , () => {
abort ();
});
// Timeout after 10 seconds
setTimeout (() => {
abort ();
}, 10000 );
});
Complete HTML document
Render a complete HTML document with proper structure:
import { renderToPipeableStream } from 'react-dom/server' ;
function Html ({ children , title }) {
return (
< html lang = "en" >
< head >
< meta charSet = "utf-8" />
< meta name = "viewport" content = "width=device-width, initial-scale=1" />
< title > { title } </ title >
< link rel = "stylesheet" href = "/styles.css" />
</ head >
< body >
< div id = "root" > { children } </ div >
</ body >
</ html >
);
}
app . get ( '/' , ( req , res ) => {
const { pipe } = renderToPipeableStream (
< Html title = "My App" >
< App />
</ Html > ,
{
bootstrapScripts: [ '/client.js' ],
onShellReady () {
res . setHeader ( 'Content-Type' , 'text/html' );
res . write ( '<!DOCTYPE html>' );
pipe ( res );
},
}
);
});
Using bootstrap scripts and modules
import { renderToPipeableStream } from 'react-dom/server' ;
type BootstrapScriptDescriptor = {
src : string ;
integrity ?: string ;
crossOrigin ?: string ;
};
app . get ( '/' , ( req , res ) => {
const { pipe } = renderToPipeableStream ( < App /> , {
// Inline script executed first
bootstrapScriptContent: `
console.log('App starting...');
window.__INITIAL_STATE__ = ${ JSON . stringify ( initialState ) } ;
` ,
// External scripts (legacy)
bootstrapScripts: [
'/vendor.js' ,
{
src: '/client.js' ,
integrity: 'sha384-...' , // Subresource Integrity
crossOrigin: 'anonymous' ,
},
],
// External ES modules (modern)
bootstrapModules: [
'/app.js' ,
],
onShellReady () {
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
},
});
});
Setting CSP nonce
import crypto from 'crypto' ;
import { renderToPipeableStream } from 'react-dom/server' ;
app . get ( '/' , ( req , res ) => {
// Generate a unique nonce for this request
const nonce = crypto . randomBytes ( 16 ). toString ( 'base64' );
const { pipe } = renderToPipeableStream ( < App /> , {
// Apply nonce to all scripts and styles
nonce: nonce ,
// Or separate nonces:
// nonce: { script: scriptNonce, style: styleNonce },
bootstrapScripts: [ '/client.js' ],
onShellReady () {
// Set CSP header with the nonce
res . setHeader (
'Content-Security-Policy' ,
`script-src 'nonce- ${ nonce } ' 'strict-dynamic'; style-src 'nonce- ${ nonce } ';`
);
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
},
});
});
Advanced Patterns
Implementing timeouts
import { renderToPipeableStream } from 'react-dom/server' ;
app . get ( '/' , ( req , res ) => {
let didTimeout = false ;
const { pipe , abort } = renderToPipeableStream ( < App /> , {
bootstrapScripts: [ '/client.js' ],
onShellReady () {
if ( ! didTimeout ) {
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
}
},
onShellError ( error ) {
res . statusCode = 500 ;
res . send ( '<h1>Error</h1>' );
},
});
// Abort after 5 seconds
setTimeout (() => {
didTimeout = true ;
abort ( new Error ( 'Rendering timeout' ));
}, 5000 );
// Abort on disconnect
req . on ( 'close' , abort );
});
Custom HTML streaming wrapper
import { renderToPipeableStream } from 'react-dom/server' ;
import { PassThrough } from 'stream' ;
function streamWithWrapper ( element , options = {}) {
return new Promise (( resolve , reject ) => {
const passThrough = new PassThrough ();
// Write HTML opening
passThrough . write ( '<!DOCTYPE html>' );
const { pipe , abort } = renderToPipeableStream ( element , {
... options ,
onShellReady () {
pipe ( passThrough );
resolve ({ stream: passThrough , abort });
},
onShellError ( error ) {
reject ( error );
},
});
});
}
// Usage
app . get ( '/' , async ( req , res ) => {
try {
const { stream , abort } = await streamWithWrapper ( < App /> , {
bootstrapScripts: [ '/client.js' ],
});
res . setHeader ( 'Content-Type' , 'text/html' );
stream . pipe ( res );
req . on ( 'close' , abort );
} catch ( error ) {
res . status ( 500 ). send ( '<h1>Error</h1>' );
}
});
Implementing early hints (HTTP 103)
import { renderToPipeableStream } from 'react-dom/server' ;
app . get ( '/' , ( req , res ) => {
const { pipe } = renderToPipeableStream ( < App /> , {
bootstrapScripts: [ '/client.js' ],
onHeaders ( headers ) {
// Send 103 Early Hints with preload links
if ( res . writeEarlyHints ) {
const hints = {};
// Convert headers to early hints format
if ( headers . Link ) {
hints . link = headers . Link ;
}
res . writeEarlyHints ( hints );
}
},
onShellReady () {
res . setHeader ( 'Content-Type' , 'text/html' );
pipe ( res );
},
});
});
Resuming from postponed state
import { renderToPipeableStream , resumeToPipeableStream } from 'react-dom/server' ;
// Initial render with postponed boundaries
app . get ( '/page' , ( req , res ) => {
const { pipe } = renderToPipeableStream ( < Page /> , {
onShellReady () {
pipe ( res );
},
});
});
// Resume postponed content
app . get ( '/page/postponed' , ( req , res ) => {
const postponedState = getPostponedStateFromCache ( req . query . id );
const { pipe } = resumeToPipeableStream ( < Page /> , postponedState , {
onShellReady () {
pipe ( res );
},
});
});
Comparison with Other APIs
renderToPipeableStream
renderToString
renderToReadableStream
// Modern, streaming, Node.js
import { renderToPipeableStream } from 'react-dom/server' ;
app . get ( '/' , ( req , res ) => {
const { pipe } = renderToPipeableStream ( < App /> , {
onShellReady () {
pipe ( res );
},
});
});
Pros:
Streaming support
Suspense support
Progressive rendering
Better performance
Non-blocking
// Legacy, blocking
import { renderToString } from 'react-dom/server' ;
app . get ( '/' , ( req , res ) => {
const html = renderToString ( < App /> );
res . send ( `<!DOCTYPE html> ${ html } ` );
});
Cons:
No Suspense
Blocking
No streaming
Worse TTFB
// Modern, streaming, Web Streams (edge)
import { renderToReadableStream } from 'react-dom/server' ;
export default async function handler ( request ) {
const stream = await renderToReadableStream ( < App /> );
return new Response ( stream );
}
For edge runtimes:
Vercel Edge
Cloudflare Workers
Deno
Web Streams API
Common Issues
Error: Can only pipe to one writable stream
pipe() can only be called once per render:const { pipe } = renderToPipeableStream ( < App /> );
pipe ( res1 ); // OK
pipe ( res2 ); // Error!
Solution : Create a new render for each response.
Headers already sent error
Shell never completes (hangs)
If a component outside Suspense boundaries throws a promise, rendering hangs: // Bad - throws promise outside Suspense
function App () {
const data = use ( dataPromise ); // No Suspense wrapper!
return < div > { data } </ div > ;
}
// Good - wrap with Suspense
function App () {
return (
< Suspense fallback = { < div > Loading... </ div > } >
< AsyncComponent />
</ Suspense >
);
}
Memory leaks with many concurrent renders
Each render keeps state in memory until complete: // Add timeouts to prevent leaks
const { abort } = renderToPipeableStream ( < App /> );
setTimeout (() => {
abort (); // Clean up after timeout
}, 10000 );
req . on ( 'close' , abort ); // Clean up on disconnect
Use onShellReady for users
Stream immediately to minimize time to first byte: onShellReady () {
pipe ( res ); // Stream as soon as shell is ready
}
Set appropriate chunk sizes
Control streaming granularity: renderToPipeableStream ( < App /> , {
progressiveChunkSize: 2048 , // 2KB chunks
});
Implement proper timeouts
Prevent hanging renders: const RENDER_TIMEOUT = 10000 ;
setTimeout (() => abort (), RENDER_TIMEOUT );
Use Suspense strategically
Place boundaries around slow content: < Suspense fallback = { < Skeleton /> } >
< SlowDataComponent /> { /* Only this waits */ }
</ Suspense >
See Also