Skip to main content

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

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

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

Common Issues

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.
Don’t call pipe() before setting response headers:
// Wrong
pipe(res);
res.setHeader('Content-Type', 'text/html'); // Too late!

// Correct
res.setHeader('Content-Type', 'text/html');
pipe(res);
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>
  );
}
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

Performance Best Practices

Stream immediately to minimize time to first byte:
onShellReady() {
  pipe(res); // Stream as soon as shell is ready
}
Control streaming granularity:
renderToPipeableStream(<App />, {
  progressiveChunkSize: 2048, // 2KB chunks
});
Prevent hanging renders:
const RENDER_TIMEOUT = 10000;
setTimeout(() => abort(), RENDER_TIMEOUT);
Place boundaries around slow content:
<Suspense fallback={<Skeleton />}>
  <SlowDataComponent /> {/* Only this waits */}
</Suspense>

See Also