Code Tips14 min read

Email Template Organization: File Structure Patterns That Scale from 5 to 50 Templates

Stop copy-pasting components between email templates. Learn file structure patterns, component organization, preview data management, and registry patterns that scale from startup to enterprise.

R

React Emails Pro

March 3, 2026

Your first React Email template goes in emails/welcome.tsx. Your second template goes in emails/reset.tsx. By template five, you're copy-pasting components between files. By template ten, you have three different button styles and no idea which one is canonical.

Email template codebases turn into copy-paste chaos because most developers treat them like throwaway code. But email templates are frontend code—they need the same organizational rigor as your components folder.

The goal: a file structure that scales from 5 templates to 50 without requiring a refactor every time you add a new flow.

The Starter Structure (5-10 templates)

If you're just getting started, this structure gets you 80% of the way there:

File structure
emails/
├── _components/
│   ├── Button.tsx
│   ├── Footer.tsx
│   ├── Header.tsx
│   └── Layout.tsx
├── welcome.tsx
├── password-reset.tsx
├── email-verification.tsx
├── invoice.tsx
└── trial-ending.tsx

Shared components live in _components/. Top-level files are individual templates. Simple, flat, easy to navigate.

The underscore prefix keeps shared code visually separate from templates and prevents import confusion.

The Scaling Structure (10-50 templates)

Once you hit 10+ templates, flat structure breaks down. You need categorization:

Scaled structure
emails/
├── _lib/
│   ├── components/
│   │   ├── primitives/
│   │   │   ├── Button.tsx
│   │   │   ├── Text.tsx
│   │   │   └── Heading.tsx
│   │   ├── sections/
│   │   │   ├── Header.tsx
│   │   │   ├── Footer.tsx
│   │   │   └── Hero.tsx
│   │   └── layouts/
│   │       ├── BaseLayout.tsx
│   │       └── MarketingLayout.tsx
│   ├── styles/
│   │   ├── tokens.ts
│   │   └── colors.ts
│   └── utils/
│       ├── formatters.ts
│       └── validators.ts
├── onboarding/
│   ├── welcome.tsx
│   ├── email-verification.tsx
│   └── first-action-nudge.tsx
├── billing/
│   ├── invoice.tsx
│   ├── failed-payment.tsx
│   └── subscription-renewal.tsx
├── security/
│   ├── password-reset.tsx
│   ├── password-changed.tsx
│   └── magic-link.tsx
└── engagement/
    ├── trial-ending.tsx
    ├── feature-announcement.tsx
    └── usage-report.tsx

Templates are grouped by lifecycle stage, not alphabetically. Shared code lives under _lib/ with clear sub-categorization.


Pattern 1: Colocate Template-Specific Components

Not every component needs to be shared. If a component is only used in one template, keep it close:

Colocation example
emails/
├── billing/
│   ├── invoice/
│   │   ├── index.tsx              # Main template
│   │   ├── LineItemTable.tsx      # Invoice-specific component
│   │   └── PaymentSummary.tsx     # Invoice-specific component
│   └── failed-payment.tsx

When LineItemTable only appears in invoices, shipping it next to invoice/index.tsx makes refactoring easier and reduces cognitive load.

Only promote components to _lib/components after they're used in two or more templates. Premature abstraction creates more problems than it solves.

Pattern 2: Separate Data from Templates

Keep preview data, test fixtures, and prop schemas separate from your template JSX:

Data separation
emails/
├── onboarding/
│   ├── welcome.tsx
│   ├── welcome.data.ts            # Preview data
│   └── welcome.schema.ts          # Zod schema
├── billing/
│   ├── invoice.tsx
│   ├── invoice.data.ts
│   └── invoice.schema.ts
emails/onboarding/welcome.schema.ts
import { z } from "zod";

export const WelcomeEmailSchema = z.object({
  userName: z.string().min(1),
  appName: z.string(),
  dashboardUrl: z.string().url(),
  supportEmail: z.string().email(),
});

export type WelcomeEmailProps = z.infer<typeof WelcomeEmailSchema>;
emails/onboarding/welcome.data.ts
import type { WelcomeEmailProps } from "./welcome.schema";

