prerenderToNodeStream

Renders a React tree to a static HTML Node.js stream at build time, once all data has resolved.

Signature
const { prelude, postponed } = await prerenderToNodeStream(reactNode, options?)
  • Added in React 19.0.0

Parameters

ParameterTypeDescription
$reactNodeReactNodeA 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.
$optionsoptionalobjectAn object with prerender options.
$options.bootstrapScriptContentoptionalstringIf specified, this string will be placed in an inline <script> tag.
$options.bootstrapScriptsoptionalstring[]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.bootstrapModulesoptionalstring[]Like bootstrapScripts, but emits <script type="module"> instead.
$options.identifierPrefixoptionalstringA 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.namespaceURIoptionalstringA 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.onErroroptional(error: Error) => voidA callback that fires whenever there is a server error, whether recoverable or not. By default, this only calls console.error.
$options.progressiveChunkSizeoptionalnumberThe number of bytes in a chunk.
$options.signaloptionalAbortSignalAn abort signal that lets you stop prerendering. Work that had not finished is recorded in postponed rather than rendered.

Return value

Promise<{ prelude: NodeJS.ReadableStream, postponed: object | null }>A Promise that resolves to an object with prelude, a Node.js Stream of HTML that you can pipe out in chunks or read into a string, and postponed, a JSON-serializeable, opaque object that can be passed to resumeToPipeableStream if prerenderToNodeStream did not finish — otherwise null, indicating the prelude contains all the content. If rendering fails, the Promise is rejected.

Errors

The returned Promise rejects if rendering fails; no partial prelude is produced in that case.

Changelog

  • PHP 19.0.0Added to react-dom/static alongside prerender, as the Node.js static generation API that waits for data before producing HTML.

prerenderToNodeStream is prerender for Node.js. Same contract — import from react-dom/static, wait for every Suspense boundary, resolve with { prelude, postponed } — except prelude is a Node Readable you can pipe straight into a file or an HTTP response.

When to reach for it

This is the API for a Node build script that writes HTML files, or a Node server that caches a rendered page. If your data lives behind a Suspense boundary, it is the only string-producing option that will actually contain that data: renderToString would emit the fallback instead.

What trips people up

The name says stream, but nothing streams incrementally. The promise does not resolve until the whole render is done, so you cannot use this to improve time-to-first-byte — that is renderToPipeableStream's job. People also forget the entry point: it is react-dom/static, while the matching resumeToPipeableStream lives in react-dom/server.

There is no nonce option, on purpose — a nonce must differ per request and prerendered HTML is reused. And check postponed: a non-null value means the prelude is only part of the page.

Example

Example · jsx
import { prerenderToNodeStream } from 'react-dom/static';
import { createWriteStream, promises as fs } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import App from './App.js';

async function buildRoute(route) {
  const { prelude, postponed } = await prerenderToNodeStream(
    <App route={route} />,
    {
      bootstrapScripts: ['/main.js'],
      onError(error) {
        console.error(`[prerender ${route}]`, error.message);
      },
    }
  );

  if (postponed !== null) {
    throw new Error(`${route} did not finish prerendering`);
  }

  const dir = path.join('dist', route);
  await fs.mkdir(dir, { recursive: true });

  const file = createWriteStream(path.join(dir, 'index.html'));
  file.write('<!DOCTYPE html>');
  // Pipe rather than buffer: the document never has to fit in memory.
  await pipeline(prelude, file);
}

// Sorted so every build processes routes in the same order.
for (const route of ['about', 'index', 'pricing'].sort()) {
  await buildRoute(route);
}

When to use it

  • A Node build script that writes one index.html per route with data already baked in.
  • Warming a page cache on deploy so the first visitor gets complete HTML.
  • Piping prerendered output straight to disk without buffering the whole document in memory.
  • Producing complete HTML for a crawler-facing route on a Node server.

Frequently asked questions

What is the difference between prerenderToNodeStream and renderToPipeableStream?

prerenderToNodeStream waits for every Suspense boundary to resolve before its promise settles, while renderToPipeableStream sends the shell immediately and streams boundaries in progressively. Use the former for build-time static generation, the latter for live requests where first paint matters.

