Most React Email codebases start clean. One template, one file, typed props. Then the second email arrives, and you copy-paste the first one. By email number eight you have eight files with nearly identical layouts, eight copies of your footer text, and a color hex that's slightly wrong in three of them. The fix isn't “be more disciplined.” It's better component architecture.
This guide covers five concrete patterns for building a React Email codebase that scales past a handful of templates without turning into a maintenance burden.
Why copy-paste doesn't scale
Email templates look like they're independent. Each one has its own subject line, its own data, its own layout tweaks. So copy-pasting feels natural. But the shared surface area is larger than you think: the <Html> / <Body> / <Container> wrapper, your logo header, your footer with unsubscribe links, your font stack, your color palette, your button styles. When any of those change (and they will), you have to find-and-replace across every file.
- Change footer text in one file, all emails update
- Brand refresh = update one tokens file
- New developer reads one layout, understands all emails
- Type errors caught once at the shared layer
- Footer change requires editing every template
- Brand refresh = global find-and-replace + prayer
- New developer reads 12 files with subtle differences
- Same bug fixed in 3 templates, missed in 2 others
Pattern 1: Base layout component
Extract everything that wraps your email content into a single BaseLayout component. This includes the <Html>, <Head>, <Body>, container styles, header logo, and footer. Your individual templates only render what's unique to them.
import {
Html, Head, Preview, Body, Container,
Section, Img, Text, Hr,
} from "@react-email/components";
import { colors, fonts, spacing } from "../tokens";
type BaseLayoutProps = {
previewText: string;
children: React.ReactNode;
};
export function BaseLayout({ previewText, children }: BaseLayoutProps) {
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={{ backgroundColor: colors.bg, fontFamily: fonts.sans }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: spacing.lg }}>
{/* Shared header */}
<Section style={{ textAlign: "center", paddingBottom: spacing.md }}>
<Img src="https://yourapp.com/logo.png" width={120} height={36} alt="YourApp" />
</Section>
{/* Template-specific content */}
{children}
{/* Shared footer */}
<Hr style={{ borderColor: colors.border, margin: `${spacing.lg} 0` }} />
<Text style={{ fontSize: "13px", color: colors.muted, textAlign: "center" }}>
YourApp Inc. · 123 Main St · San Francisco, CA 94102
</Text>
</Container>
</Body>
</Html>
);
}Now every template becomes a thin wrapper. The welcome email is just its unique content:
import { Text, Button, Section } from "@react-email/components";
import { BaseLayout } from "./components/base-layout";
import { colors, spacing } from "./tokens";
type WelcomeEmailProps = { name: string; loginUrl: string };
export default function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<BaseLayout previewText={`Welcome, ${name}!`}>
<Text style={{ fontSize: "24px", fontWeight: 600, color: colors.heading }}>
Welcome, {name}!
</Text>
<Text style={{ fontSize: "16px", color: colors.text, lineHeight: "26px" }}>
Your account is ready. Hit the button below to get started.
</Text>
<Section style={{ textAlign: "center", margin: `${spacing.md} 0` }}>
<Button href={loginUrl} style={ctaButton}>Go to Dashboard</Button>
</Section>
</BaseLayout>
);
}
const ctaButton = {
backgroundColor: colors.primary,
color: "#fff",
borderRadius: "6px",
padding: "12px 24px",
fontSize: "14px",
fontWeight: 600 as const,
textDecoration: "none",
};BaseLayout focused on structural concerns only. If you start adding conditional sections (like “show banner if trial user”), you're mixing layout with business logic. That belongs in the template or in a section primitive.Pattern 2: Typed section primitives
Once you have a base layout, the next layer of duplication lives in content sections. Most transactional emails share a handful of structural blocks: a hero heading with subtitle, a feature/detail row, and a CTA button section. Extract these into typed primitives.
import { Text, Button, Section, Row, Column } from "@react-email/components";
import { colors, spacing } from "../tokens";
type HeroProps = {
heading: string;
subtitle: string;
};
export function HeroSection({ heading, subtitle }: HeroProps) {
return (
<Section style={{ paddingBottom: spacing.md }}>
<Text style={{ fontSize: "24px", fontWeight: 600, color: colors.heading, margin: 0 }}>
{heading}
</Text>
<Text style={{ fontSize: "16px", color: colors.text, lineHeight: "26px", marginTop: "8px" }}>
{subtitle}
</Text>
</Section>
);
}
type DetailRowProps = {
label: string;
value: string;
};
export function DetailRow({ label, value }: DetailRowProps) {
return (
<Row style={{ paddingBottom: "8px" }}>
<Column style={{ width: "140px" }}>
<Text style={{ fontSize: "14px", color: colors.muted, margin: 0 }}>{label}</Text>
</Column>
<Column>
<Text style={{ fontSize: "14px", color: colors.text, fontWeight: 500, margin: 0 }}>
{value}
</Text>
</Column>
</Row>
);
}
type CTABlockProps = {
href: string;
label: string;
hint?: string;
};
export function CTABlock({ href, label, hint }: CTABlockProps) {
return (
<Section style={{ textAlign: "center", padding: `${spacing.md} 0` }}>
<Button href={href} style={{
backgroundColor: colors.primary,
color: "#fff",
borderRadius: "6px",
padding: "12px 24px",
fontSize: "14px",
fontWeight: 600 as const,
textDecoration: "none",
}}>
{label}
</Button>
{hint && (
<Text style={{ fontSize: "13px", color: colors.muted, marginTop: "8px" }}>
{hint}
</Text>
)}
</Section>
);
}With these primitives, a password reset email reads like a declaration of what to show rather than how to render it:
import { BaseLayout } from "./components/base-layout";
import { HeroSection, DetailRow, CTABlock } from "./components/sections";
type PasswordResetProps = {
resetUrl: string;
expiresInMinutes: number;
requestedFromIp?: string;
};
export default function PasswordResetEmail({
resetUrl, expiresInMinutes, requestedFromIp,
}: PasswordResetProps) {
return (
<BaseLayout previewText="Reset your password">
<HeroSection
heading="Reset your password"
subtitle="Someone requested a password reset for your account."
/>
<DetailRow label="Expires in" value={`${expiresInMinutes} minutes`} />
{requestedFromIp && (
<DetailRow label="Requested from" value={requestedFromIp} />
)}
<CTABlock href={resetUrl} label="Reset Password" hint="If you didn't request this, ignore this email." />
</BaseLayout>
);
}Recommended
SaaS Essentials Pack
21+ Templates · 60+ Variations. One-time purchase, lifetime updates.
Pattern 3: Conditional rendering with props
Real transactional emails are rarely static. An invoice email might show a discount line only when a coupon was applied. A welcome email might include a team invite section only for workspace users. Instead of creating separate templates, use typed props to control what renders.
type InvoiceEmailProps = {
customerName: string;
invoiceNumber: string;
items: Array<{ description: string; amount: number }>;
discount?: { code: string; amountOff: number };
total: number;
};
export default function InvoiceEmail({
customerName, invoiceNumber, items, discount, total,
}: InvoiceEmailProps) {
return (
<BaseLayout previewText={`Invoice #${invoiceNumber}`}>
<HeroSection
heading={`Invoice #${invoiceNumber}`}
subtitle={`Thanks for your payment, ${customerName}.`}
/>
{items.map((item, i) => (
<DetailRow key={i} label={item.description} value={fmt(item.amount)} />
))}
{/* Only renders when a discount exists */}
{discount && (
<DetailRow
label={`Discount (${discount.code})`}
value={`-${fmt(discount.amountOff)}`}
/>
)}
<DetailRow label="Total" value={fmt(total)} />
<CTABlock href="https://app.example.com/billing" label="View Invoice" />
</BaseLayout>
);
}
const fmt = (n: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);The rule: if two emails differ only in which sections are visible, they should be one component with optional props — not two separate files. Separate templates when the structure differs, not when the data differs.
Pattern 4: Shared style tokens
Inline styles are unavoidable in email. But scattering hex codes and pixel values across every template is a recipe for drift. Extract your design tokens into a single constants file.
export const colors = {
primary: "#18181b",
bg: "#f6f9fc",
text: "#484848",
heading: "#1a1a1a",
muted: "#8898aa",
border: "#e6ebf1",
success: "#16a34a",
danger: "#dc2626",
} as const;
export const fonts = {
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
mono: '"SF Mono", "Roboto Mono", Menlo, monospace',
} as const;
export const spacing = {
xs: "8px",
sm: "12px",
md: "24px",
lg: "40px",
} as const;
// Reusable style fragments
export const textStyles = {
heading: {
fontSize: "24px",
fontWeight: 600 as const,
color: colors.heading,
margin: "0 0 8px",
},
body: {
fontSize: "16px",
lineHeight: "26px",
color: colors.text,
},
caption: {
fontSize: "13px",
color: colors.muted,
},
} as const;This gives you a single place to update during a brand refresh. It also makes your templates more readable since colors.muted communicates intent better than #8898aa.
Pattern 5: Preview data factories
React Email's dev server needs preview props for every template. Most teams hardcode these inline. The problem: when you add a required prop, the preview breaks silently. Instead, create typed factory functions that produce complete mock data.
import type { WelcomeEmailProps } from "./welcome";
import type { InvoiceEmailProps } from "./invoice";
import type { PasswordResetProps } from "./password-reset";
export function welcomePreview(): WelcomeEmailProps {
return {
name: "Alice Chen",
loginUrl: "https://app.example.com/login",
};
}
export function invoicePreview(
overrides?: Partial<InvoiceEmailProps>
): InvoiceEmailProps {
return {
customerName: "Acme Corp",
invoiceNumber: "INV-2024-0042",
items: [
{ description: "Pro plan (monthly)", amount: 49 },
{ description: "Extra seats x3", amount: 30 },
],
total: 79,
...overrides,
};
}
export function passwordResetPreview(): PasswordResetProps {
return {
resetUrl: "https://app.example.com/reset?token=abc123",
expiresInMinutes: 15,
requestedFromIp: "192.168.1.1",
};
}Use the factories in your template's PreviewProps:
import { invoicePreview } from "./preview-data";
// Preview always has complete, type-checked data
InvoiceEmail.PreviewProps = invoicePreview();
// Test edge cases with overrides
InvoiceEmail.PreviewProps = invoicePreview({
discount: { code: "LAUNCH20", amountOff: 15.8 },
});overrides pattern with Partial<T> is especially useful for testing conditional rendering (Pattern 3). You get a valid baseline and can selectively toggle optional fields.When to abstract vs. when to copy
Not every duplication deserves an abstraction. Premature extraction creates indirection that makes templates harder to read. Here is a practical decision framework:
Three-strike rule
The first time you copy something, let it go. The second time, note it. The third time, extract it. Two copies is not a pattern — three is.
Structural vs. cosmetic duplication
If two sections share the same structure (heading + body + button), extract a component. If they just happen to have the same font size, leave them alone. Token constants (Pattern 4) handle cosmetic consistency without adding component indirection.
The “can I explain it in one sentence” test
If a component's purpose takes more than one sentence to explain, it's doing too much. BaseLayout is “the shared wrapper for all emails.” CTABlock is “a centered button with optional hint text.” If you can't describe it that simply, split it or inline it.
Good email architecture is not about minimizing lines of code. It is about making the common case (adding a new email) fast and the risky case (changing shared branding) safe. Extract the stable parts — layout, tokens, footer. Leave the volatile parts — copy, section ordering, conditional logic — in the individual templates where they are easy to see and change.