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 Starter Structure (5-10 templates)
If you're just getting started, this structure gets you 80% of the way there:
emails/
├── _components/
│ ├── Button.tsx
│ ├── Footer.tsx
│ ├── Header.tsx
│ └── Layout.tsx
├── welcome.tsx
├── password-reset.tsx
├── email-verification.tsx
├── invoice.tsx
└── trial-ending.tsxShared components live in _components/. Top-level files are individual templates. Simple, flat, easy to navigate.
The Scaling Structure (10-50 templates)
Once you hit 10+ templates, flat structure breaks down. You need categorization:
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.tsxTemplates 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:
emails/
├── billing/
│ ├── invoice/
│ │ ├── index.tsx # Main template
│ │ ├── LineItemTable.tsx # Invoice-specific component
│ │ └── PaymentSummary.tsx # Invoice-specific component
│ └── failed-payment.tsxWhen LineItemTable only appears in invoices, shipping it next to invoice/index.tsx makes refactoring easier and reduces cognitive load.
_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:
emails/
├── onboarding/
│ ├── welcome.tsx
│ ├── welcome.data.ts # Preview data
│ └── welcome.schema.ts # Zod schema
├── billing/
│ ├── invoice.tsx
│ ├── invoice.data.ts
│ └── invoice.schema.tsimport { 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>;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:
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:
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.
?variant=longName to test edge cases.Pattern 5: Centralize Your Style System
Stop hardcoding colors and spacing. Create a token system:
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:
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:
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.tsximport { 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:
- Create
_lib/folder — move shared components there first - Add schemas + data files — create
.schema.tsand.data.tsfor each template - Group by category — create folders like
onboarding/,billing/and move templates one at a time - Build the registry — once templates are grouped, create
_lib/registry.ts - Update imports — swap direct imports for registry-based rendering
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.tsand.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.