Here's the scenario: you shipped a password reset email three months ago. Now you need to update the layout. But 500 password reset emails are still in queue, and 10,000 users have the old link format bookmarked.
Ship the new template and break in-flight emails? Keep the old one and never improve? Most teams pick option three: copy-paste the template, add a v2 suffix, and pray they remember which one to use.
Why email templates need versioning (and your React components don't)
React components can be updated atomically — deploy and every user sees the new version instantly. Email templates can't:
- In-flight emails: Queued emails may render hours or days later
- Scheduled sends: Drip campaigns reference templates weeks in advance
- Data contracts: Old links/tokens may still be valid when template changes
- A/B tests: You need stable variants during experiment windows
Breaking a password reset template on Friday at 5pm is how you spend your weekend apologizing to support.
Pattern 1: Explicit versioning with template registry
The simplest pattern that actually works: version templates explicitly and route through a registry.
import PasswordResetV1 from "@/emails/password-reset-v1";
import PasswordResetV2 from "@/emails/password-reset-v2";
import WelcomeV1 from "@/emails/welcome-v1";
export const EMAIL_TEMPLATES = {
"password-reset": {
v1: PasswordResetV1,
v2: PasswordResetV2,
default: "v2", // Active version
},
"welcome": {
v1: WelcomeV1,
default: "v1",
},
} as const;
export type TemplateName = keyof typeof EMAIL_TEMPLATES;
export type TemplateVersion<T extends TemplateName> =
keyof Omit<typeof EMAIL_TEMPLATES[T], "default">;Now your send function references versions explicitly:
import { render } from "@react-email/render";
import { EMAIL_TEMPLATES } from "./email-templates";
export async function sendEmail<T extends TemplateName>({
template,
version,
to,
props,
}: {
template: T;
version?: TemplateVersion<T>;
to: string;
props: any;
}) {
const templateConfig = EMAIL_TEMPLATES[template];
const activeVersion = version ?? templateConfig.default;
const Template = templateConfig[activeVersion];
const html = render(<Template {...props} />);
// Send with your provider (Resend, etc.)
await resend.emails.send({
from: "noreply@example.com",
to,
subject: props.subject,
html,
headers: {
// Track version for debugging
"X-Template-Version": `${template}-${activeVersion}`,
},
});
}v2 without breaking queued emails that still reference v1. The registry makes it explicit which version is active, and old versions stay callable for in-flight sends.Pattern 2: Database-driven version selection
For SaaS products with scheduled sends or long-running campaigns, store the template version in the database when the email is enqueued.
import { db } from "@/lib/db";
import { EMAIL_TEMPLATES } from "@/lib/email-templates";
export async function POST(req: Request) {
const { template, to, props, sendAt } = await req.json();
// Capture active version at queue time
const activeVersion = EMAIL_TEMPLATES[template].default;
await db.emailQueue.create({
data: {
template,
version: activeVersion, // Locked to current default
to,
props,
sendAt: sendAt ? new Date(sendAt) : new Date(),
status: "pending",
},
});
return Response.json({ queued: true });
}When processing the queue, render the exact version that was active when the email was scheduled:
export async function processEmailQueue() {
const pending = await db.emailQueue.findMany({
where: {
status: "pending",
sendAt: { lte: new Date() },
},
});
for (const job of pending) {
await sendEmail({
template: job.template,
version: job.version, // Use stored version
to: job.to,
props: job.props,
});
await db.emailQueue.update({
where: { id: job.id },
data: { status: "sent", sentAt: new Date() },
});
}
}Pattern 3: Feature flags for gradual rollout
For high-stakes templates (password reset, payment failures), roll out changes gradually with feature flags instead of flipping a switch.
import { getFeatureFlag } from "@/lib/feature-flags";
export async function sendPasswordReset({ to, resetToken }: Props) {
// Roll out v2 to 10% of users
const useV2 = await getFeatureFlag("password-reset-v2", {
userId: to,
rolloutPercentage: 10,
});
const version = useV2 ? "v2" : "v1";
await sendEmail({
template: "password-reset",
version,
to,
props: { resetToken },
});
// Log version for analytics
analytics.track("email_sent", {
template: "password-reset",
version,
userId: to,
});
}Monitor error rates, click-through rates, and support tickets for the new version. If metrics look good, ramp to 50%, then 100%.
How to migrate from "one file per template" to versioned templates
If you're already shipping emails and want to adopt versioning without breaking production, here's the safe migration path:
- Rename existing templates:
password-reset.tsxbecomespassword-reset-v1.tsx - Create the registry: Map template names to versions with
v1as default - Update send calls: Route through the registry instead of importing directly
- Ship v1 as-is: No behavior change yet — just infrastructure
- Add v2: Create new version, test in preview, deploy behind feature flag
- Gradual rollout: Monitor metrics, ramp traffic, update default
- Deprecate v1: After v2 is stable, remove old version (keep a grace period)
Testing versioned templates
Versioned templates introduce new failure modes. Add these tests to catch regressions:
1. Validate the template registry
import { EMAIL_TEMPLATES } from "@/lib/email-templates";
import { describe, it, expect } from "vitest";
describe("Email template registry", () => {
it("every template has a default version", () => {
for (const [name, config] of Object.entries(EMAIL_TEMPLATES)) {
expect(config.default).toBeDefined();
expect(config[config.default]).toBeDefined();
}
});
it("default version points to an existing template", () => {
for (const [name, config] of Object.entries(EMAIL_TEMPLATES)) {
const defaultVersion = config.default;
expect(config[defaultVersion]).toBeTruthy();
}
});
});2. Test all versions render without errors
import { render } from "@react-email/render";
import { EMAIL_TEMPLATES } from "@/lib/email-templates";
describe("Template version rendering", () => {
const mockProps = {
"password-reset": { resetToken: "abc123", userName: "Test" },
"welcome": { userName: "Test", activationUrl: "https://example.com" },
};
for (const [templateName, config] of Object.entries(EMAIL_TEMPLATES)) {
for (const [version, Template] of Object.entries(config)) {
if (version === "default") continue;
it(`${templateName} ${version} renders without errors`, () => {
const props = mockProps[templateName as keyof typeof mockProps];
expect(() => render(<Template {...props} />)).not.toThrow();
});
}
}
});3. Test backward compatibility for queued emails
describe("Queued email compatibility", () => {
it("can send emails queued with old version", async () => {
// Simulate a queued email from before v2 shipped
const queuedEmail = {
template: "password-reset",
version: "v1", // Old version
to: "user@example.com",
props: { resetToken: "old-token" },
};
// Should still send without errors
await expect(
sendEmail(queuedEmail)
).resolves.not.toThrow();
});
});Common mistakes (and how to avoid them)
- Version at queue time (not send time) for scheduled emails
- Use feature flags for gradual rollout of high-stakes templates
- Include version in email headers for debugging (X-Template-Version)
- Keep old versions available for 30-90 days after deprecation
- Test all active versions in CI (not just the default)
- Hard-delete old template versions immediately after shipping new ones
- Ship template changes without a rollback plan
- Use the same version number for breaking prop changes
- Forget to update the default version in the registry after rollout
- Skip testing backward compatibility with old queue entries
When to bump the version number
Not every change needs a new version. Use this heuristic:
- Bump version: Changing props interface, layout restructure, breaking link formats, new required fields
- Same version: Copy tweaks, color updates, non-breaking additions to optional props
Production deployment notes
A few things to remember when shipping versioned templates to production:
- Monitor version distribution: Track which versions are actually being sent (use headers or analytics)
- Gradual default updates: Don't flip
defaultfrom v1 → v2 without monitoring v2 performance first - Document deprecation timelines: Make it clear when old versions will be removed
- Preview all versions: Your preview server should support
?version=v1URLs
export async function GET(
req: Request,
{ params }: { params: { template: string } }
) {
const url = new URL(req.url);
const version = url.searchParams.get("version");
const templateConfig = EMAIL_TEMPLATES[params.template];
const activeVersion = version ?? templateConfig.default;
const Template = templateConfig[activeVersion];
const html = render(<Template {...mockProps} />);
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}
// Preview URLs:
// /api/preview/password-reset (shows default)
// /api/preview/password-reset?version=v1 (shows v1)
// /api/preview/password-reset?version=v2 (shows v2)Email templates aren't like React components — they don't update atomically. In-flight sends, scheduled campaigns, and queued emails all need stable templates. Versioning gives you safe rollouts, reliable rollbacks, and the ability to ship improvements without breaking production.
Start with a simple registry, version at queue time, and test backward compatibility. Your future self (and your support team) will thank you.
Want production-ready templates with props interfaces designed for versioning? Check out the SaaS template library — typed, tested, and built for real codebases.
Also useful: Composable React Email patterns for building reusable template components, and Testing React Email templates for catching breaking changes before they ship.