export const welcomeEmailPreviewData: WelcomeEmailProps = {
  userName: "Alex Rivera",
  appName: "Acme Dashboard",
  dashboardUrl: "https://app.acme.com/dashboard",
  supportEmail: "support@acme.com",
};

export const welcomeEmailEdgeCases = {
  longName: {
    ...welcomeEmailPreviewData,
    userName: "Alexander Wolfgang Maximilian Rivera-Johnson III",
  },
  specialChars: {
    ...welcomeEmailPreviewData,
    userName: "José María García-López",
  },
};

This pattern keeps your template files clean while making preview data and schemas easy to locate and maintain.


Pattern 3: Create a Template Registry

As your codebase grows, maintaining a central registry prevents import hell and gives you a single source of truth:

emails/_lib/registry.ts
import WelcomeEmail from "../onboarding/welcome";
import PasswordResetEmail from "../security/password-reset";
import InvoiceEmail from "../billing/invoice";

export const EMAIL_TEMPLATES = {
  "onboarding.welcome": {
    component: WelcomeEmail,
    subject: (props) => `Welcome to ${props.appName}`,
    category: "onboarding",
  },
  "security.password-reset": {
    component: PasswordResetEmail,
    subject: () => "Reset your password",
    category: "security",
  },
  "billing.invoice": {
    component: InvoiceEmail,
    subject: (props) => `Invoice #${props.invoiceNumber}`,
    category: "billing",
  },
} as const;

export type EmailTemplateId = keyof typeof EMAIL_TEMPLATES;

// Type-safe template renderer
export function renderEmail<T extends EmailTemplateId>(
  templateId: T,
  props: Parameters<(typeof EMAIL_TEMPLATES)[T]["component"]>[0]
) {
  const template = EMAIL_TEMPLATES[templateId];
  return {
    component: template.component(props),
    subject: template.subject(props as any),
  };
}

With a registry, you get:

  • Type-safe template references across your codebase
  • Centralized subject line management
  • Easy auditing of all templates in your app
  • Single import point for rendering functions

Pattern 4: Build a Dedicated Preview Server

React Email's dev server is great for prototyping, but production apps need better preview tooling. Create a dedicated route:

app/api/email-preview/[template]/route.ts
import { NextRequest } from "next/server";
import { render } from "@react-email/render";
import { EMAIL_TEMPLATES } from "@/emails/_lib/registry";

export async function GET(
  request: NextRequest,
  { params }: { params: { template: string } }
) {
  const template = EMAIL_TEMPLATES[params.template];

  if (!template) {
    return new Response("Template not found", { status: 404 });
  }

  // Load preview data dynamically
  const previewData = await import(
    `@/emails/${params.template.replace(".", "/")}.data`
  );

  const html = render(template.component(previewData.default));

  return new Response(html, {
    headers: { "Content-Type": "text/html" },
  });
}

Now you can preview any template at /api/email-preview/onboarding.welcome with production-like data loading.

Add query params for variant switching: ?variant=longName to test edge cases.

Pattern 5: Centralize Your Style System

Stop hardcoding colors and spacing. Create a token system:

emails/_lib/styles/tokens.ts
export const colors = {
  // Brand
  primary: "#2563eb",
  primaryDark: "#1e40af",

  // Neutrals
  gray50: "#f9fafb",
  gray100: "#f3f4f6",
  gray700: "#374151",
  gray900: "#111827",

  // Semantic
  success: "#10b981",
  warning: "#f59e0b",
  error: "#ef4444",
} as const;

export const spacing = {
  xs: "4px",
  sm: "8px",
  md: "16px",
  lg: "24px",
  xl: "32px",
  xxl: "48px",
} as const;

export const typography = {
  fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
  fontSize: {
    xs: "12px",
    sm: "14px",
    base: "16px",
    lg: "18px",
    xl: "20px",
    xxl: "24px",
  },
  lineHeight: {
    tight: 1.25,
    normal: 1.5,
    relaxed: 1.75,
  },
} as const;

Now your components import tokens instead of magic strings:

emails/_lib/components/primitives/Button.tsx
import { colors, spacing, typography } from "../../styles/tokens";

