React Email: Building HTML Emails With React Components
React Email lets you build HTML emails with real React components — no table soup, no inline CSS hell. Here's how to set it up and ship emails that actually render.
Why HTML Email Is Still a Mess in 2026
Email clients are the browser compatibility nightmare that never went away. Outlook on Windows still uses the Word rendering engine — yes, Word — which means flexbox is gone, grid is gone, and you're back to nesting <table> tags like it's 2004. Gmail strips <style> blocks in certain contexts. Apple Mail does what it wants. It's genuinely chaotic.
Honestly, the traditional workflow is painful enough that a lot of teams just outsource transactional emails to drag-and-drop builders and call it done. That's fine until your designer wants pixel-perfect control, or your brand guidelines have changed, or you need to conditionally render sections based on user state. Suddenly you're hand-editing HTML files full of inline styles and cellpadding="0" and losing your mind.
React Email (from the team at Resend, released in late 2022 and hitting v2.x by 2024) is the sanest answer to this problem that exists right now. You write JSX. You get back email-safe HTML. The library handles the table nesting, the inline style conversion, the MSO conditional comments — all of it. You stay in your component model.
Worth noting: React Email isn't magic. It doesn't make Outlook support flexbox. What it does is give you a typed, composable, version-controlled way to author emails, and a set of primitive components that already know how to produce rendering-safe HTML output across the major clients.
Setting Up React Email
You'll need Node 18+ and a React project — Next.js is the most common host, but it works fine in any Node environment. Install the core package and the component primitives:
npm install react-email @react-email/components
# or with pnpm
pnpm add react-email @react-email/componentsReact Email ships a local dev server that previews your email templates in a browser with hot reload. Add it to your package.json:
{
"scripts": {
"email:dev": "email dev --dir emails --port 3001"
}
}Create an emails/ directory at your project root and drop your first template in there. The preview server picks up any .tsx or .jsx file in that directory automatically. You're looking at a working email previewer in about 60 seconds. That said, make sure you're running email dev from the right working directory — it doesn't walk up the tree to find the emails/ folder.
One more thing — if you're in a monorepo (Turborepo, Nx, etc.), add react-email to the workspace that contains your emails/ dir, not the root. The CLI needs direct access to the files.
The Component Primitives You'll Actually Use
@react-email/components is a single package that re-exports everything. The primitives map to safe HTML constructs: <Html>, <Head>, <Body>, <Container>, <Section>, <Row>, <Column>, <Text>, <Link>, <Button>, <Img>, <Hr>, and <Preview>. That <Preview> component is a small delight — it renders the hidden preheader text that shows up in Gmail's inbox list next to the subject line.
Here's a minimal transactional email — the kind you'd send after a user signs up:
import {
Html, Head, Preview, Body, Container,
Section, Text, Button, Hr, Img
} from '@react-email/components';
interface WelcomeEmailProps {
username: string;
confirmUrl: string;
}
export default function WelcomeEmail({ username, confirmUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to Empire UI, {username} — confirm your email</Preview>
<Body style={bodyStyle}>
<Container style={containerStyle}>
<Img
src="https://empire-ui.com/logo.png"
width={120}
height={40}
alt="Empire UI"
/>
<Section>
<Text style={headingStyle}>Hey {username},</Text>
<Text style={textStyle}>
Thanks for signing up. Hit the button below to confirm your
email and start building.
</Text>
<Button href={confirmUrl} style={buttonStyle}>
Confirm Email
</Button>
</Section>
<Hr style={hrStyle} />
<Text style={footerStyle}>
You received this because you signed up at empire-ui.com.
</Text>
</Container>
</Body>
</Html>
);
}
const bodyStyle = { backgroundColor: '#0f0f0f', fontFamily: 'sans-serif' };
const containerStyle = { maxWidth: '600px', margin: '0 auto', padding: '40px 24px' };
const headingStyle = { fontSize: '24px', color: '#ffffff', fontWeight: '700' };
const textStyle = { fontSize: '16px', color: '#a1a1aa', lineHeight: '1.6' };
const buttonStyle = {
backgroundColor: '#7c3aed',
color: '#ffffff',
padding: '12px 24px',
borderRadius: '8px',
fontWeight: '600',
display: 'inline-block',
};
const hrStyle = { borderColor: '#27272a', margin: '32px 0' };
const footerStyle = { fontSize: '12px', color: '#52525b' };Notice the styles are plain objects, not Tailwind classes. React Email inlines everything at render time, so the compiled output your email provider receives has style="font-size:16px;color:#a1a1aa" baked directly into each element. That's what makes it safe across clients.
In practice, you can still use Tailwind for the dev preview UI, but for the actual email output you need inline styles or the tailwind adapter from @react-email/tailwind, which processes Tailwind classes and converts them to inline styles automatically. It works well for simple utilities; complex responsive stuff will still need manual overrides for Outlook.
Sending With Resend
React Email and Resend are made by the same team, so the integration is about as frictionless as it gets. Install the Resend SDK:
npm install resendThen in a Next.js Route Handler (or any server-side Node context), render your component to HTML and hand it off:
// app/api/send-welcome/route.ts
import { Resend } from 'resend';
import { render } from '@react-email/render';
import WelcomeEmail from '@/emails/WelcomeEmail';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: Request) {
const { username, email, confirmUrl } = await req.json();
const html = await render(
<WelcomeEmail username={username} confirmUrl={confirmUrl} />
);
const { data, error } = await resend.emails.send({
from: 'Empire UI <hello@empire-ui.com>',
to: email,
subject: 'Confirm your Empire UI account',
html,
});
if (error) return Response.json({ error }, { status: 500 });
return Response.json({ id: data?.id });
}render() is a plain async function — it takes your JSX, runs it through React's server renderer, and returns an HTML string. No special setup, no bundler magic. You can use the same pattern with Nodemailer, SendGrid, Postmark, or any provider that accepts raw HTML.
Quick aside: render() also accepts a { plainText: true } option that strips all HTML and returns a plain-text version. Always include both when sending through Resend — it helps deliverability and is required by some spam filters.
Structuring Your Email Templates at Scale
Once you have more than three or four email templates, the emails/ directory gets messy fast. A structure that holds up at scale:
emails/
components/
EmailLayout.tsx # shared wrapper (Body, Container, logo, footer)
EmailButton.tsx # branded CTA button
EmailDivider.tsx # styled Hr
welcome.email.tsx
password-reset.email.tsx
invoice.email.tsx
magic-link.email.tsxThe EmailLayout component is the most valuable piece you'll write. It takes preview and children as props and wraps everything in the <Html>, <Head>, <Preview>, <Body>, and <Container> boilerplate — plus your logo, dark/light background, and footer. Every individual template just composes inside it. When your brand guidelines change (and they will), you update one file.
Look, the temptation is to hardcode things like the company address, unsubscribe URL, and support email in every template. Don't. Pull those into a shared emailConfig.ts constant and import it everywhere. You'll thank yourself when the legal team changes the registered address.
For component-level documentation and visual regression, this pattern pairs well with a Storybook setup — the storybook component library guide covers the test-driven approach that works equally well for email components as it does for UI ones.
Testing and Rendering Across Clients
The React Email preview server is great for fast iteration, but it only shows you Chrome. Real email testing means checking Outlook 2019, Outlook on Mac (which uses WebKit, so it's totally different from Windows Outlook), Gmail web, Gmail Android, Apple Mail on iOS 17, and Samsung Email. That's six distinct rendering environments before you've even thought about dark mode.
Litmus and Email on Acid both offer screenshot-based testing across 90+ clients. They're not cheap ($99–$149/month), but if transactional email is core to your product they're worth it. Free alternative: use the Can I Email site to look up CSS property support before you write it — it'll save you from a lot of surprises.
For automated regression testing in CI, render your templates to HTML strings and snapshot them. Jest's toMatchInlineSnapshot() or toMatchSnapshot() works perfectly:
import { render } from '@react-email/render';
import WelcomeEmail from '../emails/WelcomeEmail';
test('WelcomeEmail renders correctly', async () => {
const html = await render(
<WelcomeEmail username="testuser" confirmUrl="https://example.com/confirm" />
);
expect(html).toMatchSnapshot();
});This won't catch visual bugs, but it will catch the moment someone accidentally removed a critical CTA or broke the layout structure. Pair snapshot tests with a Litmus integration in staging and you've got a solid safety net. Worth noting: run the render function in Node, not jsdom — some React Email internals behave differently in browser environments.
Dark Mode in Email (Yes, It's a Thing)
Apple Mail, Outlook 2019+, and Gmail on iOS all support dark mode email rendering. Apple Mail goes furthest — it will aggressively invert colors on emails that don't declare dark mode support, turning your carefully chosen dark purple into a washed-out light purple on a dark background. Not ideal.
React Email lets you inject a <style> block inside <Head> that targets dark mode via media queries. The catch: only some clients respect it. For Apple Mail and Gmail iOS, this works well:
<Head>
<style>{`
@media (prefers-color-scheme: dark) {
.email-body { background-color: #0f0f0f !important; }
.email-text { color: #e4e4e7 !important; }
.email-card { background-color: #18181b !important; }
}
`}</style>
</Head>Add className attributes to your components alongside style objects. The className does nothing in clients that strip <style> blocks (which inline the styles anyway), but in Apple Mail it targets the right elements. It's a dual-track approach: inline styles as the baseline, CSS class overrides for dark mode. Not pretty, but it works.
In practice, the cleanest strategy for most teams is designing emails that look intentional in both light and dark contexts from the start — think neutral backgrounds (#0f0f0f dark, #ffffff light) with high-contrast text, rather than relying on colorful gradients that invert badly. If you're going all-in on branded visual design with aurora effects and gradient text (the kind you'd build with Empire UI's component library), email is probably not where you do it — save the visual flair for your web UI.
FAQ
No. React Email just renders JSX to an HTML string — you can pass that string to any email provider: Nodemailer, SendGrid, Postmark, AWS SES, whatever. Resend is the most frictionless option because it's built by the same team, but there's no dependency.
Yes, via the @react-email/tailwind package, which converts Tailwind utility classes to inline styles at render time. It covers most utilities well, but complex responsive or state-based classes won't translate — you'll need inline style fallbacks for those.
Partially. You can inject @media (prefers-color-scheme: dark) CSS via a <style> block in <Head>, which Apple Mail and Gmail iOS respect. Outlook on Windows ignores it entirely. The dual-track approach — inline styles as baseline, CSS class overrides for dark mode — is the standard workaround.
Run email dev from the React Email CLI and it starts a local preview server on port 3000 (configurable) that hot-reloads as you edit. It shows desktop and mobile previews side by side in your browser — no email send required.