JSX for the email DOM
Authoring transactional email templates with React Email, writing modern JSX and Tailwind that the renderer turns into the inbox-safe HTML every mail client understands.
In the last chapter you sent a real email through a verified domain, and the call that sent it took a React component as its body: react: <WelcomeEmail name="Ada" />. That component was a one-line placeholder, <p>Welcome, {name}!</p>, with a comment promising the real thing would arrive later. This lesson builds the real thing.
It answers the questions you’d raise in a design review before writing a single template. When that component renders, what HTML actually goes on the wire? Why can’t the team just ship the same JSX it ships to the browser? And what API makes writing email feel like writing the React you already know, instead of hand-coding markup from twenty years ago?
By the end you’ll have a typed emails/welcome.tsx, a proper template with a heading, body copy, and a call-to-action button, that you could pass to sendEmail unchanged. You already know JSX, components, typed props, and Tailwind. The whole job of this lesson is to point those tools at a new target.
Why email HTML is its own subset
Section titled “Why email HTML is its own subset”Start with the constraint, because every component you’re about to meet exists to satisfy it. Without the constraint, those components look like pointless ceremony.
When you build for the web, you build for one rendering engine at a time, and that engine is recent. Your users are on a current Chrome, Safari, or Firefox, and the few features that aren’t universal you can look up and decide about deliberately. Email has no such luxury. The same message you send is parsed by Gmail on the web, Gmail on iOS and Android, Apple Mail on macOS and iOS, Outlook on Windows, Outlook on Mac, Yahoo, Proton, plus whatever enterprise webmail your B2B customers’ IT departments standardized on a decade ago. There is no shared modern baseline across that matrix. Outlook on Windows spent years rendering email with the layout engine from Microsoft Word. Newer builds use a browser engine now, but the old installs don’t disappear, and your message has to survive them too.
Almost everything you reach for to lay out a web page is unreliable in that matrix. The following table lines up the tools you use every day against whether they can be trusted in an email.
| 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 |
So what can you rely on? Roughly: HTML 4, table-based layout, styles written inline on each element, and a small subset of media queries. That’s the interoperable baseline, what we’ll call 2004-shaped HTML. It’s the markup a developer would have written before flexbox existed, because the worst client in your matrix still effectively lives in that era.
That gap sets the trap. If you write a normal <div className="flex gap-4"> for a two-column header, it works perfectly in your browser and silently collapses to a single stacked column the moment it hits Gmail. The page you tested is not the page your user opens.
The thesis of the whole chapter follows from that: React Email lets the team write 2026 React while its renderer emits the 2004-shaped HTML the worst client still parses. You author with modern components, and it hands the mailbox the ancient markup the mailbox understands. You keep your tools, and the constraint gets satisfied underneath.
One alternative is worth naming so you understand the choice. MJML (and its mjml-react binding) is the older tool that solved the same problem with its own XML-like syntax. It’s still excellent for squeezing the last drop of compatibility out of ancient Outlook, but it’s a separate language to learn, and it 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. We’ll use it for the rest of the unit and not look back.
From JSX to a message on the wire
Section titled “From JSX to a message on the wire”Everything else hangs on one mental model. Get it right and the react prop, the plain-text version, and “no CSS file is loaded” all stop being surprising.
A React Email template is a server-rendered React component. It has no state, no effects, and no event handlers; it renders once, on the server, straight to a string of HTML. The function that does this is render (from the react-email package), and the detail that matters is what it returns. render(<WelcomeEmail … />) gives you a single HTML string : email-safe HTML, with every style inlined onto its element and the layout built from tables.
The diagram below walks the pipeline once, step by step.
<Html><Body>
<Text>Welcome, {firstName}!</Text>
</Body></Html> <table><tr><td>
<p style="margin:0;font-size:16px">Welcome, Ada!</p>
</td></tr></table> Slow down on the fourth step, because a genuinely useful senior reflex lives there. The Resend SDK doesn’t just take your HTML. Since its August 2025 update it automatically generates the plain-text part from whatever you pass it. Every legitimate email goes out as a multipart/alternative message with both a text/html and a text/plain part, and you get both for free just by handing the SDK your React node.
The reflex follows: in your application code, you almost never call render yourself. The react prop on the send call runs the whole pipeline internally. Look at the wrapper you built last chapter. sendEmail takes a react node, not a pre-rendered string:
await sendEmail({ to: user.email, subject: 'Welcome to YourApp', react: <WelcomeEmail firstName="Ada" verifyUrl={verifyUrl} />,});You pass the component, not a string. Calling await render(...) at the send site and passing the result would be strictly worse: it’s extra work, and it throws away the SDK’s automatic 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 for the fallback when Gmail truncates a long message. It gets its own treatment in a later lesson. For now the only thing to carry forward is that you get it for free by passing react.
One last consequence of this pipeline shapes how the next sections look: styles are inlined per element, at render time. There is no <link> to a stylesheet and no shared <style> block that the client loads. This isn’t a quirk; it’s forced. A mailbox can’t be trusted to fetch and apply an external stylesheet, so every style has to ride along on the element it targets. Keep that fact in your pocket, because it explains why the Tailwind component, later, compiles to inline styles instead of class names.
The component vocabulary
Section titled “The component vocabulary”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>, because each one carries the email-safe defaults and the Outlook workarounds you’d otherwise have to hand-roll and remember.
There are about fifteen of them. The wrong way to learn them is as a flat reference table you skim and forget, so we won’t do that. The trick that makes them stick is this: each primitive is the email-safe version of a web element you already know. Lead with the web tag in your head, and the primitive is just “that, but it survives the inbox.” We’ll go cluster by cluster (shell, layout, text, the call-to-action, images, and inbox metadata), covering only the ones the welcome email actually needs.
The document shell: Html, Head, Body
Section titled “The document shell: Html, Head, Body”These are exactly what they sound like: the email’s <html>, <head>, and <body>. <Html> wraps the whole document (and later carries the lang attribute). <Head> is where document-level tags live: the <Title>, custom <Font> declarations, and the dark-mode meta tags a later lesson adds. <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 wide, and that number isn’t arbitrary: 600px is the de-facto safe column width across the whole client matrix. Go wider and Outlook starts clipping; narrower wastes space. Treat it as the convention, not a magic value you need to second-guess.
Stacking and splitting: Section, Row, Column
Section titled “Stacking and splitting: Section, Row, Column”Here’s the payoff of all that table talk: these three primitives are the table layout, so you never write a <table> yourself. <Section> is a vertical band you use to stack blocks down the page. <Row> paired with <Column> is the horizontal split: a logo on the left and a link on the right, or two product cards side by side. This is how you lay out horizontally when flexbox is off the table. You describe rows and columns, and the renderer emits the table cells underneath.
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. (Heading order is an accessibility concern a later lesson digs into; for now, just know as is how you pick the level.) <Text> is your paragraph. Use it over a raw <p> because <p> inherits whatever margins and line-height each client defaults to, which vary wildly, while <Text> ships sensible, consistent ones. <Link> is the styled anchor.
The call to action: Button
Section titled “The call to action: Button”This is the headline primitive of the bunch. <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. The senior call is simple: for any real call-to-action, reach for <Button> over a styled <a>. A styled anchor loses its background color and padding in Outlook 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 four things: src, alt, width, and height. The width and height are non-negotiable, and the reason is a watch-out worth burning in now: they have to be HTML attributes, not CSS. Outlook ignores CSS dimensions on images entirely. Without the attributes it renders the image at its natural pixel size, so a 1200px logo blows straight past your 600px column and wrecks the layout. Set them as attributes, every time.
Inbox metadata: Preview
Section titled “Inbox metadata: Preview”<Preview> is the one beginners forget, and forgetting it quietly hurts every single send. It renders a hidden element whose text becomes the preheader , the gray preview line the inbox shows next to your subject before the message is opened. Leave it blank and the client scrapes the first visible text in your body instead, which is almost always a header or greeting that just repeats the subject. The recipient then sees “Welcome to YourApp / Welcome to YourApp”, wasting inbox space at the exact 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 a code or snippet (a one-time login code, say). Reach for them when the content calls for it; the welcome email doesn’t.
Now see how the core primitives nest. Here’s a minimal but complete skeleton with a shell, container, one section, a heading, body text, and a button:
<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>That single block is the whole vocabulary in miniature. Step through it cluster by cluster to pin down which lines do which job.
<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 everything renders onto. 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, the email’s main content area. 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. It’s how you stack blocks down the page; the renderer turns it into table rows underneath.
<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: a single <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.
That walkthrough is the reference; there’s no table to memorize. What’s worth retaining isn’t the exact syntax of each tag (you’ll look that up, and the preview server next lesson will cement it through repetition), but which primitive does which job. So drill exactly that, sorting each primitive 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 / BodyPreviewTailwind without a third syntax
Section titled “Tailwind without a third syntax”You now have email-safe building blocks, but the skeleton above is unstyled: the primitives carry sensible defaults, not your brand. The web app styles everything with Tailwind, and the good news is that the same Tailwind, the same utility class names, work in email too.
Wrap the body in <Tailwind> (from react-email) and you 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>text-2xl, font-semibold, bg-zinc-50, mx-auto, max-w-[600px]: same vocabulary, same muscle memory as the web build. Under the hood the wrapper runs Tailwind 4 internally, so the utility set matches your app’s.
Two things differ, though, and both follow directly from the render pipeline. This is the “same tools, new target” idea again. First, what the wrapper does: at render time it compiles each class you used into inline styles on that element, and silently drops any class it doesn’t recognize (with a console warning). It can’t ship a stylesheet, for the reason you saw earlier, so it inlines. That’s the payoff of “no CSS file is loaded” made concrete.
Second, the supported subset is narrower than the web build, and the gaps are exactly the modern-CSS features from that support matrix at the top of the lesson. The watch-outs worth knowing up front:
- No
flex, nogrid. This is the big one. For horizontal layout, reach for<Row>and<Column>, the layout cluster you just learned. That’s not a workaround; it’s the way to lay out across in email. space-*utilities and complex selectors don’t work, because of how styles get inlined onto individual elements: there’s no stylesheet for a descendant selector to live in.- Arbitrary values do work and are genuinely useful, 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 it’s the most insidious trap in the chapter, and it bites you specifically because the tooling lies to you. The preview server you’ll meet next lesson renders in Chrome, so flex and grid look like they work perfectly there. Then the message hits Gmail, which throws the flex away and collapses everything to default block flow, and your carefully aligned two-column header becomes a single stack. The preview being correct is not the inbox being correct. 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. The preview looks aligned, but Gmail discards flex and gap, stacking the logo and link into one column. Nothing warns you; it just breaks 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 alike. Use Tailwind utilities for the spacing inside each column.
<Tailwind> also takes a config prop, which accepts a Tailwind config object so you can wire in your brand’s theme tokens. That’s worth a section of its own, because bridging your app’s tokens into email is the one genuinely fiddly part, and it’s exactly where we go next.
Bridging the brand tokens
Section titled “Bridging the brand tokens”Your web app’s brand colors don’t automatically reach your email templates, and understanding why tells you how to fix it.
Back in the styling chapter you defined your design tokens the Tailwind 4 way: a @theme block in CSS, with colors in OKLCH. The email side can’t read that. The <Tailwind> component is configured through a JavaScript config object passed to its config prop, not a CSS @theme block, and the two don’t share a source. So the real mismatch isn’t a version gap between old and new Tailwind (the theme.extend shape is the same either way). It’s that the web declares tokens in CSS while email wants them in JS. Bridging them means mirroring your brand tokens, by hand, into a small JS config the email templates import.
There’s a second wrinkle, and it’s the kind that bites silently. Your web tokens are in OKLCH , and a handful of mailbox clients can’t parse OKLCH. When a client meets a color value it doesn’t understand, it doesn’t fall back gracefully; it drops the whole property. Your brand color simply vanishes, and the button renders with no background. So the email config can’t reuse the OKLCH values; it needs plain hex (or RGB) for each token.
Put both together and you get a small, hand-maintained file: emails/email-tailwind-config.ts. One module, one default export, mirroring your brand tokens as hex values 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 uses it the same way:
<Tailwind config={emailTailwindConfig}> {/* …template… */}</Tailwind>There’s one line in that config you haven’t seen, and it’s the senior default for email: presets: [pixelBasedPreset]. Tailwind’s spacing and sizing utilities are rem-based, because rem respects the user’s font-size setting, which is the right call on the web. But some email clients don’t honor rem units, so a text-lg could render at an unexpected size. pixelBasedPreset re-bases every utility onto a fixed 16px pixel scale, sidestepping the problem. Include it in every email config.
One honest expectation to set: you maintain this file by hand as your palette evolves. There’s no sync script generating it from your CSS tokens, so you mirror a change when you make one. For a project with a handful of brand colors that’s a few lines you touch rarely. The cost is real but small, and it buys you brand colors that actually render.
One template per file
Section titled “One template per file”Before composing the real thing, a quick word on where templates live, because the location is load-bearing: it couples directly to the preview server you’ll boot next lesson.
React Email scans an emails/ directory at your repo root by default. Each .tsx file in it is one template, default-exported. That’s a deliberate exception to the project’s usual named-exports rule, and it lines up with the “one concept per file” convention you already follow: one template, one file, one default export. The preview server and this file location work together, because its preview URL is derived from the path, so moving or renaming a template silently breaks its preview. Place templates with intent.
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 things to notice. Subdirectories like auth/ are supported for grouping once you have enough templates to warrant it. Don’t reach for them with three files, but know they’re there. 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). That split trips people up, because the file name and the export name follow different conventions on purpose, so keep them straight as you create files.
Props are the template’s contract
Section titled “Props are the template’s contract”Now the senior reflex that makes a template testable, previewable, and trustworthy. You already practice it everywhere else in the app; here you just apply it to email.
A template’s 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;};(type, not interface, is the house rule for prop shapes you’ve followed since the React unit.) The rule that goes with it is strict and worth stating as an absolute: the template never reads environment variables, the current user, or database rows. It doesn’t import env, it doesn’t query the database, and it doesn’t reach for the session. The caller, a Server Action, fetches all of that and passes the finished values in as props. The template is pure presentation, and the data-fetching lives where data-fetching belongs. This is the same server/client boundary discipline you already apply to React components, pointed at email.
Why be this strict? Because a template that reads only from props renders identically in three different places: the preview server (fed mock props), a unit test (fed test props), and a real send (fed real props). There’s no “if preview, do this; if production, do that” branching anywhere. One file, one behavior, three contexts. That’s the durable reason: not style, but the property that lets the same code be developed, tested, and shipped without special-casing.
That’s where the mock data comes in. A template co-locates its preview data right beside it, as a static property:
WelcomeEmail.PreviewProps = { firstName: 'Ada', verifyUrl: 'https://yourapp.com/verify/abc123',} satisfies WelcomeEmailProps;PreviewProps is mock data that ships with the template. The preview server next lesson auto-detects it and renders the template with those values, so you see a realistic email without wiring up a real send. Production ignores it and passes real props. This co-location is why email doesn’t need a separate Storybook: the “render this in isolation with fake data” story lives in the template file itself.
Here’s the contract assembled: the typed props, a component that reads only from them, and the PreviewProps. Step through the three parts.
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 recipient’s first name and the verification URL. This is 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 is the template. It destructures its typed props in the signature, and the body reads only from them, never env, never the session, never the database. The caller supplies these 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;Mock data, co-located. The preview server picks this up and renders the template with realistic values; production passes real ones. satisfies keeps it honest against the props type.
The boundary is the concept that pays off, so make sure it’s solid. Of the things a template could touch, which belong inside it, and which belong 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 that calls the template does all of that and passes the finished values in as props. Keeping the template props-only is exactly what lets the same file render unchanged in the preview, in a unit test, and in a real send — no if preview / else production branching anywhere.Composing the shared chunks
Section titled “Composing the shared chunks”You could now write the welcome email top to bottom. But the moment you write the second template, a password reset, say, you’d be copy-pasting the same header, the same padded container, the same footer. That’s the duplication an experienced engineer factors out before it spreads. Most transactional emails share three things: a header (logo and product name), a body container with consistent padding, and a footer (the legal mailing address, a support link, the year).
So you reach for a single EmailLayout component that owns all 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, pulling its colors from the emailTailwindConfig you built earlier. Change the logo once, and every email updates. The contrast between duplicating and composing is stark.
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 tweak or a footer address change means editing every file, and they drift apart 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 own body; the header, footer, and brand surface are defined once in EmailLayout and shared. One edit updates every email.
Host your images, don’t embed them
Section titled “Host your images, don’t embed them”The layout’s header carries the first <Img> you’ll write, the logo, so this is the moment to settle how email handles images, because the obvious shortcut is a trap with a hard limit attached.
The shortcut is to inline the image as a base64 data URL : paste the whole image straight into the src so there’s nothing to host. Don’t, for two reasons, one of them a hard wall.
First, the hard wall: Gmail clips any message larger than 102 KB. A base64-encoded logo can eat that budget by itself. When Gmail clips, it 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. That buried content can include your call to action or the footer’s legal address and support line, a problem on top of the deliverability one. The 102 KB budget is a real constraint that drives lean-template discipline, and inline images are the fastest way to blow it.
Second, some clients strip base64 images outright, so even under the budget the image may simply 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. The two forms, side by side.
<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 simply 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, and 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 its full natural size and breaks your column. Attributes, every image.
The running artifact: the welcome email
Section titled “The running artifact: the welcome email”Every piece is now on the table. Time to assemble the whole emails/welcome.tsx: the <Tailwind> wrapper with your config, the <EmailLayout> shell, a <Preview> for the inbox, an <h1>, body copy, the verify <Button>, the typed props, and the co-located PreviewProps. This is the file you’d pass to sendEmail unchanged. Read it whole, then step through how each region maps back to what taught it.
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. The primitives are imported from react-email, then wrapped in <Tailwind> with the brand-token config, so every utility below resolves your colors and renders 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, set deliberately so it doesn’t just duplicate the heading.
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. The header, footer, and brand chrome all come from EmailLayout, and 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. Tailwind utilities style each one, and bg-brand resolves through the config.
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 in, realistic preview values co-located, so the same file renders in the preview, in a test, and in a real send, with no branching.
That’s a real, shippable template. Drop it into a sendEmail call with a real firstName and verifyUrl and it goes out to any inbox, both the HTML and the auto-derived plain-text part.
But here’s the catch, and it’s the bridge to the next lesson: you’ve been reading this template as code. You haven’t seen it. Does the heading wrap awkwardly at 600px? Is the button readable in dark mode? Is the preheader actually what you intended? The only place those answers are real is the inbox, and Chrome, where you’d naturally check, is precisely the place that lies to you about flex and dark mode. So the next lesson boots the preview server: the tight save-and-eyeball loop, the device and dark-mode toggles, and the test-send that catches what your browser won’t.
The durable takeaways from this lesson:
- Email HTML is a constrained, 2004-shaped subset (table layout, inline styles, no flexbox or grid) because the worst client in the matrix still parses like it’s 2004.
- React Email is the same React with a different DOM: you write modern JSX and the renderer emits the ancient markup the inbox understands.
- The Resend SDK renders both MIME parts from the
reactnode, so you pass the component, never a pre-rendered string. Callingrenderat the send site throws away the free plain-text part. - Reach for the named primitives over raw tags:
<Container>for the column,<Section>/<Row>/<Column>for layout,<Button>for a bulletproof CTA,<Img>withwidth/heightas attributes. <Preview>sets the inbox preheader. Set it on every template or the client duplicates your subject.<Tailwind>inlines a narrower utility subset (noflex/grid, so use<Row>/<Column>); bridge brand tokens through a hand-maintained JS config with hex values andpixelBasedPreset.- Templates are pure, props-only renderers (no env, no session, no database reads) with mock data co-located as
PreviewProps, so they render identically in preview, test, and production. - Host images at a public URL; never inline base64, since it clips the message past Gmail’s 102 KB limit and hides your footer.
External resources
Section titled “External resources”The React Email docs are the canonical reference for the full primitive set, so keep them open while you build. The other resources below turn the lesson’s abstractions into something you can poke at: the real 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.