mrkeyoor.com_
Thu 06 Aug 15:38 UTC
npmWeb Frontendupdated 06 Aug 2026

@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.

Verdict

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.

API stability4/5The surface is tiny and has settled: 1.0 made render async and deprecated renderAsync, 2.0.0 removed renderAsync outright with a one-line migration, and 2.x has been patch releases plus the additive unstableToPlainText. The deduction is for the churn around it rather than in it: the components package was deprecated and moved, which is the kind of change that breaks imports in code that only ever called render
Docs4/5react.email/docs has a page per component with props tables and live previews, integration examples for Resend, Nodemailer, SendGrid, Postmark, SES, Mailgun, and several more, and a utilities section covering render and toPlainText. What is thin is the operational side: caching rendered output, what the SSR comment markers in the HTML are, and which runtime build the export map will pick for you
Maintenance5/5Backed by Resend as a commercial product's front door, repo pushed 2026-08-05, 2.1.0 released 2026-07-10, and a changelog with a genuine entry per patch rather than version bumps. Only 2 open issues out of 27 open issues and PRs across the whole monorepo, which is an unusually clean tracker for 19.5k stars and suggests issues get closed rather than accumulated
Ecosystem4/5About 10.5M downloads a week for render alone, with the component set, a CLI preview server, a TipTap-based editor, and worked examples for ten sending providers in the same repo. It is effectively the default for React email templating. The reservation is that the surrounding pieces are moving: the components package was deprecated in favour of the react-email package, so third-party guides and starters go stale faster than the API does

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
Skip it if

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 out

The 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

PackageRegistryPick it when
mjmlnpmYou 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
maizzlenpmYou prefer writing HTML with Tailwind and a build pipeline that inlines CSS and runs email-specific transformers, rather than modelling emails as components
juicenpmYou already generate email HTML some other way and the only missing piece is inlining a stylesheet into style attributes