prerender

Renders a React tree to a static HTML Web Stream at build time, after all data has loaded.

Signature
const { prelude, postponed } = await prerender(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. Content that had not resolved is left for a later resume rather than being rendered.

Return value

Promise<{ prelude: ReadableStream, postponed: object | null }>A Promise that resolves to an object with prelude, a Web Stream of HTML, and postponed, a JSON-serializeable, opaque object that can be passed to resume if prerender did not finish — otherwise null, indicating that the prelude contains all the content and no resume is necessary. If rendering fails, the Promise is rejected.

Errors

The returned Promise rejects if rendering fails; there is no partial output in that case.

Changelog

  • PHP 19.0.0Added to react-dom/static as a static generation API that waits for all data to load, improving on renderToString for SSG.

prerender is React 19's static generation renderer for Web Streams runtimes. Unlike every other server API, it is imported from react-dom/static, not react-dom/server. It renders the whole tree, waits for every Suspense boundary to resolve, and only then resolves with a stream of finished HTML.

When to reach for it

Reach for it at build time, or in any cache-once path where you want data-complete HTML rather than a fast first byte. This is the API that fixes renderToString's worst behaviour: fallbacks no longer leak into your static output. It produces hydratable markup, so a client bundle can still take over.

What trips people up

People expect it to stream progressively — it does not. The result is a stream only because streams are the transport; nothing is emitted until the entire render finishes. If you want content to arrive as it loads, use renderToReadableStream.

nonce is deliberately not an option: a nonce must be unique per request, and baking one into prerendered HTML would defeat your CSP. Also check postponed — if it is not null, the prelude is incomplete and needs resume to finish at request time.

Example

Example · jsx
import { prerender } from 'react-dom/static';
import App from './App.js';

// Runs at build time, not per request.
async function buildPage(route) {
  const { prelude, postponed } = await prerender(<App route={route} />, {
    bootstrapScripts: ['/main.js'],
    onError(error) {
      console.error(`[prerender ${route}]`, error);
    },
  });

  if (postponed !== null) {
    // Rendering did not finish: the rest must be produced at request
    // time by passing `postponed` to resume().
    return { html: null, postponed };
  }

  // Streams can be read into a string, or written straight to disk.
  const html = await new Response(prelude).text();
  return { html: '<!DOCTYPE html>' + html, postponed: null };
}

// Sorted so the build emits pages in the same order every time.
const routes = ['/about', '/index', '/pricing'].sort();
for (const route of routes) {
  const { html } = await buildPage(route);
  if (html) await writePage(route, html);
}

When to use it

  • Generating one HTML file per route in a static build, with all data already resolved.
  • Caching a fully rendered marketing page in a CDN or KV store on deploy.
  • Producing data-complete HTML for crawlers without holding an open request.
  • Partially prerendering a shared shell and finishing the user-specific parts later with resume.

Frequently asked questions

What is the difference between prerender and renderToReadableStream?

prerender waits for every Suspense boundary to resolve before it gives you anything, while renderToReadableStream starts emitting the shell immediately and streams boundaries in as they finish. Use prerender for build-time static generation where complete HTML matters, and renderToReadableStream for live requests where time-to-first-byte matters.

Where do I import prerender from?

From 'react-dom/static' — not 'react-dom/server', which is where every other server rendering API lives. react-dom/static holds both prerender and prerenderToNodeStream, while their continuation functions resume and resumeToPipeableStream are in react-dom/server.

Why is there no nonce option on prerender?

Because a CSP nonce must be unique per request, and prerendered HTML is generated once and reused. Baking a fixed nonce into the output would let an attacker reuse it, so React omits the option entirely. Apply nonces at request time instead, or use a hash-based CSP for prerendered pages.

More examples

Serving the prelude directly as a Response

prelude is a real ReadableStream, so it can be handed straight to the Response constructor with no buffering step. The important difference from renderToReadableStream is timing: the await here does not resolve until every Suspense boundary in App has finished, so by the time the Response exists the entire document is already decided. That is what makes the long cache-control header safe.

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

export default async function handler(request) {
  const { prelude } = await prerender(<App />, {
    bootstrapScripts: ['/main.js'],
  });

  return new Response(prelude, {
    headers: {
      'content-type': 'text/html',
      'cache-control': 'public, max-age=3600',
    },
  });
}

Data that actually suspends

This is the concrete contrast with renderToString, which would have emitted '<p>Loading…</p>' and stopped. Because the promise is read with use inside a Suspense boundary, prerender waits for it and the fallback never reaches the output. Note the requirement: only data read through something that activates a boundary suspends — fetching inside an Effect would still produce the fallback, because Effects do not run on the server.

Example · jsx
import { Suspense, use } from 'react';
import { prerender } from 'react-dom/static';

function Posts({ postsPromise }) {
  const posts = use(postsPromise);
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

function App({ postsPromise }) {
  return (
    <html lang="en">
      <head>
        <title>Blog</title>
      </head>
      <body>
        <h1>Blog</h1>
        <Suspense fallback={<p>Loading…</p>}>
          <Posts postsPromise={postsPromise} />
        </Suspense>
      </body>
    </html>
  );
}

const postsPromise = Promise.resolve([
  { id: 1, title: 'Hello' },
  { id: 2, title: 'Streaming' },
]);

const { prelude } = await prerender(<App postsPromise={postsPromise} />);
const html = await new Response(prelude).text();
// html contains <ul><li>Hello</li><li>Streaming</li></ul>
// and NOT <p>Loading…</p>

Handling a postponed prerender

postponed is the signal that the prelude is incomplete. It is JSON-serialisable on purpose, so it can be written to a cache or an object store at build time and read back on a different machine. Passing it to resume continues the same render where it stopped, producing the boundaries that were not finished. When postponed is null the prelude is the whole document and no resume step is needed.

Example · jsx
import { prerender } from 'react-dom/static';
import { resume } from 'react-dom/server';

// Build step: prerender the shared shell only.
const controller = new AbortController();
queueMicrotask(() => controller.abort());

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

const shellHtml = await new Response(prelude).text();
await saveToCache('/dashboard', { shellHtml, postponed });

// Request time: finish the personalised parts.
export default async function handler(request) {
  const { shellHtml, postponed } = await readFromCache('/dashboard');
  if (postponed === null) {
    return new Response(shellHtml, {
      headers: { 'content-type': 'text/html' },
    });
  }
  const stream = await resume(<App />, postponed);
  return new Response(stream, {
    headers: { 'content-type': 'text/html' },
  });
}

Discussion

  • Be the first to comment on this lesson.