Email is not the browser. Flex, Grid, and JS die in Outlook. The final HTML must be tables, inline styles, plain text, and a look that survives Gmail, Outlook Windows, Apple Mail, and mobile.
Unified package react-email (v6). Do not install @react-email/components in a new project. Preview: @react-email/ui.
Stack
npm i react-email @react-email/ui
import {
Html, Head, Body, Container, Section, Row, Column,
Text, Heading, Button, Img, Link, Hr, Preview, Font, render,
} from "react-email"
render(<Email />)→ HTML.render(<Email />, { plainText: true })→ text part. Always send both.- Tailwind via the React Email wrapper only if the project already uses it; prefer inline style objects for predictability.
Anatomy
emails/
_layout.tsx # Html, Head, Preview, Body, Container, footer
auth/magic-link.tsx
transactional/receipt.tsx
marketing/welcome.tsx
Single shared layout (~600px). Typed props per template. No data fetching inside the email — the caller passes already-resolved data.
Non-negotiable rules
- Layout with
Container+Section+Row/Column— never flexbox/grid/divloose for structure. - Content width 600px (attribute + style). Outlook ignores
max-widthon its own. - Styles inline (objects
style={{}}). No external stylesheet;<style>only for minimal reset / progressive dark-mode in theHead. - CTA with component
Button(VML/MSO included). Do not invent<a>with “CSS button” padding. - Every
Imghaswidth,heightandalt(emptyalt=""only if decorative). Html lang="pt-BR"(or the actual language).Previewwith 40–90 useful characters — not “Do not reply to this email” as the preview.- Part plain text equivalent (same links and facts).
- No JavaScript, interactive forms, autoplay video, or webfont as the only font.
Compatibility
| Do | Avoid |
|---|---|
Row / Column (tables + MSO) |
display: flex, Grid, gap |
width={600} in the container |
Fluid 100% without constraint |
Button from the package |
<div> clickable, border-radius in <a> raw |
Hex colors (#111827) |
oklch(), color-mix, CSS variables as the only source |
| System fonts + fallback | Only "Inter" / hosted font |
| HTTPS-hosted PNG/JPG | SVG as critical content, CID without need |
| Solid background | background-image as information |
Margin via Section/Text style |
Browser margin collapsing |
Minimum clients to consider: Gmail (web + app), Outlook Windows (Word engine), Outlook Mac, Apple Mail, iOS Mail, Yahoo. Test for real (Litmus, Email on Acid, or sending to accounts) before calling it done.
Gmail may invert colors in dark mode. Do not rely on light text on a light background "because the media query saves you." Backgrounds and texts with contrast in both modes, or accept inversion with mid-contrast colors.
<Head>
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
</Head>
Accessibility
- Hierarchy: one
Heading as="h1"per email; thenh2sections. Do not skip levels. - Body ≥ 14px, line-height ~1.5. Links underlined or with obvious text ("Reset password"), never "click here".
- Contrast WCAG AA (4.5:1 normal text). Brand accent is no excuse for light gray on white.
- Information not conveyed by color alone (error: text + prefix "Error:", not just red).
- Logo images:
altwith the product name. Tracking pixels:alt="", 1×1, never content. - Touch area on the CTA (~44px in height via padding of the
Button). - Prefer semantic HTML that React Email already outputs (
p,h1,a,img) overroleARIA that clients ignore.
Design
Beautiful email is rhythm and restraint, not modern CSS.
- One brand accent (button + links) with AA contrast. Canvas background
#f4f4f5, card#ffffff, text#111827, muted#52525b. - Hierarchy: h1 24–28px / body 16px / footer 12–13px muted.
- Consistent vertical spacing (24/32px between blocks). No sections stuck together.
- One primary CTA. Secondary becomes a text link.
- Width 600, internal padding 24–32px. Mobile: columns become a stack via
Row/Column(not complex media queries). - Small logo at the top (height ~32–40px), not an 800px hero.
- No gradients, glassmorphism, heavy shadows, excessive rounded borders (Outlook makes them disappear).
- Footer: who sends it, why, preferences/unsubscribe link if marketing (required in many jurisdictions).
Patterns:
| Type | Content |
|---|---|
| Auth | Who requested it, action button, validity, notice "if it wasn't you, ignore", raw URL below the button |
| Transactional | Fact (order #, amount), simple table Row/Column, next step |
| Welcome / e-commerce | One message, one CTA, one product — not a 12-block newsletter |
Skeleton
import { Html, Head, Preview, Body, Container, Section, Text, Heading, Button, Link, Img, Hr } from "react-email"
const font = "system-ui, -apple-system, 'Segoe UI', sans-serif"
export function MagicLinkEmail({ url, expiresMinutes }: { url: string; expiresMinutes: number }) {
return (
<Html lang="pt-BR">
<Head />
<Preview>Seu link de acesso vale por {expiresMinutes} minutos</Preview>
<Body style={{ margin: 0, backgroundColor: "#f4f4f5", fontFamily: font }}>
<Container style={{ maxWidth: 600, width: "100%", margin: "32px auto", backgroundColor: "#ffffff" }}>
<Section style={{ padding: "32px 32px 0" }}>
<Img src="https://example.com/logo.png" width="120" height="32" alt="Acme" />
</Section>
<Section style={{ padding: "24px 32px" }}>
<Heading as="h1" style={{ fontSize: 24, color: "#111827", fontWeight: 600 }}>
Entrar na Acme
</Heading>
<Text style={{ fontSize: 16, lineHeight: "24px", color: "#3f3f46" }}>
Use o botão abaixo para acessar sua conta. O link expira em {expiresMinutes} minutos.
</Text>
<Button href={url} style={{ backgroundColor: "#18181b", color: "#fafafa", padding: "12px 20px" }}>
Acessar conta
</Button>
<Text style={{ fontSize: 13, lineHeight: "20px", color: "#52525b" }}>
Se o botão não funcionar, copie:{" "}
<Link href={url} style={{ color: "#18181b", textDecoration: "underline" }}>{url}</Link>
</Text>
</Section>
<Hr />
<Section style={{ padding: "0 32px 32px" }}>
<Text style={{ fontSize: 12, color: "#71717a" }}>
Se você não pediu este e-mail, ignore. Acme nunca pede senha por mensagem.
</Text>
</Section>
</Container>
</Body>
</Html>
)
}
Sending
- HTML +
text/plain. List-Unsubscribe in marketing. - Images on a stable HTTPS CDN; do not embed heavy base64.
- Low total weight (images below ~100KB each when possible).
- Local preview (
react-email/ UI) does not replace Outlook Windows.
Anti-patterns
- Import of
@react-email/componentsin a v6 app - Flex/Grid,
gap,position: absolute - CTA without
Button - Empty preview or one identical to the subject
- No plain text
- White text on a white background "in dark mode"
altmissing oralt="image"- "Click here"
- Gradient, video, image map as sole navigation
Integration with other skills
- Copy:
website-copywriting - Locale/templates per language:
i18n-backend/i18n-frontend - Auth flows (server generates the link):
authentication-authorization,phoenix,ruby-on-rails - Web UI does not apply 1:1 — do not copy Tailwind from an app to email without inlining
Completion criteria
- Imports of
react-email(v6) - 600px layout in table components; one
ButtonCTA lang,Preview,alt, AA contrast, body ≥14px- HTML + plain text
- Sober visual (rhythm, one accent, hierarchy) — not a "landing page"
- At least the Outlook + Gmail path considered (documented or tested)