Should I replace renderToString with prerenderToNodeStream?

Yes, if your components suspend for data — that is exactly why React 19 added it. renderToString emits Suspense fallbacks into your static HTML, whereas prerenderToNodeStream waits for the data and emits the real content. For trees with no suspending data and no need for a stream, renderToString is still fine.

What does the postponed value from prerenderToNodeStream mean?

A non-null postponed means rendering did not finish and the prelude contains only part of the page. It is a JSON-serialisable object you store and later pass to resumeToPipeableStream from react-dom/server to render the remainder at request time. When it is null, the prelude is the complete document.

More examples

Reading the prelude into a string

prelude is an ordinary Node Readable, so async iteration collects it into a string in a few lines. This is the drop-in replacement for a renderToString call in a build script: the resulting string contains resolved Suspense content rather than fallbacks. Buffering like this is fine for a single page; for hundreds of pages, pipe to disk instead so you never hold a whole document in memory.

Example · jsx
import { prerenderToNodeStream } from 'react-dom/static';

async function streamToString(stream) {
  const chunks = [];
  for await (const chunk of stream) {
    chunks.push(Buffer.from(chunk));
  }
  return Buffer.concat(chunks).toString('utf8');
}

const { prelude, postponed } = await prerenderToNodeStream(<App />, {
  bootstrapScripts: ['/main.js'],
});

if (postponed !== null) {
  throw new Error('Prerender incomplete — a resume step is required.');
}

const html = '<!DOCTYPE html>' + (await streamToString(prelude));
await cache.set('page:home', html);

Serving prerendered HTML from Express

Because the await completes before any bytes are written, the status code and headers are still free to set — the opposite of renderToPipeableStream, where calling pipe commits the response head immediately. A render failure therefore produces a clean 500 from the catch block. The trade-off is latency: this request waits for the full render, which is why the result is cached.

Example · jsx
import { prerenderToNodeStream } from 'react-dom/static';
import express from 'express';
import Doc from './Doc.js';

const app = express();
const cache = new Map();

async function streamToString(stream) {
  const chunks = [];
  for await (const chunk of stream) {
    chunks.push(Buffer.from(chunk));
  }
  return Buffer.concat(chunks).toString('utf8');
}

app.get('/docs/:slug', async (req, res) => {
  const key = req.params.slug;

  if (cache.has(key)) {
    res.setHeader('Content-Type', 'text/html');
    res.send(cache.get(key));
    return;
  }

  try {
    const { prelude, postponed } = await prerenderToNodeStream(
      <Doc slug={key} />,
      { bootstrapScripts: ['/main.js'] }
    );

    if (postponed !== null) {
      throw new Error(`${key} did not finish prerendering`);
    }

    // The await already finished, so nothing has been written yet:
    // the status code and headers are still free to set.
    const html = '<!DOCTYPE html>' + (await streamToString(prelude));
    cache.set(key, html);

    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html');
    res.send(html);
  } catch (error) {
    res.status(500).send('<!DOCTYPE html><p>Render failed.</p>');
  }
});

app.listen(3000);

Node and Web variants side by side

The two APIs are the same renderer with different stream types: prerenderToNodeStream yields a Node Readable suited to pipe() and fs, prerender yields a Web ReadableStream suited to the Response constructor. Options and semantics are otherwise identical, including the absence of nonce and the meaning of postponed. The pairing of each with its resume function matters — passing a postponed object to the wrong one will not work.

Example · jsx
// Node.js build script
import { prerenderToNodeStream } from 'react-dom/static';

const nodeResult = await prerenderToNodeStream(<App />, {
  bootstrapScripts: ['/main.js'],
});
nodeResult.prelude.pipe(process.stdout); // Node Readable

// Edge / worker runtime
import { prerender } from 'react-dom/static';

const webResult = await prerender(<App />, {
  bootstrapScripts: ['/main.js'],
});
return new Response(webResult.prelude); // Web ReadableStream

// Continuation lives in react-dom/server, not react-dom/static:
//   prerenderToNodeStream -> resumeToPipeableStream
//   prerender             -> resume

Discussion

  • Be the first to comment on this lesson.