React Email14 min read

Email Component Library Patterns: Build Reusable React Email Components That Scale

Build a production-ready React Email component library in under an hour. Foundation patterns for base layouts, styled primitives, buttons, sections, and alerts that prevent template chaos.

R

React Emails Pro

March 1, 2026

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.

The goal: write an email template in 20 lines, not 200. Reusable components give you speed, consistency, and fewer bugs when you change a color or tweak a layout.

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.

components/emails/base-layout.tsx
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:

emails/welcome.tsx
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>
  );
}
One layout. Zero boilerplate. Every new email is 15 lines instead of 80.

Pattern 2: Styled primitives (typography + spacing)

Don't inline text styles everywhere. Build typed primitives with consistent spacing and hierarchy.

Heading component

components/emails/heading.tsx
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

components/emails/text.tsx
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>
  );
}
Type-safe variants prevent "fontSize: 15.5px" drift. If it's not in the component, it doesn't exist in your emails.

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.

components/emails/button.tsx
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:

emails/password-reset.tsx
<Button href={resetUrl} variant="primary">
  Reset password
</Button>

<Button href={cancelUrl} variant="secondary">
  Cancel request
</Button>
Dark mode tip: Use mid-tone colors (#2563eb, not #000000) and test in Gmail dark mode. Absolute blacks get inverted unpredictably.

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.

components/emails/section.tsx
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:

emails/invoice.tsx
<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.

components/emails/alert.tsx
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:

emails/failed-payment.tsx
<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:

Project structure
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.tsx
Primitives are atoms (text, buttons). Layout is structure (sections, containers). Feedback is status (alerts, badges). Complex is composed UI (lists, tables, grids).

Testing 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:

Terminal
npm run email dev

Navigate 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:

emails/_component-playground.tsx
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:

tests/components.spec.ts
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.
Don't over-abstract. If a component is only used once, it's not a component — it's just indirection. Build components when you copy-paste the same code three times.

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.

Tiny win: Add a _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.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates