Readable in every client
Make a React Email transactional template hold up in every recipient's client, the plain-text body, the accessibility checklist, and dark mode.
You can now edit a template, watch it hot-reload in the preview, and send a test to your own inbox to confirm it arrives intact. That loop optimizes for one render: the HTML, the way a sighted person sees it in a modern client, the way you saw it in the preview.
That is only one of the renders that ship from a single send. A VoiceOver user hears the message read aloud and never sees your layout. A locked-down corporate account strips the HTML at the gateway and leaves text only. Gmail on Android inverts every color, turning the dark logo you designed on white into a photo negative. None of these readers see the preview, and all of them are on the recipient list.
So the real work is catching the recipients who never see the HTML you eyeballed.
The through-line is concrete: every fix here lands on a specific attribute or element in welcome.tsx.
Every email carries a text and an HTML body
Section titled “Every email carries a text and an HTML body”Every transactional email travels on the wire as a single multipart/alternative message carrying two bodies, one text/plain and one text/html.
They are not two emails but two encodings of the same message, bundled together, and the receiving client renders whichever it can or prefers.
The free text part you got in the first lesson by passing the react prop is the text/plain body of this envelope.
The text body is not optional, because several readers depend on it. Screen readers and accessibility-mode clients that never render HTML read it. Clients and corporate security policies that strip HTML for safety leave the recipient with text only. Spam and content scanners read it to judge the message, and a missing text part is itself a faint spam signal. And when Gmail clips an oversized HTML message, the text part is part of what indexers and scanners fall back to.
You do not write that text body.
The Resend SDK derives it automatically from the same react node you pass to send.
You pass the component, and both bodies come out the other side.
Never hand-maintain a parallel text field.
Writing your own text string is tempting, but it drifts from the HTML within a release or two: someone edits the verify copy in the JSX, nobody updates the hand-written string, and the two bodies now disagree about what the email says.
The generated text is downstream of the JSX, so it can never go stale.
You opt out by passing text: '' or your own string to the send call.
You will rarely want to.
await resend.emails.send({ from, to, subject: 'Verify your email', react: <WelcomeEmail firstName={firstName} verifyUrl={verifyUrl} />, // text: 'Pass a string here only to override the auto-generated text body.',});When you need the text directly, say for a unit test that asserts on it, the utility is toPlainText(html) , imported from react-email and run on the output of render().
It is the helper behind the preview server’s plain-text tab and behind your tests, not something you reach for on the send path.
Reading the generated plain-text version
Section titled “Reading the generated plain-text version”The text version is generated, but generated is not the same as good.
The generator only has the structure and wording you gave it to work with, so reading the result is an active QA habit, not something you set and forget.
Open the preview’s plain-text tab and read the body as the message, the way a screen-reader user or plain-text client receives it, not as a degraded copy of the styled version.
The shift that makes this worth your time: a bad text version is not a text problem but a bug in your HTML JSX, and there is no separate text file to edit.
Every defect traces back to a decision in welcome.tsx.
Read it against four questions, each pointing at a specific spot in the JSX.
Are the purpose and call to action clear from the text alone? Strip the button and layout: does the reader still know what the email is and what to do?
If the verify action only makes sense beside a colored button, the copy is carrying too little.
Fix it by strengthening the <Text> and heading so the message stands without the chrome.
Are links labeled rather than bare? The generator places each URL right after its link text, so a naked https://yourapp.com/verify/abc123 reads worse than the same URL with words beside it.
Fix it on the <Link> text: “Verify your email” reads well before a URL; “click here” does not.
Do decorative images stay out of the text? A decorative image should add nothing to the body.
Set alt="", empty and explicit rather than omitted, and it drops out cleanly; leave it missing or junk and you get stray noise like “image” or a filename.
Fix it on the <Img>’s alt.
Does the text’s section order match the HTML? The generator walks your component tree top to bottom, so if the footer floats to the top of the text, the tree is out of order, not the text. Fix the structure.
{/* … layout wrapper, <Head>, brand bar … */}<Img src="https://cdn.yourapp.com/sparkle.png" alt="" width="64" height="64" /><Heading as="h1">Welcome, Ada!</Heading><Text> Confirm your email so we can finish setting up your YourApp account.</Text><Button href="https://yourapp.com/verify/abc123">Verify your email</Button>{/* … footer … */}The source you author and eyeball in the preview. Two spots decide the whole text body: the alt="" on the decorative sparkle, and the <Button> text sitting in front of its href.
Welcome, Ada!
Confirm your email so we can finish setting up your YourApp account.
Verify your email: https://yourapp.com/verify/abc123The exact text body a plain-text client receives. The sparkle left no trace because its alt was empty, and the link text reads cleanly beside its URL.
Side by side, you can trace each line of text to its source: the empty alt produced nothing, the descriptive button text produced the readable “Verify your email:” prefix, and the order matches because the tree order did.
You open the preview’s plain-text tab and read the generated body. One line is wrong: the filename of the decorative logo leaked in above the greeting:
logo-final-v2.pngWelcome, Ada!
Confirm your email so we can finish setting up your YourApp account.
Verify your email: https://yourapp.com/verify/abc123The logo is pure decoration and the rest of the body reads fine. Which edit to welcome.tsx drops that stray line?
<Img>’s alt to the empty string.logo-final-v2.png line by hand.alt so the generator emits less text.width and height.alt="" marks the logo as decorative, so the generator skips it and the filename line disappears. There is no separate text body to edit; it’s derived from the JSX every render, so the fix always lives in welcome.tsx. Emptying the hero image’s alt would quiet the text by blinding screen-reader users to real content — alt="" is only for genuinely decorative images. And width/height are layout, with no bearing on the text body.The accessibility checklist for transactional email
Section titled “The accessibility checklist for transactional email”Email accessibility is a shorter list than web accessibility, but its floors are harder, not softer.
The list is shorter because the medium fights you: email renders on tables and inline styles, with no JavaScript and almost no working ARIA, so the patterns you reach for on the web collapse to a small set. That short list is the unforgiving part, because the reader cannot restyle your message. On the web a low-vision user can bump the font, override your colors, or run a reader extension; in an inbox they get what you sent. So the numeric floors for font size, contrast, and touch target are hard limits you meet up front.
Most of these are habits you already have from semantic HTML: semantics, one heading per page, descriptive links, contrast.
Here they point at a more constrained surface.
Each item below is a rule, a one-line reason, and where it lives in welcome.tsx.
lang on <Html>. <Html lang="en"> has been in the template since the first lesson.
A screen reader picks its pronunciation rules from lang; leave it off and the reader falls back to the system locale and may read the whole message in the wrong accent.
<Title> in <Head>. <Title>Verify your email</Title> is announced by assistive tech, and some clients show it in their “open in browser” view.
This <Head> content is new this lesson.
One <h1>, in logical order. Use exactly one <Heading as="h1"> and have it state the message’s purpose; subsections drop to as="h2".
The <h1> is the first thing a screen-reader user navigates to, so it must be the point of the message, not the brand mark: “Verify your email,” not “YourApp.”
Descriptive link text. <Link>Verify your email</Link>, never “click here.”
Screen readers let users pull up a list of every link by its text alone, so each link has to make sense standing by itself.
The plain-text section made the same point for the text reader; here it is the accessibility reason.
Image alt discipline. A decorative image gets alt="", empty and explicit, never omitted.
An informational image gets one descriptive sentence; a logo gets the brand name.
A missing alt often falls back to the image’s filename in some clients, which is both an accessibility and a deliverability failure.
Color contrast at WCAG AA. That means 4.5:1 for body text and 3:1 for headings and large text.
Default <Text> on a near-white background is usually fine.
The failure that recurs is the brand color on the call-to-action button: the text on bg-brand has to clear 4.5:1 against the fill.
This is where the project’s brand / brand-foreground pair earns a real contrast check rather than a guess.
Minimum font size. Use 14px for body text and 16px on mobile.
iOS Mail auto-bumps text under about 13px, but design to the floor rather than relying on the bump.
Sizes are predictable here because the pixelBasedPreset from the first lesson keeps your text-* utilities in pixels rather than rems.
Touch target of at least 44×44px. A finger needs room.
The <Button> component’s default padding clears 44×44 on its own; a <Link> you have hand-styled to look like a button usually does not.
One more reason to reach for <Button> for any real call to action.
Don’t carry meaning in color alone. A red “urgent” button means nothing to someone who can’t perceive the red, because a screen reader announces the text, not the hue. The text has to carry the meaning; color only reinforces it.
<Tailwind config={emailTailwindConfig}> <Html lang="en"> <Head> <Title>Verify your email</Title> </Head> <Body> <Container> <Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} /> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Verify your email </Heading> <Text className="text-base text-zinc-700"> Confirm your email so we can finish setting up your YourApp account. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify your email </Button> <Link href="https://yourapp.com/help" className="text-sm text-zinc-500"> Visit the help center </Link> </Container> </Body> </Html></Tailwind>Document level. lang="en" tells a screen reader which pronunciation rules to use; drop it and the whole message may be read in the wrong accent. <Title> is announced by assistive tech and shown in some clients’ “open in browser” view.
<Tailwind config={emailTailwindConfig}> <Html lang="en"> <Head> <Title>Verify your email</Title> </Head> <Body> <Container> <Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} /> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Verify your email </Heading> <Text className="text-base text-zinc-700"> Confirm your email so we can finish setting up your YourApp account. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify your email </Button> <Link href="https://yourapp.com/help" className="text-sm text-zinc-500"> Visit the help center </Link> </Container> </Body> </Html></Tailwind>One <h1>, stating the purpose. Use exactly one <Heading as="h1">, and have it say what the message is: “Verify your email,” not the brand name. The <h1> is the first thing a screen-reader user lands on, so putting the logo here instead of the point is the classic anti-pattern. Subsections drop to as="h2".
<Tailwind config={emailTailwindConfig}> <Html lang="en"> <Head> <Title>Verify your email</Title> </Head> <Body> <Container> <Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} /> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Verify your email </Heading> <Text className="text-base text-zinc-700"> Confirm your email so we can finish setting up your YourApp account. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify your email </Button> <Link href="https://yourapp.com/help" className="text-sm text-zinc-500"> Visit the help center </Link> </Container> </Body> </Html></Tailwind>Link text that stands alone. Both the <Button> and the <Link> read as full actions, “Verify your email” and “Visit the help center,” never “click here,” because screen readers can list every link by its text with no surrounding sentence. As a bonus, the <Button>’s default padding already clears the 44×44px touch target a hand-styled <Link> would miss.
<Tailwind config={emailTailwindConfig}> <Html lang="en"> <Head> <Title>Verify your email</Title> </Head> <Body> <Container> <Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} /> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Verify your email </Heading> <Text className="text-base text-zinc-700"> Confirm your email so we can finish setting up your YourApp account. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify your email </Button> <Link href="https://yourapp.com/help" className="text-sm text-zinc-500"> Visit the help center </Link> </Container> </Body> </Html></Tailwind>alt discipline. This logo carries meaning, so its alt is the brand name, "YourApp". A purely decorative image would get alt="", empty and explicit, never omitted. Leave alt off and many clients fall back to the filename, an accessibility and deliverability footgun.
<Tailwind config={emailTailwindConfig}> <Html lang="en"> <Head> <Title>Verify your email</Title> </Head> <Body> <Container> <Img src="https://cdn.yourapp.com/logo.png" alt="YourApp" width={120} height={32} /> <Heading as="h1" className="text-2xl font-semibold text-zinc-900"> Verify your email </Heading> <Text className="text-base text-zinc-700"> Confirm your email so we can finish setting up your YourApp account. </Text> <Button href={verifyUrl} className="rounded-md bg-brand px-5 py-3 text-brand-foreground" > Verify your email </Button> <Link href="https://yourapp.com/help" className="text-sm text-zinc-500"> Visit the help center </Link> </Container> </Body> </Html></Tailwind>The contrast check. The CTA is where contrast most often fails: text-brand-foreground on bg-brand has to clear 4.5:1. This is where the project’s brand / brand-foreground pair earns a real measurement, and the light template must pass on its own.
Designing for dark mode you can’t control
Section titled “Designing for dark mode you can’t control”Dark mode is the hardest part of this lesson: you are designing for three behaviors at once, and you don’t get to choose which one a recipient’s client uses.
A mail client does exactly one of three things with dark mode:
- No transformation. It renders your HTML as authored and applies your own dark styles if you wrote any. Apple Mail on macOS and recent Outlook behave this way.
- Partial inversion. It flips light backgrounds to dark but preserves elements that are already dark, so brand colors usually survive while a near-white background goes near-black. Gmail on iOS and Outlook on iOS do this.
- Full inversion. It inverts everything: your dark-text-on-white logo becomes a photo negative, and brand CTA colors hue-shift into something you never picked. Gmail on Android in some configurations, and some Outlook installs, do this.
The posture follows from the figure: assume some clients will invert, design so inversion doesn’t break the message, and opt into your own dark styles for the clients that honor the preference.
The light template must already meet WCAG contrast on its own.
Most clients ignore your dark preference, and some Gmail clients strip <style> blocks, so a message that only reaches AA contrast after a dark transform fails for most of its readers.
Dark mode is a courtesy layer on a template that already passes, never your contrast strategy.
The plumbing
Section titled “The plumbing”Three pieces, all in <Head>, flag the template as dark-aware.
Without them Apple Mail won’t apply your dark styles even when the user is in dark mode, because it needs to be told the template opted in:
<meta name="color-scheme" content="light dark" /><meta name="supported-color-schemes" content="light dark" />- an inline
<style>with:root { color-scheme: light dark; }
They extend the same <Head> the accessibility section added <Title> to. From there, two ways exist to style for dark.
The reliable one for a transactional template is an @media (prefers-color-scheme: dark) block in an inline <style> in <Head>, targeting class or data- selectors to swap the brand color and the logo.
It works across every client that respects the preference, so reach for it.
The other is Tailwind’s dark: variant through the <Tailwind> component.
It is narrower: it works in Apple Mail and recent Outlook and is ignored elsewhere, fine for a simple background-and-text swap but not a substitute for the media-query block when you need to handle real inversion.
The first lesson told you not to reach for dark: yet; with the head plumbing in place you now can, as long as you know its ceiling.
The minimum-viable posture for the project’s template, then: the head plumbing plus one @media (prefers-color-scheme: dark) block for the brand-color swap and the logo.
A full dark-everything redesign is a marketing-template concern, out of scope here.
<Head> <Title>Verify your email</Title> <meta name="color-scheme" content="light dark" /> <meta name="supported-color-schemes" content="light dark" /> <style>{` :root { color-scheme: light dark; } @media (prefers-color-scheme: dark) { .cta { background-color: #1d4ed8 !important; } .logo-light { display: none !important; } .logo-dark { display: block !important; } } `}</style></Head>The accessibility layer. <Title> is the single line the accessibility section added: announced by assistive tech, shown in some clients’ “open in browser” view. This is the half of the <Head> that exists for the reader, not the renderer.
<Head> <Title>Verify your email</Title> <meta name="color-scheme" content="light dark" /> <meta name="supported-color-schemes" content="light dark" /> <style>{` :root { color-scheme: light dark; } @media (prefers-color-scheme: dark) { .cta { background-color: #1d4ed8 !important; } .logo-light { display: none !important; } .logo-dark { display: block !important; } } `}</style></Head>The dark-mode layer. The two color-scheme metas flag the template as dark-aware; without them Apple Mail won’t apply your dark styles at all. The inline <style> carries the swap: the @media block recolors .cta and flips the logo. Those are plain class names you add to the elements you want swapped, because <Tailwind> compiles utilities to inline styles, and an inline style can’t hold a media query, so the swap needs a real class to hook onto. Treat these rules as an enhancement: some Gmail clients strip <style>, so the light template must already be correct on its own.
The logo-on-dark problem
Section titled “The logo-on-dark problem”One failure deserves a name even though the project won’t build the fix.
A dark-text logo on a near-white background vanishes when a client inverts to near-black, becoming dark-on-dark or a smeared negative.
The pattern that solves it is a <picture> element with a <source media="(prefers-color-scheme: dark)"> pointing at a light-on-dark logo, plus a fallback <img> with the normal dark-on-light version.
Apple Mail and most modern webmail honor it, and clients that ignore the preference get the fallback, so the worst case degrades to “barely visible” rather than “missing.” The project ships one brand-neutral logo that reads on either background, so you won’t build the swap, but recognize the pattern when you need it.
<picture> <source srcSet="https://cdn.yourapp.com/logo-on-dark.png" media="(prefers-color-scheme: dark)" /> <Img src="https://cdn.yourapp.com/logo-on-light.png" alt="YourApp" width="120" height="32" /></picture>Each claim is about how a real mail client treats dark mode — the four spots where the wrong belief costs you readers. Mark each statement True or False.
You ship a CTA styled with Tailwind’s dark: variant and nothing else in <Head>. Apple Mail in dark mode will pick up that dark styling.
color-scheme and supported-color-schemes metas in <Head>. Without them, your dark: (or @media) rules are simply ignored, even with the user in dark mode. The plumbing is the opt-in; the styling rides on top of it.Your CTA only clears 4.5:1 contrast once a client applies your dark styles. Since it reads fine in dark mode, the template is accessible enough to ship.
<style> blocks outright — so a message that only reaches AA after a dark transform fails contrast for the majority of its readers. Dark mode is a courtesy layer, never your contrast strategy.You author no dark styles at all, yet a Gmail-on-Android recipient still sees your dark-on-white logo come back as a washed-out negative.
The template looks right when you flip the preview server’s dark toggle, so you can be confident it survives Gmail on Android.
prefers-color-scheme preference — it renders what a preference-respecting client would show. It cannot reproduce a client’s own inversion heuristic. Only a real test send to that client verifies how Gmail Android actually mangles the message.Reveal card-by-card review
Right-to-left support with dir="auto"
Section titled “Right-to-left support with dir="auto"”For international readers, one token in the template carries its weight today.
In <Html lang="en" dir="auto">, dir="auto" lets the client flip the layout to right-to-left for Arabic or Hebrew on its own, with no separate per-locale template.
Paired with the logical-property utilities from earlier, ps-* and pe-* instead of pl-* and pr-*, which <Tailwind> supports, the message mirrors cleanly when the direction flips.
Real localization, translated copy, ICU plurals, and per-locale templates, is a larger topic the course covers in its internationalization unit; the first welcome email stays English-only.
<Html lang="en" dir="auto">External resources
Section titled “External resources”The render utility and the toPlainText helper — what sits behind the preview's plain-text tab and your tests.
The full client matrix plus the data-attribute and image-swap techniques beyond the transactional minimum.
The AA contrast (1.4.3) and target-size criteria the accessibility checklist leans on.