React Email
16. February, 2025 • 11 min read • Develop
Still tables, all the way down
Email markup never moved on. The HTML you write for an inbox in 2025 is roughly the HTML you would have written in 2003: nested tables, inline styles, and a quiet hope that Outlook does something reasonable. Every convenience I take for granted in the browser (components, types, a dev server that reloads) stops at the mailbox door.
React Email is an attempt to put those conveniences back. You write templates as React components, and it renders them down to the table soup that mail clients actually understand. The generated HTML is still horrible. The difference is that you no longer have to look at it.
I write React most days, so the pitch landed easily. The components now declare React 19 as a supported peer alongside 18, so if you’ve already moved to React 19 there’s no dependency wrestling involved. Below is what I found once I got past the readme.
What’s actually in the package
Two packages do the work, and it’s worth knowing which is which.
@react-email/components (0.0.33 at the time of writing) is the component library. It re-exports about twenty small packages: Html, Head, Body, Container, Section, Row, Column, Text, Heading, Button, Link, Img, Hr, Font, Preview, Markdown, CodeBlock, CodeInline and the Tailwind wrapper. It also re-exports render, so in practice one dependency covers you.
react-email (3.0.7) is the CLI and the preview app. It is a dev dependency, not something you ship.
The components are thin on purpose, and that’s the part I like. Unwrap the layout ones and there’s almost nothing there:
| Component | What it renders |
|---|---|
Section |
<table> with a single <tr> and <td> |
Row |
<table> with a <tr> |
Column |
<td> |
Container |
<table> capped at maxWidth: 37.5em |
Preview |
hidden <div> with maxHeight: 0 |
That 37.5em is the 600px everyone has silently agreed on. Preview holds the snippet text that shows up in the inbox list, padded with invisible characters so the client doesn’t drag your footer into the preview line.
None of that is clever. It is just the table boilerplate you would have written by hand, given a name and a prop signature. Which is the correct amount of abstraction for a problem this dumb.
Getting it running
npm install @react-email/components
npm install --save-dev react-emailTemplates go in an emails/ directory by default. Add a script:
{
"scripts": {
"email": "email dev --dir ./emails --port 3000"
}
}email dev starts a preview server that watches the directory and rebuilds on save. The other commands are email build and email start (build the preview app and serve it, useful if you want designers poking at templates on a staging URL) and email export, which writes the compiled templates to out/:
npx email export --outDir out --pretty
npx email export --outDir out --plainTextOne thing that caught me out: --dir is resolved relative to the project root, not to wherever you run the command from. If the preview server shows an empty list, that’s usually why.
A template with some meat on it
The tutorial examples are all “Hello world in a <Text>”, which tells you nothing. Here’s something closer to a real transactional mail, with typed props and a preview fixture:
// emails/welcome.tsx
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Text,
} from '@react-email/components';
type WelcomeEmailProps = {
name: string;
confirmUrl: string;
};
export const WelcomeEmail = ({ name, confirmUrl }: WelcomeEmailProps) => (
<Html lang="en">
<Head />
<Preview>Confirm your address and you are done</Preview>
<Body style={body}>
<Container style={container}>
<Text style={heading}>Hello {name}</Text>
<Text>
One more step. Confirm your address so we know the mailbox is real.
</Text>
<Section style={{ textAlign: 'center' }}>
<Button href={confirmUrl} style={button}>
Confirm my address
</Button>
</Section>
<Hr style={rule} />
<Text style={footnote}>
If you did not sign up, ignore this mail. Nothing happens.
</Text>
</Container>
</Body>
</Html>
);
WelcomeEmail.PreviewProps = {
name: 'Angelo',
confirmUrl: 'https://example.com/confirm/abc123',
} satisfies WelcomeEmailProps;
export default WelcomeEmail;
const body = {
backgroundColor: '#f4f4f5',
fontFamily: 'Helvetica, Arial, sans-serif',
};
const container = { backgroundColor: '#ffffff', padding: '24px' };
const heading = { fontSize: '20px', fontWeight: 'bold' };
const button = {
backgroundColor: '#111827',
color: '#ffffff',
padding: '12px 20px',
borderRadius: '4px',
textDecoration: 'none',
};
const rule = { borderColor: '#e4e4e7' };
const footnote = { fontSize: '12px', color: '#71717a' };PreviewProps is the bit worth stealing. The preview server reads that static property and renders the template with those values, so you get a realistic preview without a fake wrapper component. satisfies keeps it honest when you rename a prop.
Note that Button is just an <a> under the hood, so textDecoration: 'none' is on you. Older versions had pX/pY props for padding; those are gone, and normal padding in the style object is the way now.
Tailwind, and where it stops
There’s a Tailwind component that wraps your tree and rewrites utility classes into inline styles at render time:
import { Tailwind } from '@react-email/components';
<Tailwind
config={{
theme: {
extend: {
colors: { brand: '#111827' },
},
},
}}
>
<Body className="bg-zinc-100 font-sans">
<Container className="bg-white p-6">
<Text className="text-xl font-bold">Hello</Text>
</Container>
</Body>
</Tailwind>;The config prop takes a subset of a Tailwind v3 config object (theme, plugins, darkMode and a few others), so a shared theme file from your app mostly transplants.
Here is the caveat nobody puts in the headline. Anything that cannot be expressed as an inline style, so media queries, hover:, dark:, gets hoisted into a <style> block in the <head> instead. That works in clients which keep head styles, and it silently does nothing in the ones that strip them. Gmail’s web client is fine with it; several others are not. So responsive breakpoints in an email are a progressive enhancement, not a layout strategy. Build the thing to look correct at 600px first.
render() returns a promise now
This is the change most old tutorials get wrong. Since @react-email/render 1.0, render() is asynchronous:
declare const render: (
element: React.ReactElement,
options?: Options
) => Promise<string>;renderAsync still exists but is deprecated, and now just points at render. The reason is that React is moving away from renderToStaticMarkup, and an async renderer is what keeps Suspense and server components working.
The practical consequence: if you forget the await, you pass a Promise where a string is expected and send an email whose body reads [object Promise]. It fails quietly, which is the worst kind of failing.
The options are small:
const html = await render(<WelcomeEmail {...props} />, { pretty: true });
const text = await render(<WelcomeEmail {...props} />, { plainText: true });pretty beautifies the output (nice for snapshots, pointless in production, and it costs you bytes you may not have). plainText runs the HTML through html-to-text, and you can tune that with htmlToTextOptions. There’s also a plainTextSelectors export with sensible defaults if you want to build your own conversion.
Testing the templates
Templates are components, so they test like components. I covered the general approach in Testing Strategies in React; nothing here is special except the await.
npm install --save-dev vitest @vitejs/plugin-reactYou do need the React plugin, otherwise Vitest will not transform the JSX in your .tsx templates and you’ll get a parse error that looks like a Vitest bug and isn’t.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: { environment: 'node' },
});// emails/welcome.test.tsx
import { expect, test } from 'vitest';
import { render } from '@react-email/components';
import { WelcomeEmail } from './welcome';
const props = { name: 'Angelo', confirmUrl: 'https://example.com/c/abc' };
test('renders the confirmation link', async () => {
const html = await render(<WelcomeEmail {...props} />);
expect(html).toContain('https://example.com/c/abc');
expect(html).toContain('Hello Angelo');
});
test('has a usable plain text alternative', async () => {
const text = await render(<WelcomeEmail {...props} />, { plainText: true });
expect(text).toContain('Confirm my address');
expect(text).not.toContain('<td');
});I would not snapshot the full HTML. It changes every time a component package bumps a patch version, and you end up approving diffs you haven’t read. Assert on the things that would actually cause a support ticket: the link is correct, the name is interpolated, the plain text alternative is not empty.
Sending through AWS SES
React Email renders. It does not send. That part is yours, and SES is the cheap option if you already have an AWS account.
npm install @aws-sdk/client-ses// lib/send-welcome.tsx
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import { render } from '@react-email/components';
import { WelcomeEmail } from '../emails/welcome';
const ses = new SESClient({ region: 'eu-central-1' });
export async function sendWelcome(to: string, name: string, confirmUrl: string) {
const element = <WelcomeEmail name={name} confirmUrl={confirmUrl} />;
const [html, text] = await Promise.all([
render(element),
render(element, { plainText: true }),
]);
await ses.send(
new SendEmailCommand({
Source: 'hello@example.com',
Destination: { ToAddresses: [to] },
Message: {
Subject: { Data: 'Confirm your address', Charset: 'UTF-8' },
Body: {
Html: { Data: html, Charset: 'UTF-8' },
Text: { Data: text, Charset: 'UTF-8' },
},
},
})
);
}Two details that matter and are easy to miss.
The file has to be .tsx, not .ts. It contains JSX. I have seen that exact mistake copied around in half the blog posts on this subject.
Send both parts. A message with only an HTML body scores worse with spam filters and renders as nothing at all in a text-only client. Since render already gives you the plain text version, there is no excuse.
On the AWS side, a new account starts in the SES sandbox: 200 messages per 24 hours, one message per second, and every recipient address has to be verified first. You request production access through a support ticket, which takes a day or two. Before you do, set up DKIM signing on the sending domain and add SPF and DMARC records, because the request asks how you handle bounces and complaints and the honest answer needs to be “with a configuration set publishing to SNS”, not “I don’t”.
If you’d rather use the newer API, @aws-sdk/client-sesv2 has its own SendEmailCommand with a different parameter shape (FromEmailAddress, Content.Simple.…). Same idea, different nouns.
The parts that still hurt
React Email fixes the authoring experience. It does not fix email.
Classic Outlook on Windows still renders through the Word engine, so no flexbox, no grid, and background images need VML if you really want them. The new Outlook is on a web engine, which helps, but you cannot assume your recipients have it.
Gmail clips a message once the HTML passes about 102 KB and hides the rest behind a “view entire message” link. That is HTML only, images don’t count, but a Tailwind-heavy template with lots of repeated inline styles gets there faster than you’d think. Rendering without pretty helps.
Dark mode is a mess. Some clients invert your colours for you, some do it badly, some ignore prefers-color-scheme entirely. I have stopped trying to control it and just avoid pure white backgrounds with near-white text.
And the preview server is not a rendering test. It’s Chrome. It tells you your template compiles and roughly how it looks; it tells you nothing about Outlook. For that you still need Litmus, Email on Acid, or a folder of test accounts and some patience.
Where I’ve landed
I’d use it again. Not because emails became easy, but because the templates now live next to the rest of the code, get type-checked in CI, and can be reviewed in a pull request by someone who does not know what VML is.
Next thing on my list is wiring email export into the build so the compiled templates are artefacts rather than something rendered at request time. I have not decided yet whether that’s a real improvement or just me enjoying a build step
‘Till next time!