export function Button({ href, children }: { href: string; children: React.ReactNode }) {
  return (
    <a
      href={href}
      style={{
        display: "inline-block",
        padding: `${spacing.md} ${spacing.xl}`,
        backgroundColor: colors.primary,
        color: "#ffffff",
        fontFamily: typography.fontFamily,
        fontSize: typography.fontSize.base,
        textDecoration: "none",
        borderRadius: "6px",
      }}
    >
      {children}
    </a>
  );
}

Pattern 6: Organize Tests with Templates

Keep tests next to the templates they cover:

Test colocation
emails/
├── onboarding/
│   ├── welcome.tsx
│   ├── welcome.data.ts
│   ├── welcome.schema.ts
│   └── welcome.test.tsx          # Unit + render tests
├── billing/
│   ├── invoice.tsx
│   ├── invoice.data.ts
│   ├── invoice.schema.ts
│   └── invoice.test.tsx
emails/onboarding/welcome.test.tsx
import { render } from "@react-email/render";
import { describe, it, expect } from "vitest";
import WelcomeEmail from "./welcome";
import { welcomeEmailPreviewData, welcomeEmailEdgeCases } from "./welcome.data";
import { WelcomeEmailSchema } from "./welcome.schema";

describe("WelcomeEmail", () => {
  it("renders with valid props", () => {
    const result = WelcomeEmailSchema.parse(welcomeEmailPreviewData);
    const html = render(<WelcomeEmail {...result} />);

    expect(html).toContain(welcomeEmailPreviewData.userName);
    expect(html).toContain(welcomeEmailPreviewData.dashboardUrl);
  });

  it("handles long names without breaking layout", () => {
    const html = render(<WelcomeEmail {...welcomeEmailEdgeCases.longName} />);
    expect(html).toContain("Alexander Wolfgang");
  });

  it("rejects invalid props", () => {
    expect(() =>
      WelcomeEmailSchema.parse({ userName: "", appName: "Test" })
    ).toThrow();
  });
});

How to Migrate from Flat to Scaled Structure

Don't do it all at once. Here's a safe migration path:

  1. Create _lib/ folder — move shared components there first
  2. Add schemas + data files — create .schema.ts and .data.ts for each template
  3. Group by category — create folders like onboarding/, billing/ and move templates one at a time
  4. Build the registry — once templates are grouped, create _lib/registry.ts
  5. Update imports — swap direct imports for registry-based rendering
Don't migrate during a sprint with active email work. Pick a slow week or do it incrementally over multiple PRs.

Common Anti-Patterns to Avoid

1. One giant components folder

components/Button.tsx, components/Button2.tsx, components/ButtonPrimary.tsx...

Fix: Use nested folders: primitives/, sections/, layouts/.

2. Mixing marketing and transactional templates

Marketing emails and transactional emails have different lifecycles, testing needs, and compliance requirements.

Fix: Separate them at the top level or use clear folder boundaries.

3. No version control for copy changes

Embedding copy directly in templates makes A/B testing and rollbacks impossible.

Fix: Extract copy to .data.ts files or a centralized copy registry.

4. Treating templates as "not real code"

Email templates are frontend code. They deserve linting, testing, type safety, and code review.

Fix: Apply the same standards you'd use for your component library.


Organization Checklist

Use this checklist to audit your email template structure:

  • ✅ Templates are grouped by lifecycle stage or flow
  • ✅ Shared components live in _lib/components/
  • ✅ Each template has a colocated .schema.ts and .data.ts
  • ✅ Style tokens are centralized in _lib/styles/tokens.ts
  • ✅ A template registry provides type-safe rendering
  • ✅ Tests are colocated with templates
  • ✅ Preview server supports all templates + edge cases
  • ✅ No hardcoded colors, spacing, or typography

Conclusion

Email template organization isn't glamorous, but it's the difference between a codebase you can maintain and one that becomes a liability after six months.

Start with the starter structure if you have fewer than 10 templates. Migrate to the scaling structure when you hit organizational pain. And remember: colocation beats premature abstraction every time.

Your future self (and your teammates) will thank you.

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