react-email is a component library for writing HTML emails in React. It takes a tree of React components, renders them to static HTML, prepends the XHTML 1.0 Transitional doctype, and produces something Outlook won't mangle.
Why I starred it
HTML email is a time capsule. You're still writing table layouts, fighting Outlook's Word-based renderer, and sprinkling <!--[if mso]> conditional comments everywhere. Most projects solve this with string templates and a prayer.
React Email applies a more systematic approach: encode the cross-client workarounds inside React components, expose a clean API, and let render() produce the final HTML. The component library ships @react-email/components as a single barrel package — Button, Section, Column, Tailwind, Font, Markdown, the lot.
What made me dig deeper was the Tailwind component. Using Tailwind inside an email template is a legitimately hard problem.
How it works
The monorepo is structured around packages/ — one package per component, plus render and tailwind as the two core infrastructure packages.
Render
packages/render/src/node/render.tsx is the entry point for server-side use. It dynamically imports react-dom/server and picks between renderToReadableStream (Bun/Edge runtimes) and renderToPipeableStream (Node), wrapping the tree in a Suspense boundary and a custom ErrorBoundary:
// packages/render/src/node/render.tsx
reactDOMServer.renderToReadableStream(
<ErrorBoundary>
<Suspense>{node}</Suspense>
</ErrorBoundary>,
{
progressiveChunkSize: Number.POSITIVE_INFINITY,
onError(error) { reject(error); },
},
)
.then(async (stream) => {
await stream.allReady;
return readStream(stream);
})
The progressiveChunkSize: Infinity is intentional — they want the full rendered output, not chunks, because they read the complete stream into a string and then bolt on the XHTML doctype:
const doctype = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" ...>';
const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
That doctype matters. It's what triggers quirks mode in Outlook's renderer, which ironically makes layout more consistent.
Tailwind
packages/tailwind/src/tailwind.tsx is the interesting one. Email clients strip <style> blocks, so Tailwind classes need to become inline styles. The Tailwind component does this at render time by walking the React tree twice.
First pass — collect all className values and register them with a Tailwind instance:
let classesUsed: string[] = [];
let mappedChildren = mapReactTree(children, (node) => {
if (React.isValidElement<EmailElementProps>(node)) {
if (node.props.className) {
const classes = node.props.className?.split(/\s+/);
classesUsed = [...classesUsed, ...classes];
tailwindSetup.addUtilities(classes);
}
}
return node;
});
Second pass — inline the generated CSS back onto each element, while injecting any non-inlinable rules (e.g. media queries) into the <head>:
const { inlinable: inlinableRules, nonInlinable: nonInlinableRules } =
extractRulesPerClass(styleSheet, classesUsed);
If you use a responsive class like sm:text-lg but there's no <Head> in the tree, it throws with a clear error message pointing you to the GitHub issue. That kind of explicit error is more useful than a silent rendering failure.
The tree walking itself lives in packages/tailwind/src/utils/react/map-react-tree.ts. It directly calls functional components by invoking them as plain functions — no hooks, no reconciler. This works because email templates don't use state or effects, but it's a known limitation. The comment in the source is honest about it: "This has a few issues with hooks, and the only solution is renderAsync here, which will probably be done in the future."
Button
packages/button/src/button.tsx shows how much work goes into one component. A clickable button in email is actually an <a> tag with carefully engineered padding — because Outlook ignores CSS padding on anchor elements.
The fix uses Outlook conditional comments with   (hair space) characters scaled via mso-font-width:
dangerouslySetInnerHTML={{
__html: `<!--[if mso]><i styl="mso-font-width:${
plFontWidth * 100
}%;mso-text-raise:${textRaise}" hidden>${' '.repeat(
plSpaceCount,
)}</i><![endif]-->`,
}}
There's a utility (px-to-pt.ts) that converts CSS pixels to typographic points (px * 3/4) because Outlook uses point-based metrics for text layout. These are the kind of details you'd discover after hours of testing, and they're now encoded as composable components.
Using it
pnpm install @react-email/components -E
A minimal transactional email:
import { Html, Head, Body, Section, Text, Button } from "@react-email/components";
const WelcomeEmail = ({ name }: { name: string }) => (
<Html>
<Head />
<Body style={{ fontFamily: "sans-serif" }}>
<Section>
<Text>Hey {name}, welcome aboard.</Text>
<Button href="https://example.com" style={{ background: "#000", color: "#fff", padding: "12px 20px" }}>
Get started
</Button>
</Section>
</Body>
</Html>
);
Render to HTML for sending:
import { render } from "@react-email/components";
const html = await render(<WelcomeEmail name="Florian" />);
// pass html to Resend, SendGrid, Nodemailer, SES, etc.
The preview-server package spins up a local dev server with hot reload for previewing templates in the browser — useful for iterating on layout without a full send cycle.
Rough edges
The Tailwind support doesn't handle all CSS-in-email edge cases. Media queries injected via nonInlinableRules still get stripped by many clients that block <style> tags (Gmail being the main one). You'll know when something breaks — the error messages are specific — but you still need to understand which clients support what.
The mapReactTree hook limitation is real. Templates that use hooks in sub-components won't render correctly through the Tailwind component's tree walking. Realistically, email templates don't use hooks, so this rarely bites you — but it's a design constraint worth knowing.
The @react-email/render package has three separate entry points (node, edge, browser) with subtly different behavior. The docs don't make this obvious; you discover it when your edge function behaves differently from your local dev setup.
Bottom line
If you're writing transactional emails in a TypeScript project, this is the most ergonomic approach available. The real value isn't just the component API — it's that years of cross-client hacks are encoded inside the components, so you don't have to rediscover them.
