@react-email/render
@react-email/render takes a React element and returns an HTML string that email clients can display. That is the whole package: one async render function, plus toPlainText and a couple of helpers. Under the hood it runs React's server renderer, then post-processes the output for email, adding the XHTML doctype Outlook wants, keeping the markup free of things React injects for the browser, and optionally running the result through Prettier. Pass plainText: true and instead of HTML you get the text alternative, generated from the same component tree by html-to-text with a preset of selectors tuned for email. It is the piece of React Email you actually ship to production. The components, the dev preview server, and the CLI live in the separate react-email package, and this one only depends on React and react-dom as peers, so it runs in Node, in Bun, in Deno, and on edge runtimes.
If your product is a React codebase, this is the least painful way to keep email templates reviewable and typed, and the plainText option is worth the install on its own. It renders what you wrote and nothing more, so you still need the component set for tables and buttons and you still need to test in real clients.
Use it if
- You are building transactional email (receipts, password resets, invites) and want the templates to be components with props and TypeScript types instead of a pile of HTML strings with placeholder tokens
- You already write React, so the team can review an email template in a pull request the same way they review a page
- You need both the HTML and the text/plain alternative and do not want to maintain two copies that drift apart, which is what the plainText option is for
- Your sending code runs anywhere React does: the package ships separate builds for node, edge-light, workerd, deno, and convex through export conditions, so a Cloudflare Worker or a Vercel edge function works without a bundler workaround
- You want the rendering step to be a plain function you call, not a framework: render(element) gives you a string, and what you do with it (Resend, Nodemailer, SES, Postmark) is your business
- You expect React to protect you from email client quirks. It does not. This renders the markup you wrote, so a flexbox layout still collapses in Outlook, and the component library you need for tables and buttons is a separate install. React is the templating language here, not a compatibility layer
- Nobody on the team writes React. The payoff is reusing skills and tooling you already have, and without that you are adding React, react-dom, a build step, and a rendering pass to produce something MJML or a Handlebars template produces with less
- Your emails are edited by marketing, not engineers. A .tsx file in a git repo is worse than any drag-and-drop builder for that audience, and the TipTap-based editor the project added is new and not the same thing as a hosted campaign tool
- You need the component set and are following older docs. @react-email/components is deprecated on npm, every one of its 135 published versions carries the deprecation notice, and the components now ship from the react-email package itself. Installing the old one still works today and is a dead end
- You are counting bytes in a serverless function. render pulls in prettier and html-to-text as real dependencies, which is a lot of code for what is nominally a string builder, and they load whether or not you use pretty or plainText
- You want rendering to be cheap. Every send runs React SSR plus HTML post-processing, so a bulk campaign renders the same template thousands of times. Cache the output when the template has no per-recipient parts, because nothing here does that for you
Setup reality
npm install @react-email/render, and add react and react-dom yourself: both are peer dependencies accepting ^18 or ^19, and neither is installed for you. Node 20 or newer. If you also want the components (Html, Head, Preview, Body, Container, Button, Tailwind and the rest) and the local preview server, npm install react-email, which is a much bigger install: it carries esbuild, socket.io, tailwindcss, prismjs, and a Babel parser for the dev server, and a fresh project with React and both packages lands around 78 MB of node_modules on disk. That is a devDependency in most setups, but the components come from the same package, so if you import Button at runtime you are shipping it. Do not reach for @react-email/components: every version of it is deprecated and the components moved into react-email. Three things surprise people. render is async and returns a Promise even for a trivial template, because React 18 and 19 render through streams internally; awaiting it inside a loop over ten thousand recipients is slow for reasons that are not obvious from the call site. The output contains React's server-render comment markers such as the empty <!--$--> pair, which are harmless but show up in diffs and confuse snapshot tests. And your template must be a self-contained tree: hooks that need a client, context providers you forgot to include, and dynamic imports all fail at render time rather than at build time, so the errors arrive when you send.
Patterns
Render a component to an HTML stringrender-to-html
import { render } from "@react-email/render";
import { Receipt } from "./emails/receipt";
const html = await render(<Receipt name="Ada" total={4200} />);
// readable output for debugging or golden files
const pretty = await render(<Receipt name="Ada" total={4200} />, { pretty: true });render is async and always has been since 1.0; the old renderAsync was removed in 2.0.0 and render is a drop-in replacement for it. Do not use pretty in production: it runs Prettier over the string, which costs real time per send and adds whitespace that some clients render as gaps between table cells. The output starts with an XHTML 1.0 Transitional doctype because that is what Outlook's rendering engine expects.
Generate the text/plain part from the same componentplain-text-alternative
import { render } from "@react-email/render";
const el = <Receipt name="Ada" total={4200} />;
const [html, text] = await Promise.all([
render(el),
render(el, { plainText: true }),
]);
// text looks like:
// "HI ADA\n\nThanks for the order.\n\nView receipt https://example.com/x\n\n------"The conversion is opinionated: Heading becomes uppercase, a link's href is appended after its text, Hr becomes a row of dashes, and images are dropped. Anything marked data-skip-in-text="true" is skipped, which is how the Preview component keeps its padding characters out of the text part. You are rendering the tree twice, so build the element once and reuse it rather than calling your component function twice.
Hand the string to whatever sends your mailsend-with-a-provider
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
const el = <Welcome name="Ada" />;
const [html, text] = await Promise.all([render(el), render(el, { plainText: true })]);
await transporter.sendMail({
from: "hello@example.com",
to: "ada@example.com",
subject: "Welcome",
html,
text,
});
// Resend accepts the element directly and calls render for you
await resend.emails.send({ from, to, subject: "Welcome", react: el });Always send both parts. A message with only an HTML body scores worse with spam filters and is unreadable in text-only clients. The Resend SDK's react option is convenience, not a different code path; it calls the same render underneath, so you get identical output either way and lose the ability to inspect or cache the string.
A template is a component with propstyped-template
import { Html, Head, Preview, Body, Container, Heading, Text, Button, Hr } from "react-email";
interface ReceiptProps {
name: string;
total: number;
url: string;
}
export function Receipt({ name, total, url }: ReceiptProps) {
return (
<Html lang="en">
<Head />
<Preview>Your receipt for ${(total / 100).toFixed(2)}</Preview>
<Body style={{ backgroundColor: "#f6f6f6", fontFamily: "Arial, sans-serif" }}>
<Container style={{ backgroundColor: "#fff", padding: 24 }}>
<Heading>Hi {name}</Heading>
<Text>Thanks for the order.</Text>
<Button href={url} style={{ background: "#000", color: "#fff", padding: "12px 20px" }}>
View receipt
</Button>
<Hr />
</Container>
</Body>
</Html>
);
}Import the components from react-email, not from @react-email/components: that package is deprecated on npm in every published version and the components moved here. Preview sets the snippet shown next to the subject line in most inboxes and pads it with invisible characters so the next line of body text does not leak in. Style values are inline objects because email clients strip <style> blocks unpredictably; Body and Container do not give you a layout, they give you the table markup that survives Outlook.
Tailwind classes that get inlined at render timetailwind
import { Tailwind, Html, Body, Container, Text } from "react-email";
export function Digest() {
return (
<Html>
<Tailwind
config={{ theme: { extend: { colors: { brand: "#0f766e" } } } }}
>
<Body className="bg-gray-100 font-sans">
<Container className="bg-white p-6 rounded">
<Text className="text-brand text-lg font-bold">This week</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
}The Tailwind component converts class names into inline style attributes while the tree renders, so there is no build step and no stylesheet in the output. Anything that has no inline equivalent does not survive: responsive prefixes become a media query in a head style block that some clients drop, and pseudo-classes such as hover: mostly do nothing in email. Keep the utility set boring, and wrap the whole document once rather than nesting Tailwind blocks.
Rendering on Workers, Deno, and edge functionsedge-runtimes
// no configuration needed: the export map picks the build
// workerd / edge-light -> dist/edge
// deno / worker / browser -> dist/browser
// node (default) -> dist/node
export default {
async fetch(request: Request) {
const { render } = await import("@react-email/render");
const html = await render(<Receipt name="Ada" total={4200} url="/r/1" />);
return new Response(html, { headers: { "content-type": "text/html" } });
},
};The node build uses renderToPipeableStream and the edge and browser builds use renderToReadableStream, which is why there are three of them. If your bundler resolves the wrong condition you get a stream API that does not exist in that runtime, and the error names a React internal rather than the condition. Check the resolved path before assuming the package is broken. There is also a dedicated convex condition, added because the export order between it and node had to be fixed in 2.0.7.
Control how elements convert to textcustomize-plain-text
import { render, plainTextSelectors } from "@react-email/render";
const text = await render(el, {
plainText: true,
htmlToTextOptions: {
wordwrap: 78,
selectors: [
...plainTextSelectors,
{ selector: ".footer", format: "skip" },
{ selector: "h1", options: { uppercase: false } },
],
},
});Spread plainTextSelectors first. Passing a bare selectors array replaces the defaults rather than extending them, so you lose the img skip, the data-skip-in-text rule that hides Preview padding, and the link formatting, and your text output suddenly contains image alt text and duplicated URLs. A fix in 2.0.2 stopped custom selectors from clobbering the built-in ones, but you still want the spread. Word wrap has been off by default since 1.4.0.
The in-house text converter, and why it is called unstableunstable-text-conversion
import { render, unstableToPlainText } from "@react-email/render";
// via the render option
const text = await render(el, { plainText: true, unstableTextConversion: true });
// or directly on an HTML string you already have
const text2 = unstableToPlainText(html);Added in 2.1.0 to sidestep html-to-text and its dependency weight. It is genuinely unstable right now: on real output it can leak Outlook conditional comments and React's server-render markers into the text, so the result may contain fragments like [if mso] and $htmlhead. Diff it against the html-to-text output before switching, and note that unstableTextConversion and htmlToTextOptions are mutually exclusive in the types.
Do not re-render the same template per recipientcache-rendered-output
import { render } from "@react-email/render";
// bad: React SSR runs once per recipient
for (const user of users) {
await send(user.email, await render(<Newsletter issue={42} />));
}
// better: render once when nothing is per-recipient
const html = await render(<Newsletter issue={42} />);
for (const user of users) await send(user.email, html);
// when there is a personalised part, keep it to a token swap
const template = await render(<Newsletter issue={42} name="__NAME__" />);
for (const user of users) {
await send(user.email, template.replaceAll("__NAME__", escapeHtml(user.name)));
}Nothing in the library memoizes anything. A campaign to 50,000 addresses runs 50,000 full React renders plus HTML post-processing, which is usually the slowest part of the job. The token approach trades type safety for speed, so escape the substituted values yourself: React escaped them for you in the first version and does not in the second.
Testing templates without a mail clientsnapshot-tests
import { expect, test } from "vitest";
import { render } from "@react-email/render";
test("receipt links to the invoice", async () => {
const html = await render(<Receipt name="Ada" total={4200} url="https://x.test/i/1" />);
expect(html).toContain('href="https://x.test/i/1"');
expect(html).not.toContain("undefined");
});
test("plain text carries the amount", async () => {
const text = await render(<Receipt name="Ada" total={4200} url="https://x.test/i/1" />, {
plainText: true,
});
expect(text).toContain("$42.00");
});Assert on the parts that matter rather than snapshotting the whole string. The HTML contains React's server-render comment markers and the Preview component's block of invisible padding characters, so a full snapshot is enormous and churns on any React or library upgrade. Checking for the literal 'undefined' catches the classic bug where a missing prop renders as text instead of throwing.
Local preview with the CLIpreview-server
npm install --save-dev react-email
# emails live in ./emails by default, one default export per file
npx email dev
npx email dev --dir src/emails --port 4000
# export static HTML for review or for a design handoff
npx email export --outDir outThe dev server hot-reloads and renders each template in an iframe with a plain-text tab, which is the fastest feedback loop available short of sending yourself mail. It is a development tool: it pulls esbuild, socket.io, tailwindcss, and a Babel parser, so keep react-email in devDependencies unless you import components from it at runtime, in which case it has to be a real dependency. Each email file needs a default export or the server will not list it.
When the rendered HTML is empty or wrongdebug-broken-output
// hooks that need a browser will not run: this renders the initial state only
function Bad() {
const [n, setN] = useState(0);
useEffect(() => setN(1), []); // never runs during render
return <Text>{n}</Text>; // always 0
}
// context has to be inside the tree you pass to render
await render(
<ThemeProvider value={theme}>
<Receipt name="Ada" total={4200} url="/x" />
</ThemeProvider>
);
// find out what you actually produced
console.log(await render(el, { pretty: true }));This is server rendering, so useEffect never fires, refs are null, and anything touching window or document throws. A component marked "use client" in a Next.js app still renders here, but only its initial output. Several 2.0.x patches were about errors inside templates being swallowed or crashing the process instead of surfacing, so if you are on an early 2.0.x and seeing silently empty emails, upgrade before you start debugging your own code.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mjml | npm | You want a markup language purpose-built for email that compiles to table layouts with client quirks handled, and you do not need React or component props |
| maizzle | npm | You prefer writing HTML with Tailwind and a build pipeline that inlines CSS and runs email-specific transformers, rather than modelling emails as components |
| juice | npm | You already generate email HTML some other way and the only missing piece is inlining a stylesheet into style attributes |