Authoring email templates with React Email
Write transactional email templates with React Email, using JSX and Tailwind that the renderer turns into inbox-safe HTML.
In the last chapter you sent a real email through a verified domain, and the call took a React component as its body: react: <WelcomeEmail name="Ada" />. That component was a placeholder, <p>Welcome, {name}!</p>, with a comment promising the rest. This lesson writes the real template.
Three questions settle first. When that component renders, what HTML goes on the wire? Why can’t the team ship the same JSX it ships to the browser? And what API makes writing email feel like the React you already know, instead of hand-coding raw markup?
By the end you’ll have a typed emails/welcome.tsx: a template with a heading, body copy, and a call-to-action button, ready to pass to sendEmail unchanged. You already know JSX, components, typed props, and Tailwind; this lesson points them at a new target.
Why email forces a 2004-shaped HTML baseline
Section titled “Why email forces a 2004-shaped HTML baseline”When you build for the web, you target one rendering engine at a time, and a recent one: your users are on a current Chrome, Safari, or Firefox, and the few features that aren’t universal you can look up and decide deliberately. Email has no such luxury. The same message is parsed by Gmail on web, iOS, and Android, Apple Mail, Outlook on Windows and Mac, Yahoo, Proton, and whatever enterprise webmail your B2B customers standardized on a decade ago, with no shared modern baseline. Outlook on Windows spent years rendering email with the layout engine from Microsoft Word; newer builds use a browser engine, but the old installs are still in inboxes your message has to survive.
Almost everything you reach for to lay out a web page is unreliable in that matrix:
| CSS feature | Modern browser | Email (worst-client baseline) |
|---|---|---|
display: flex Flexbox | ||
display: grid CSS Grid | ||
--brand Custom properties | ||
@container Container queries | ||
vh, vw Viewport units |
What you can rely on is roughly HTML 4, table-based layout, styles written inline on each element, and a small subset of media queries. Call it 2004-shaped HTML: the markup a developer would have written before flexbox, because the worst client in your matrix still effectively lives in that era.
That gap is the trap. Write a normal <div className="flex gap-4"> for a two-column header and it works in your browser, then silently collapses to one stacked column in Gmail. The page you tested is not the page your user opens.
The whole chapter follows from this: React Email lets you write 2026 React while its renderer emits the 2004-shaped HTML the worst client still parses. You author with modern components and Tailwind, and the constraint gets satisfied underneath.
One alternative is worth naming. MJML (and its mjml-react binding) solved the same problem first, with its own XML-like syntax. It still wrings the last drop of compatibility out of ancient Outlook, but it’s a separate language that doesn’t share your app’s component model. For a 2026 SaaS already built on React, React Email is the default: same language, same JSX, same Tailwind.
How a template becomes a sent email
Section titled “How a template becomes a sent email”A React Email template is a server-rendered React component: no state, no effects, no event handlers. Rendered once on the server, render(<WelcomeEmail … />) (from the react-email package) returns a single string of email-safe HTML, every style inlined onto its element and the layout built from tables.
<Html><Body>
<Text>Welcome, {firstName}!</Text>
</Body></Html> <table><tr><td>
<p style="margin:0;font-size:16px">Welcome, Ada!</p>
</td></tr></table> Since its August 2025 update, the Resend SDK generates the plain-text part from what you pass it, not just the HTML. Every email goes out as a multipart/alternative message carrying both parts, and you get both just by handing the SDK your React node.
So you almost never call render yourself: the react prop on the send call runs the whole pipeline internally. The sendEmail wrapper from last chapter takes a react node, not a pre-rendered string:
await sendEmail({ to: user.email, subject: 'Welcome to YourApp', react: <WelcomeEmail firstName="Ada" verifyUrl={verifyUrl} />,});Pre-rendering and passing the result is strictly worse: extra work that discards the SDK’s plain-text generation, so you’d ship an HTML-only message by accident.
The plain-text part matters for screen readers, for clients that strip HTML, and as the fallback when Gmail truncates a long message; a later lesson covers it in full. For now, just remember that passing react gives it to you.
A mailbox can’t be trusted to fetch an external stylesheet, so styles are inlined per element at render time: no <link> to a stylesheet, no shared <style> block, every style riding along on the element it targets. This is also why the Tailwind component, later, compiles to inline styles instead of class names.
The React Email primitives
Section titled “The React Email primitives”Now the components. React Email ships a set of primitives, and the senior move is to reach for them instead of raw <div>, <table>, and <style>: each one carries the email-safe defaults and Outlook workarounds you’d otherwise hand-roll.
There are about fifteen, and one rule makes them stick: each primitive is the email-safe version of a web element you already know. Picture the web tag; the primitive is that tag, built to survive the inbox. We’ll go cluster by cluster, covering only what the welcome email needs.
The document shell: Html, Head, Body
Section titled “The document shell: Html, Head, Body”These map to the email’s <html>, <head>, and <body>. <Html> wraps the document, <Head> holds document-level tags like <Title> and custom <Font> declarations, and <Body> is the canvas everything renders onto. Every template starts wrapped in this trio.
Framing the column: Container
Section titled “Framing the column: Container”<Container> is the centered, max-width wrapper, the email equivalent of a page’s main content column. It defaults to 600px, the de-facto safe width across the client matrix: wider and Outlook clips, narrower wastes space.
Stacking and splitting: Section, Row, Column
Section titled “Stacking and splitting: Section, Row, Column”These three are the table layout, so you never write a <table> yourself. <Section> is a vertical band for stacking blocks down the page. <Row> paired with <Column> is the horizontal split, a logo left and a link right, or two cards side by side, and it’s how you lay out across when flexbox is off the table. You describe rows and columns; the renderer emits the cells.
Text: Heading, Text, Link
Section titled “Text: Heading, Text, Link”<Heading> takes an as prop to set its level: as="h1" for the message’s purpose, as="h2" for a subsection. <Text> is your paragraph: use it over a raw <p>, whose default margins and line-height vary wildly by client, while <Text> ships consistent ones. <Link> is the styled anchor.
The call to action: Button
Section titled “The call to action: Button”<Button href="..."> renders what the email world calls a bulletproof button : a call-to-action whose background, padding, and shape survive every client, including the Outlook fallback rendered in VML that the component generates for you. Reach for <Button> over a styled <a>: in Outlook a styled anchor loses its background and padding and degrades to plain blue link text, on the one element you most need to look like a button.
Images: Img
Section titled “Images: Img”<Img> needs src, alt, width, and height, and the dimensions have to be HTML attributes, not CSS. Outlook ignores CSS dimensions on images entirely, so without the attributes a 1200px logo renders at natural size, blowing past your 600px column and wrecking the layout. Set them as attributes, every image.
Inbox metadata: Preview
Section titled “Inbox metadata: Preview”<Preview> is the one beginners forget, and skipping it hurts every send. It renders a hidden element whose text becomes the preheader , the gray line the inbox shows next to your subject before the message is opened. Leave it blank and the client scrapes the first visible body text instead, usually a heading that repeats the subject, so the recipient sees “Welcome to YourApp / Welcome to YourApp” at the moment that decides whether they open it. Set a <Preview> on every transactional template.
A few more exist for situational use: <Hr> for a divider, and <CodeBlock> and <CodeInline> for templates that show code. The welcome email doesn’t need them.
Here’s how the core primitives nest, a minimal but complete skeleton with a shell, container, one section, a heading, body text, and a button. Step through it cluster by cluster.
<Html> <Head /> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Container> <Section> <Heading as="h1">Welcome to YourApp</Heading> <Text>Confirm your email address to get started.</Text> <Button href={verifyUrl}>Verify email</Button> </Section> </Container> </Body></Html>The document shell. <Html> wraps everything, <Head> holds document-level tags, and <Body> is the canvas. Every template opens with this trio.
<Html> <Head /> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Container> <Section> <Heading as="h1">Welcome to YourApp</Heading> <Text>Confirm your email address to get started.</Text> <Button href={verifyUrl}>Verify email</Button> </Section> </Container> </Body></Html><Container> frames the centered 600px column. Everything readable lives inside it.
<Html> <Head /> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Container> <Section> <Heading as="h1">Welcome to YourApp</Heading> <Text>Confirm your email address to get started.</Text> <Button href={verifyUrl}>Verify email</Button> </Section> </Container> </Body></Html><Section> is a vertical band for stacking blocks; the renderer turns it into table rows.
<Html> <Head /> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Container> <Section> <Heading as="h1">Welcome to YourApp</Heading> <Text>Confirm your email address to get started.</Text> <Button href={verifyUrl}>Verify email</Button> </Section> </Container> </Body></Html>The content: <Heading as="h1"> for the message’s purpose, <Text> for body copy, and <Button> for the call-to-action, a bulletproof button that survives Outlook.
<Html> <Head /> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Container> <Section> <Heading as="h1">Welcome to YourApp</Heading> <Text>Confirm your email address to get started.</Text> <Button href={verifyUrl}>Verify email</Button> </Section> </Container> </Body></Html><Preview> is hidden in the body but becomes the inbox preheader. Set it, or the client scrapes your first heading and duplicates the subject.
What’s worth retaining isn’t each tag’s exact syntax but which primitive does which job. Drill that by sorting each one under the job it does.
Each item is a React Email primitive. Drop it under the job it does. Drag each item into the bucket it belongs to, then press Check.
ContainerSectionRowColumnHeadingTextLinkButtonImgHtml / Head / BodyPreviewReusing your Tailwind classes in email
Section titled “Reusing your Tailwind classes in email”You have email-safe building blocks, but the skeleton above is unstyled. The web app styles everything with Tailwind, and the same utility classes work in email.
Wrap the body in <Tailwind> (from react-email) and write the classes you already know:
<Tailwind> <Body className="bg-zinc-50"> <Container className="mx-auto max-w-[600px]"> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Welcome to YourApp </Heading> </Container> </Body></Tailwind>Same vocabulary as the web build: text-2xl, font-semibold, bg-zinc-50, mx-auto, max-w-[600px]. The wrapper runs Tailwind 4 internally, so the utility set matches your app’s.
Two things differ, both from the render pipeline. First, what the wrapper does: at render time it compiles each class into inline styles on its element, and silently drops any it doesn’t recognize (with a console warning). It can’t ship a stylesheet, so it inlines.
Second, the supported subset is narrower than the web build, and the gaps are the modern-CSS features from the support matrix at the top of the lesson. The watch-outs:
- No
flex, nogrid. For horizontal layout, reach for<Row>and<Column>. In email, that is how you lay out across, not a workaround. space-*utilities and complex selectors don’t work. Styles inline onto individual elements, so there’s no stylesheet for a descendant selector to live in.- Arbitrary values work, like
max-w-[600px]andmt-[40px]. The arbitrary value syntax is first-class here. dark:needs head-tag plumbing a later lesson sets up. Don’t reach for the dark variant yet.
The flex/grid gap deserves a hard warning, because the tooling lies to you. The preview server in the next lesson renders in Chrome, where flex and grid look perfect. Then the message hits Gmail, which throws the flex away and collapses everything to default block flow, turning your two-column header into a single stack. A correct preview is not a correct inbox. Here’s the difference in code.
<div className="flex gap-4"> <Img src={logoUrl} alt="YourApp" width={120} height={32} /> <Link href={appUrl}>Open dashboard</Link></div>Renders in Chrome, collapses in Gmail. Gmail discards flex and gap, stacking the logo and link into one column. Nothing warns you; it breaks only in the inbox.
<Row> <Column> <Img src={logoUrl} alt="YourApp" width={120} height={32} /> </Column> <Column align="right"> <Link href={appUrl}>Open dashboard</Link> </Column></Row>Table cells, renders everywhere. <Row> and <Column> compile to the table layout every client understands, so the two-up header holds in Gmail, Outlook, and Apple Mail. Use Tailwind utilities for spacing inside each column.
<Tailwind> also takes a config prop, a Tailwind config object for wiring in your brand’s theme tokens. Bridging those tokens into email is the fiddly part, and where we go next.
Mirroring brand tokens into the email config
Section titled “Mirroring brand tokens into the email config”Your web app’s brand colors don’t reach your email templates automatically, for two reasons.
First, the two sides read their tokens from different places. In the styling chapter you defined design tokens the Tailwind 4 way: a @theme block in CSS, with colors in OKLCH. The <Tailwind> component can’t read that. It’s configured through a JavaScript object on its config prop, with no shared source. This isn’t an old-versus-new Tailwind gap; the theme.extend shape is identical either way. The web declares tokens in CSS, email wants them in JS, so you mirror your brand tokens by hand into a small config the templates import.
Second, the values themselves don’t transfer. Your web tokens are in OKLCH , and a client that can’t parse a color value drops the whole property rather than falling back: the brand color vanishes and the button renders with no background. So the email config needs plain hex (or RGB), not the OKLCH original.
Both points land in one hand-maintained file, emails/email-tailwind-config.ts: a single default export mirroring your brand tokens as hex under theme.extend.colors, keyed by the same names the web app uses, so bg-brand means the same thing in both places.
import { pixelBasedPreset } from 'react-email';
const emailTailwindConfig = { presets: [pixelBasedPreset], theme: { extend: { colors: { brand: '#4f46e5', 'brand-foreground': '#ffffff', muted: '#71717a', }, }, },};
export default emailTailwindConfig;Then every template passes it the same way:
<Tailwind config={emailTailwindConfig}> {/* …template… */}</Tailwind>One line is new: presets: [pixelBasedPreset], the email default. Tailwind’s spacing and sizing utilities are rem-based, so they respect the user’s font-size setting, the right call on the web. But some email clients ignore rem, so text-lg can render at an unexpected size. pixelBasedPreset re-bases every utility onto a fixed 16px scale. Include it in every email config.
No script syncs this file from your CSS tokens, so when the palette changes, you mirror the change by hand. For a handful of brand colors that’s a few lines you touch rarely, and it buys you brand colors that actually render.
The emails/ directory convention
Section titled “The emails/ directory convention”Where templates live is load-bearing, because the location couples to the preview server you’ll boot next lesson.
React Email scans an emails/ directory at your repo root by default. Each .tsx file is one default-exported template, a deliberate exception to the project’s named-exports rule that follows the one-concept-per-file convention you already use. The preview URL is derived from the file path, so moving or renaming a template silently breaks its preview.
Here’s the shape of the directory by the end of this lesson, with a peek at how it grows.
Directoryemails/
- welcome.tsx the running artifact you build this lesson
- email-layout.tsx shared shell + header + footer
- email-tailwind-config.ts the brand-token bridge
Directoryauth/ subfolders group related templates (React Email 6+)
- reset-password.tsx
Two details. Reach for subdirectories like auth/ only once you have enough related templates to warrant grouping, not at three files. And watch the casing: the file is email-layout.tsx (kebab-case, per the project’s file-naming rule), but the component it exports is EmailLayout (PascalCase). File and export follow different conventions on purpose, so keep them straight.
Templates render only from props
Section titled “Templates render only from props”What makes a template testable and previewable is one rule: the default export is a React component with typed props, and every dynamic value comes from props. The welcome email needs the recipient’s first name and a verification URL, so its contract is:
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};The companion rule is absolute: the template never reads environment variables, the session, or the database. The caller, a Server Action, fetches all of that and passes finished values in as props. The template is pure presentation.
Why so strict? A props-only template renders identically in three places: the preview server (fed mock props), a unit test (fed test props), and a real send (fed real props). No “if preview, do this; if production, do that” branching anywhere.
That mock data lives in the template too, as a static property:
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;PreviewProps ships realistic mock data with the template, so you preview a finished email without wiring up a real send. This is why email needs no separate Storybook: the “render in isolation with fake data” story lives in the template file.
Put together, the contract is typed props, a component that reads only from them, and PreviewProps:
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Html> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Heading as="h1">Welcome, {firstName}!</Heading> <Button href={verifyUrl}>Verify email</Button> </Body> </Html> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;The contract: two typed props, the first name and the verification URL. Everything dynamic the template is allowed to know.
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Html> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Heading as="h1">Welcome, {firstName}!</Heading> <Button href={verifyUrl}>Verify email</Button> </Body> </Html> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;The default export destructures its typed props and reads only from them; the caller supplies the values.
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Html> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <Heading as="h1">Welcome, {firstName}!</Heading> <Button href={verifyUrl}>Verify email</Button> </Body> </Html> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;Co-located mock data. The preview server picks this up; production passes real values. satisfies keeps it honest against the props type.
The boundary is what pays off. Of the things a template could touch, which belongs inside it, and which belongs in the Server Action that calls it?
You’re writing WelcomeEmail, and the body needs the recipient’s name and a verification link. Which line belongs inside the template?
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });const verifyUrl = `${process.env.APP_URL}/verify/${token}`;<Heading as="h1">Welcome, {firstName}!</Heading>const session = await auth();firstName straight from props belongs in the template; it’s pure presentation. The other three are data-fetching: the database lookup, the env var, and the session read. The Server Action does all of that and passes finished values in as props, which is what lets the same file render unchanged in the preview, a unit test, and a real send.Composing the shared chunks
Section titled “Composing the shared chunks”You could write the welcome email top to bottom, but the moment you write a second template, a password reset, say, you’d copy-paste the same shell. Most transactional emails share three things: a header (logo and product name), a body container with consistent padding, and a footer (legal mailing address, support link, year). Factor that shell out before the duplication spreads.
A single EmailLayout component owns the shared chrome, and templates compose into it:
<EmailLayout> <Section>{/* …this email's unique body… */}</Section></EmailLayout>The brand surface (colors, typography, the logo URL) lives in EmailLayout and nowhere else, drawing its colors from the emailTailwindConfig you built earlier. Change the logo once, and every email updates.
export default function WelcomeEmail({ firstName }: WelcomeEmailProps) { return ( <Html><Body><Container> <Header /* logo + product name, repeated */ /> <Text>Welcome, {firstName}!</Text> <Footer /* address + support link + year, repeated */ /> </Container></Body></Html> );}
export default function ResetPasswordEmail({ resetUrl }: ResetPasswordEmailProps) { return ( <Html><Body><Container> <Header /* logo + product name, repeated */ /> <Button href={resetUrl}>Reset password</Button> <Footer /* address + support link + year, repeated */ /> </Container></Body></Html> );}Every template re-declares the shell. A logo or footer-address change means editing every file, and they drift the moment one gets missed.
export function EmailLayout({ children }: { children: ReactNode }) { return ( <Html><Body><Container> <Header /* logo + product name, defined once */ /> {children} <Footer /* address + support link + year, defined once */ /> </Container></Body></Html> );}
export default function WelcomeEmail({ firstName }: WelcomeEmailProps) { return <EmailLayout><Text>Welcome, {firstName}!</Text></EmailLayout>;}The shell lives in one place. Each template carries only its body; the header, footer, and brand surface are defined once in EmailLayout. One edit updates every email.
Host your images, don’t embed them
Section titled “Host your images, don’t embed them”The header carries your first <Img>, the logo, so settle now how email handles images. The obvious shortcut is to inline the image as a base64 data URL : paste the whole image into the src so there’s nothing to host. Don’t, for two reasons.
The first is a hard wall: Gmail clips any message larger than 102 KB, and a base64-encoded logo can eat that budget by itself. Clipping doesn’t just hide the image; it cuts the message off and hides everything past the cut behind a “View entire message” link most people never click, burying your call to action or the footer’s legal address and support line.
The second: some clients strip base64 images outright, so even under the budget the logo may not appear.
The fix is to host the image at a public HTTPS URL and point src at it: a CDN, your project’s object storage (the R2 bucket a later unit sets up), or your marketing site’s assets directory. One logo URL, referenced across every template.
<Img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUg…" alt="YourApp" />Blows the 102 KB budget on its own. The encoded bytes bloat the message until Gmail clips it, and some clients strip data URLs outright, so the logo doesn’t appear.
<Img src="https://cdn.yourapp.com/logo.png" width={120} height={32} alt="YourApp" />Lean and Outlook-safe. One hosted URL, a few hundred bytes on the wire, with width/height as attributes so Outlook renders it at the right size.
And there’s the width/height-as-attributes rule again, on the logo where it matters first: without them Outlook renders the logo at full natural size and breaks your column. Attributes, every image.
Assembling the welcome email
Section titled “Assembling the welcome email”Here is the whole emails/welcome.tsx, built from the pieces above: the <Tailwind> wrapper and config, the <EmailLayout> shell, a <Preview>, the heading, body copy, the verify <Button>, and typed props with co-located PreviewProps.
import { Body, Button, Heading, Html, Preview, Section, Tailwind, Text,} from 'react-email';
import emailTailwindConfig from './email-tailwind-config';import { EmailLayout } from './email-layout';
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Tailwind config={emailTailwindConfig}> <Html lang="en"> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <EmailLayout> <Section> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Welcome, {firstName}! </Heading> <Text className="text-base text-zinc-700"> Thanks for signing up. Confirm your email address to get started. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify email </Button> </Section> </EmailLayout> </Body> </Html> </Tailwind> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;Shell and styling. Primitives import from react-email, wrapped in <Tailwind> with the brand-token config so every utility below resolves your colors and compiles to inline styles.
import { Body, Button, Heading, Html, Preview, Section, Tailwind, Text,} from 'react-email';
import emailTailwindConfig from './email-tailwind-config';import { EmailLayout } from './email-layout';
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Tailwind config={emailTailwindConfig}> <Html lang="en"> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <EmailLayout> <Section> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Welcome, {firstName}! </Heading> <Text className="text-base text-zinc-700"> Thanks for signing up. Confirm your email address to get started. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify email </Button> </Section> </EmailLayout> </Body> </Html> </Tailwind> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;The preheader. The inbox shows this gray line next to the subject before the message is opened.
import { Body, Button, Heading, Html, Preview, Section, Tailwind, Text,} from 'react-email';
import emailTailwindConfig from './email-tailwind-config';import { EmailLayout } from './email-layout';
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Tailwind config={emailTailwindConfig}> <Html lang="en"> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <EmailLayout> <Section> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Welcome, {firstName}! </Heading> <Text className="text-base text-zinc-700"> Thanks for signing up. Confirm your email address to get started. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify email </Button> </Section> </EmailLayout> </Body> </Html> </Tailwind> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;The shared shell. Header, footer, and brand chrome come from EmailLayout; this template supplies only its unique body.
import { Body, Button, Heading, Html, Preview, Section, Tailwind, Text,} from 'react-email';
import emailTailwindConfig from './email-tailwind-config';import { EmailLayout } from './email-layout';
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Tailwind config={emailTailwindConfig}> <Html lang="en"> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <EmailLayout> <Section> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Welcome, {firstName}! </Heading> <Text className="text-base text-zinc-700"> Thanks for signing up. Confirm your email address to get started. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify email </Button> </Section> </EmailLayout> </Body> </Html> </Tailwind> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;The content. A single <Heading as="h1">, body copy, and a bulletproof <Button> whose href is the verify URL from props.
import { Body, Button, Heading, Html, Preview, Section, Tailwind, Text,} from 'react-email';
import emailTailwindConfig from './email-tailwind-config';import { EmailLayout } from './email-layout';
type WelcomeEmailProps = { firstName: string; verifyUrl: string;};
export default function WelcomeEmail({ firstName, verifyUrl }: WelcomeEmailProps) { return ( <Tailwind config={emailTailwindConfig}> <Html lang="en"> <Preview>Confirm your email to finish setting up YourApp</Preview> <Body> <EmailLayout> <Section> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Welcome, {firstName}! </Heading> <Text className="text-base text-zinc-700"> Thanks for signing up. Confirm your email address to get started. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify email </Button> </Section> </EmailLayout> </Body> </Html> </Tailwind> );}
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;The contract and its mock data. Typed props plus co-located preview values, so one file renders in the preview, in a test, and in a real send, with no branching.
Pass this to sendEmail with a real firstName and verifyUrl and it ships to any inbox as both the HTML and the auto-derived plain-text part.
You’ve read this template as code, but you haven’t seen it. Does the heading wrap awkwardly at 600px? Is the button readable in dark mode? Is the preheader what you intended? The inbox is the only place those answers are real, and Chrome, where you’d naturally check, is exactly where flex and dark mode lie to you. The next lesson boots the preview server: the save-and-eyeball loop, the device and dark-mode toggles, and the test-send that catches what your browser won’t.
External resources
Section titled “External resources”The React Email docs are the canonical reference for the full primitive set. The rest make the lesson’s ideas concrete: the live client support matrix, and templates from brands you recognize.
The complete primitive reference — every component this lesson introduced, plus the situational ones, with props and examples.
The Tailwind wrapper, its config prop, pixelBasedPreset, and the supported-utility caveats.
The interactive support matrix this lesson opened with — search any HTML or CSS feature and see exactly which clients honor it.
Open-source templates recreating real emails from Stripe, Apple, GitHub and more — study how the primitives compose at full scale.