Most React Email codebases start with copy-paste and end with chaos. You duplicate a button, tweak the padding, ship it, and six months later you're maintaining seventeen slightly different CTAs.
A component library doesn't have to be ambitious. You don't need Storybook or a design system. You just need a few foundational patterns that prevent drift and make email development feel like building with blocks instead of rewriting the same HTML over and over.
Why email components are different from web components
Email HTML is stuck in 2005. No flexbox. No grid. Limited CSS support. What works in Gmail breaks in Outlook. Dark mode rewrites your colors.
This means your component library has to be defensive by default:
- Table-based layouts (yes, really)
- Inline styles everywhere
- Fallback patterns for broken clients
- Safe color palettes that survive dark mode
The good news: React Email handles most of this. The bad news: if you don't structure your components well, you'll still end up with unmaintainable templates.
Pattern 1: Base layout component (the foundation)
Every email should share the same outer structure: HTML wrapper, head tags, body container, and footer. Don't repeat this 30 times across templates.
import { Html, Head, Preview, Body, Container, Section } from "@react-email/components";
import { Footer } from "./footer";
interface BaseLayoutProps {
preview: string;
children: React.ReactNode;
includeFooter?: boolean;
}
export function BaseLayout({ preview, children, includeFooter = true }: BaseLayoutProps) {
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={bodyStyles}>
<Container style={containerStyles}>
{children}
{includeFooter && <Footer />}
</Container>
</Body>
</Html>
);
}
const bodyStyles = {
backgroundColor: "#f6f9fc",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
};
const containerStyles = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
};Now every template starts clean:
import { BaseLayout } from "@/components/emails/base-layout";
import { Button } from "@/components/emails/button";
import { Heading } from "@/components/emails/heading";
import { Text } from "@/components/emails/text";
export function WelcomeEmail({ userName }: { userName: string }) {
return (
<BaseLayout preview="Welcome to the platform">
<Heading>Welcome, {userName}</Heading>
<Text>Let's get you started.</Text>
<Button href="https://app.example.com/onboarding">
Start setup
</Button>
</BaseLayout>
);
}Pattern 2: Styled primitives (typography + spacing)
Don't inline text styles everywhere. Build typed primitives with consistent spacing and hierarchy.
Heading component
import { Heading as ReactEmailHeading } from "@react-email/components";
interface HeadingProps {
children: React.ReactNode;
as?: "h1" | "h2" | "h3";
}
export function Heading({ children, as = "h1" }: HeadingProps) {
const styles = {
h1: { fontSize: "24px", fontWeight: "700", lineHeight: "32px", margin: "0 0 20px" },
h2: { fontSize: "20px", fontWeight: "600", lineHeight: "28px", margin: "32px 0 16px" },
h3: { fontSize: "16px", fontWeight: "600", lineHeight: "24px", margin: "24px 0 12px" },
};
return (
<ReactEmailHeading as={as} style={styles[as]}>
{children}
</ReactEmailHeading>
);
}Text component
import { Text as ReactEmailText } from "@react-email/components";
interface TextProps {
children: React.ReactNode;
variant?: "body" | "small" | "muted";
}
export function Text({ children, variant = "body" }: TextProps) {
const styles = {
body: { fontSize: "16px", lineHeight: "24px", color: "#1f2937", margin: "0 0 16px" },
small: { fontSize: "14px", lineHeight: "20px", color: "#1f2937", margin: "0 0 12px" },
muted: { fontSize: "14px", lineHeight: "20px", color: "#6b7280", margin: "0 0 12px" },
};
return (
<ReactEmailText style={styles[variant]}>
{children}
</ReactEmailText>
);
}Pattern 3: Smart button component (with variant support)
Buttons are the most copy-pasted component in email templates. Build one that handles primary, secondary, and danger variants.
import { Button as ReactEmailButton } from "@react-email/components";
interface ButtonProps {
href: string;
children: React.ReactNode;
variant?: "primary" | "secondary" | "danger";
}
export function Button({ href, children, variant = "primary" }: ButtonProps) {
const styles = {
primary: {
backgroundColor: "#2563eb",
color: "#ffffff",
border: "1px solid #2563eb",
},
secondary: {
backgroundColor: "#ffffff",
color: "#2563eb",
border: "1px solid #e5e7eb",
},
danger: {
backgroundColor: "#dc2626",
color: "#ffffff",
border: "1px solid #dc2626",
},
};
const baseStyles = {
fontSize: "16px",
fontWeight: "600",
lineHeight: "24px",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
display: "inline-block",
textAlign: "center" as const,
};
return (
<ReactEmailButton href={href} style={{ ...baseStyles, ...styles[variant] }}>
{children}
</ReactEmailButton>
);
}Usage is clean and obvious:
<Button href={resetUrl} variant="primary">
Reset password
</Button>
<Button href={cancelUrl} variant="secondary">
Cancel request
</Button>Pattern 4: Section component (content blocks with spacing)
Most emails are a stack of sections: hero, body, CTA, footer. A section component enforces consistent vertical spacing.
import { Section as ReactEmailSection } from "@react-email/components";
interface SectionProps {
children: React.ReactNode;
spacing?: "tight" | "normal" | "loose";
backgroundColor?: string;
}
export function Section({ children, spacing = "normal", backgroundColor }: SectionProps) {
const spacingMap = {
tight: "16px 20px",
normal: "32px 20px",
loose: "48px 20px",
};
const styles = {
padding: spacingMap[spacing],
...(backgroundColor && { backgroundColor }),
};
return (
<ReactEmailSection style={styles}>
{children}
</ReactEmailSection>
);
}Build emails like Lego blocks:
<BaseLayout preview="Your invoice is ready">
<Section spacing="loose">
<Heading>Invoice #1234</Heading>
<Text variant="muted">March 1, 2026</Text>
</Section>
<Section backgroundColor="#f9fafb">
<InvoiceLineItems items={items} />
</Section>
<Section spacing="normal">
<Button href={paymentUrl} variant="primary">
View invoice
</Button>
</Section>
</BaseLayout>Pattern 5: Conditional alert/callout component
Security warnings, trial expiration, failed payments — these need visual weight without looking like spam.
import { Section } from "@react-email/components";
interface AlertProps {
children: React.ReactNode;
variant: "info" | "warning" | "error";
}
export function Alert({ children, variant }: AlertProps) {
const styles = {
info: {
backgroundColor: "#dbeafe",
borderLeft: "4px solid #2563eb",
color: "#1e40af",
},
warning: {
backgroundColor: "#fef3c7",
borderLeft: "4px solid #f59e0b",
color: "#92400e",
},
error: {
backgroundColor: "#fee2e2",
borderLeft: "4px solid #dc2626",
color: "#991b1b",
},
};
const baseStyles = {
padding: "16px",
borderRadius: "4px",
fontSize: "14px",
lineHeight: "20px",
margin: "16px 0",
};
return (
<Section style={{ ...baseStyles, ...styles[variant] }}>
{children}
</Section>
);
}Perfect for dunning emails or security notifications:
<Alert variant="warning">
Your payment method was declined. Please update your billing information
to avoid service interruption.
</Alert>
<Button href={updateUrl} variant="primary">
Update payment method
</Button>Folder structure that scales
Organize components by purpose, not alphabetically:
components/
emails/
primitives/
heading.tsx
text.tsx
button.tsx
link.tsx
layout/
base-layout.tsx
section.tsx
footer.tsx
header.tsx
feedback/
alert.tsx
badge.tsx
complex/
invoice-line-items.tsx
feature-list.tsx
stats-grid.tsx
emails/
welcome.tsx
password-reset.tsx
invoice.tsx
trial-ending.tsxTesting your component library
Don't ship components that break in Outlook. Test them in isolation first.
1. Local preview server
React Email's dev server shows all your templates at once:
npm run email devNavigate to http://localhost:3000 and spot-check each component variant in the preview.
2. Component playground file
Create a kitchen-sink email that uses every component:
import { BaseLayout } from "@/components/emails/base-layout";
import { Heading } from "@/components/emails/heading";
import { Text } from "@/components/emails/text";
import { Button } from "@/components/emails/button";
import { Alert } from "@/components/emails/alert";
import { Section } from "@/components/emails/section";
export default function ComponentPlayground() {
return (
<BaseLayout preview="Component library test">
<Section>
<Heading as="h1">H1 Heading</Heading>
<Heading as="h2">H2 Heading</Heading>
<Heading as="h3">H3 Heading</Heading>
</Section>
<Section>
<Text variant="body">Body text variant</Text>
<Text variant="small">Small text variant</Text>
<Text variant="muted">Muted text variant</Text>
</Section>
<Section>
<Button href="#" variant="primary">Primary button</Button>
<Button href="#" variant="secondary">Secondary button</Button>
<Button href="#" variant="danger">Danger button</Button>
</Section>
<Section>
<Alert variant="info">Info alert message</Alert>
<Alert variant="warning">Warning alert message</Alert>
<Alert variant="error">Error alert message</Alert>
</Section>
</BaseLayout>
);
}Send this to yourself and open it in Gmail, Outlook, and Apple Mail. If something breaks, fix it once in the component.
3. Automated snapshot tests (optional but helpful)
Use Playwright or a visual regression tool to catch unintended changes:
import { test, expect } from '@playwright/test';
import { render } from '@react-email/render';
import { ComponentPlayground } from '../emails/_component-playground';
test('component playground matches snapshot', async () => {
const html = render(<ComponentPlayground />);
expect(html).toMatchSnapshot();
});How to maintain your component library
- Version your components. If you ship a breaking change (like removing a prop), make it a new file:
button-v2.tsx. Migrate templates one at a time. - Document variants in code. Use TypeScript union types so autocomplete shows options:
variant?: "primary" | "secondary". - Avoid magic props. If a component needs complex logic, expose it as a variant or separate component. Don't hide behavior behind boolean flags.
- Test in real clients quarterly. Email rendering changes. Gmail ships updates. Outlook breaks things. Schedule a quarterly test pass.
Implementation checklist
Before you start building:
- ✅ Define your color palette (5-7 colors max, test in dark mode)
- ✅ Pick 2-3 font sizes and stick to them (don't invent new sizes per template)
- ✅ Build BaseLayout first (this is your foundation)
- ✅ Extract Button, Heading, Text next (these are 80% of your templates)
- ✅ Add Section and Alert when you need spacing/feedback patterns
- ✅ Test everything in Gmail, Outlook, and Apple Mail before declaring victory
If you want production-ready components without building from scratch, see our React Email template library — it includes all these patterns plus edge case handling.
_templates folder with blank starter files (welcome, invoice, etc.). New emails become a copy-paste + fill-in-the-blanks exercise instead of starting from zero.A good component library makes email development boring (in the best way). You stop worrying about padding and colors, and you just ship.
That's the point.