renderToString
Renders a React tree to an HTML string on the server, without streaming or waiting for data.
const html = renderToString(reactNode, options?)Parameters
| Parameter | Type | Description |
|---|---|---|
$reactNode | ReactNode | A React node you want to render to HTML. For example, a JSX node like <App />. |
$optionsoptional | object | An object for server render. |
$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. |
Return value
stringAn HTML string.
Errors
Errors thrown while rendering a component are not caught by renderToString and propagate out of the call, so an uncaught error takes down the whole response rather than a subtree.
Changelog
- PHP 18.0.0Suspense support became limited rather than unsupported: if a component suspends, renderToString immediately emits its fallback as HTML instead of waiting. Streaming alternatives (renderToPipeableStream, renderToReadableStream) were introduced alongside it.
- PHP 19.0.0react-dom/static added prerender and prerenderToNodeStream, which wait for data to load before producing static HTML — the intended replacement for renderToString in static generation.
renderToString walks your tree once, synchronously, and hands back a complete HTML string. There is no stream, no chunking, and no point at which React yields control — the string is either finished or the call threw.
When to reach for it
Reach for it when you genuinely cannot stream: an email renderer, a template compiler, a test that asserts on markup, or a legacy server framework whose response API only accepts a string. In a real HTTP server you almost always want renderToPipeableStream instead, and for build-time static output you want prerender.
What trips people up
The big one is data. renderToString does not wait for anything. If a component suspends — a promise read with use, a lazy component — React does not pause; it immediately emits that boundary's fallback into the HTML. Your server response then contains spinners, the client hydrates, refetches, and the user sees a flash of loading state on a page that was supposed to be server-rendered. Nothing errors, so this is easy to ship by accident.
The output is hydratable, so pair it with hydrateRoot on the client and keep identifierPrefix identical on both sides or useId values will mismatch.
Example
import { renderToString } from 'react-dom/server';
import express from 'express';
function ProductPage({ product }) {
return (
<html lang="en">
<head>
<title>{product.name}</title>
</head>
<body>
<div id="root">
<h1>{product.name}</h1>
<p>{product.priceLabel}</p>
</div>
<script src="/main.js"></script>
</body>
</html>
);
}
const app = express();
app.get('/product/:id', (req, res) => {
// Data is fetched BEFORE rendering: renderToString will not wait for it.
const product = { name: 'Kettle', priceLabel: '$39.00' };
const html = renderToString(<ProductPage product={product} />, {
identifierPrefix: 'app-',
});
res.setHeader('Content-Type', 'text/html');
res.send('<!DOCTYPE html>' + html);
});
app.listen(3000);When to use it
- Rendering an HTML email body from React components in a job queue worker.
- Asserting on server output in a unit test without spinning up a stream.
- Serving SSR from a legacy framework whose handler only accepts a complete string body.
- Generating a static HTML snippet to embed inside a non-React CMS template.
Frequently asked questions
Why does renderToString return my Suspense fallback instead of the content?
Because renderToString does not wait for suspended data — it emits the nearest Suspense fallback the moment a component suspends. There is no option to change this. Load your data before calling renderToString and pass it in as props, or switch to prerender (build time) or renderToPipeableStream (request time), both of which wait for boundaries to resolve.
Is renderToString deprecated in React 19?
No, renderToString is not deprecated and still ships in react-dom/server. React 19 added prerender and prerenderToNodeStream as better options for static generation because they wait for data, and the streaming APIs remain the recommendation for servers, but renderToString continues to work for string-only environments like email rendering and tests.
What is the difference between renderToString and renderToStaticMarkup?
renderToString produces hydratable HTML that includes React's internal markers, while renderToStaticMarkup produces plain HTML that cannot be hydrated. Use renderToString when a client bundle will call hydrateRoot on the output; use renderToStaticMarkup when the page or fragment stays static forever.
More examples
Hydrating the string on the client
The server emitted markup with identifierPrefix: 'app-', so the client must pass the same prefix. If the prefixes differ, every useId value differs between server and client and React logs a hydration mismatch and discards the server HTML for that subtree. The serialized product data is embedded in the page because the client must render exactly the same tree the server did — refetching it would produce a mismatch on the first pass.
// client.js
import { hydrateRoot } from 'react-dom/client';
import ProductPage from './ProductPage.js';
const product = JSON.parse(
document.getElementById('__DATA__').textContent
);
hydrateRoot(
document.getElementById('root'),
<ProductPage product={product} />,
{ identifierPrefix: 'app-' }
);Rendering an email body
This is the case renderToString is still a good fit for: a one-shot string, no client, no streaming. Note that the output still contains React's internal hydration markers (comment nodes around Suspense boundaries and text). Email clients tolerate them, but if you want markup with none of that bookkeeping, use renderToStaticMarkup instead.
import { renderToString } from 'react-dom/server';
function ReceiptEmail({ order }) {
return (
<table width="600">
<tbody>
<tr>
<td>
<h1>Order {order.id}</h1>
</td>
</tr>
{order.lines.map((line) => (
<tr key={line.sku}>
<td>
{line.name} × {line.qty}
</td>
</tr>
))}
</tbody>
</table>
);
}
const order = {
id: 'A-1001',
lines: [
{ sku: 'kettle', name: 'Kettle', qty: 1 },
{ sku: 'mug', name: 'Mug', qty: 2 },
],
};
const body = renderToString(<ReceiptEmail order={order} />);
// body === '<table width="600">…</table>' with data-reactroot-free markup
// containing React's hydration comment markers.Why it should not be called in the browser
renderToString does run in the browser, but importing react-dom/server into client code pulls a second renderer into your bundle and produces markup full of hydration comments. Rendering into a detached DOM node with createRoot gives you the real DOM output, and flushSync forces it to be committed before innerHTML is read — without flushSync the render is asynchronous and innerHTML would still be empty.
// Not recommended — this ships the whole server renderer to the client.
import { renderToString } from 'react-dom/server';
function toHtml(node) {
return renderToString(node);
}
// Do this instead: render into a detached node and read its markup.
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
function toHtmlOnClient(node) {
const div = document.createElement('div');
const root = createRoot(div);
flushSync(() => {
root.render(node);
});
const html = div.innerHTML;
root.unmount();
return html;
}
Discussion