renderToStaticMarkup
Renders a React tree to plain HTML with no hydration markers, for output that never becomes interactive.
const html = renderToStaticMarkup(reactNode, options?)Parameters
| Parameter | Type | Description |
|---|---|---|
$reactNode | ReactNode | A React node you want to render to HTML. For example, a JSX node like <Page />. |
$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. |
Return value
stringAn HTML string.
Errors
Errors thrown during rendering propagate out of the call; there is no onError callback and no error boundary recovery on the server path.
Changelog
- PHP 18.0.0Gained limited Suspense support: a component that suspends causes its fallback to be emitted immediately rather than throwing.
renderToStaticMarkup is renderToString with the hydration bookkeeping stripped out. You get the HTML a browser would show and nothing else — no comment markers delimiting text nodes or Suspense boundaries, no attributes React uses to reattach itself.
When to reach for it
Use it when the HTML is the final product and no React will ever run against it: transactional emails, PDF or print templates, RSS and sitemap fragments, an HTML snippet pasted into a CMS, or a static marketing page with no client bundle. It is a good fit for treating JSX purely as a typed templating language.
What trips people up
The output cannot be hydrated. If you call hydrateRoot against markup produced this way, React has no boundary information to work with and will warn and re-render the tree from scratch on the client — you pay for SSR and still get a client render. Pick renderToString the moment any interactivity is involved.
Like renderToString, it never waits for data: a suspended component emits its fallback into the output. Event handlers are simply dropped, since there is nothing to attach them to.
Example
import { renderToStaticMarkup } from 'react-dom/server';
function WelcomeEmail({ name, activationUrl }) {
return (
<html lang="en">
<body style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}>
<h1>Welcome, {name}</h1>
<p>Confirm your address to finish signing up.</p>
<p>
<a href={activationUrl}>Activate your account</a>
</p>
</body>
</html>
);
}
const html = renderToStaticMarkup(
<WelcomeEmail name="Ada" activationUrl="https://example.com/a/xyz" />
);
// html is plain markup with no React comment markers:
// '<html lang="en"><body style="font-family:Helvetica, Arial, sans-serif">…'
await mailer.send({
to: '[email protected]',
subject: 'Welcome',
html: '<!DOCTYPE html>' + html,
});When to use it
- Building transactional email HTML from React components in a queue worker.
- Generating an OG image or PDF template that a headless browser will screenshot.
- Producing an HTML fragment to store in a CMS field or an RSS item description.
- Rendering a fully static landing page that ships no JavaScript bundle at all.
Frequently asked questions
Can I hydrate renderToStaticMarkup output?
No. renderToStaticMarkup deliberately omits the comment markers and boundary metadata that hydrateRoot relies on, so hydration fails and React re-renders the whole tree on the client. Use renderToString for any markup that a client bundle will hydrate.
How do I render React to HTML for an email?
Use renderToStaticMarkup — it produces clean markup with no React comment nodes, which is what email clients want. Prepend your own doctype, inline your CSS as style props since email clients ignore stylesheets, and remember that event handlers are dropped entirely.
Does renderToStaticMarkup wait for data to load?
No. Like renderToString, it emits a Suspense fallback the instant a component suspends rather than waiting. Fetch your data first and pass it in as props, or use prerender if you need an API that resolves all boundaries before returning.
More examples
Comparing the two string renderers
The only difference in this tree is the comment markers. renderToString inserts <!-- --> around interpolated text so that hydrateRoot can tell where one text node ends and the next begins. renderToStaticMarkup omits them, which is why its output is cleaner but no longer hydratable — the client would have no way to line up the text nodes it creates with the ones already in the document.
import { renderToString, renderToStaticMarkup } from 'react-dom/server';
function Greeting({ name }) {
return <p>Hello, {name}!</p>;
}
console.log(renderToString(<Greeting name="Ada" />));
// <p>Hello, <!-- -->Ada<!-- -->!</p>
console.log(renderToStaticMarkup(<Greeting name="Ada" />));
// <p>Hello, Ada!</p>A build step that writes static pages
Each post becomes a self-contained HTML file with no script tag, so there is no client bundle and nothing to hydrate. The posts are sorted before the loop so repeated builds emit files in a stable order — directory iteration order is not guaranteed, and unsorted input makes build output diff noisily. Note that renderToStaticMarkup does not emit the doctype; you prepend it yourself.
import { renderToStaticMarkup } from 'react-dom/server';
import { writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
function Article({ post }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>{post.title}</title>
<link rel="canonical" href={`https://example.com/${post.slug}`} />
</head>
<body>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
</article>
</body>
</html>
);
}
const posts = [
{ slug: 'first', title: 'First post', bodyHtml: '<p>Hello.</p>' },
{ slug: 'second', title: 'Second post', bodyHtml: '<p>Again.</p>' },
];
// Sorted so the build output is byte-identical on every run.
for (const post of [...posts].sort((a, b) => a.slug.localeCompare(b.slug))) {
const html = '<!DOCTYPE html>' + renderToStaticMarkup(<Article post={post} />);
const dir = path.join('dist', post.slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'index.html'), html, 'utf8');
}The mistake: hydrating static markup
This is the single most common misuse. Nothing fails loudly at render time — the page looks correct until hydration runs, at which point React discards the server DOM and rebuilds it. The symptom is a visible flicker and lost SSR benefit, with a hydration warning in the console. renderToStaticMarkup is only correct when no client-side React touches the output.
// server.js — WRONG for an interactive page
import { renderToStaticMarkup } from 'react-dom/server';
const html = renderToStaticMarkup(<App />);
// client.js
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />);
// Warning: An error occurred during hydration. The server HTML was
// replaced with client content.
// Fix: use renderToString on the server so the markup carries
// the boundary information hydrateRoot needs.
import { renderToString } from 'react-dom/server';
const hydratableHtml = renderToString(<App />);
Discussion