React Email12 min read

Email Template Versioning: How to Ship Breaking Changes Without Breaking Production

Stop breaking in-flight emails. Learn versioned template patterns, gradual rollout strategies, and backward-compatible migrations for React Email in production.

R

React Emails Pro

March 1, 2026

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.

The cost of no versioning strategy: broken templates in production, inconsistent user experience, impossible rollbacks, and a codebase where no one knows which template is actually being sent.

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.

lib/email-templates.ts
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:

lib/send-email.ts
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}`,
    },
  });
}
Why this works: You can ship 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.

app/api/queue-email/route.ts
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:

lib/process-email-queue.ts
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() },
    });
  }
}
Use case: Drip campaigns, scheduled announcements, or any workflow where emails are queued days/weeks in advance. This prevents the "I shipped a template update and broke 1,000 scheduled sends" incident.

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.

lib/send-email.ts
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:

  1. Rename existing templates: password-reset.tsx becomes password-reset-v1.tsx
  2. Create the registry: Map template names to versions with v1 as default
  3. Update send calls: Route through the registry instead of importing directly
  4. Ship v1 as-is: No behavior change yet — just infrastructure
  5. Add v2: Create new version, test in preview, deploy behind feature flag
  6. Gradual rollout: Monitor metrics, ramp traffic, update default
  7. Deprecate v1: After v2 is stable, remove old version (keep a grace period)
Grace period matters: Don't delete old template versions immediately. Emails in queue or scheduled sends may still reference them. Wait 30-90 days before removal (depending on your longest send delay).

Testing versioned templates

Versioned templates introduce new failure modes. Add these tests to catch regressions:

1. Validate the template registry

tests/email-templates.test.ts
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

tests/template-versions.test.ts
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

tests/queue-compatibility.test.ts
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)

Do
  • 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)
Don't
  • 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
Rule of thumb: If an in-flight email using the old props would throw an error or look broken, bump the version. If it just looks slightly different, you can ship in-place.

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 default from 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=v1 URLs
app/api/preview/[template]/route.ts
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)

Key takeaway

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.

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