You're flying blind if you're not testing email variants. But most teams either skip A/B testing entirely or build it wrong — hardcoding variants, logging to nowhere, and calling winner based on vibes.
Production A/B testing for emails needs five pieces: variant routing, deterministic assignment, tracking infrastructure, statistical significance, and clean rollout.
The five layers of email A/B testing infrastructure
A production-ready A/B testing system for transactional emails needs these layers working together:
- Variant definition: where you declare tests and variants
- Assignment logic: deterministic user → variant mapping
- Rendering layer: conditionally render variants in templates
- Event tracking: log sends, opens, clicks per variant
- Analysis + rollout: declare winners and phase out losers
Let's build each layer with production patterns that don't fall apart under load.
Layer 1: Variant definition (centralized config)
Don't scatter test definitions across your codebase. Use a centralized registry that's easy to audit and update.
export const AB_TESTS = {
"welcome-subject-test": {
id: "welcome-subject-test",
name: "Welcome email subject line test",
status: "active", // active | paused | concluded
startDate: "2026-03-01",
endDate: "2026-03-15",
variants: {
control: {
id: "control",
weight: 0.5,
subject: "Welcome to Acme",
},
variant_a: {
id: "variant_a",
weight: 0.5,
subject: "Your Acme account is ready",
},
},
},
"reset-cta-test": {
id: "reset-cta-test",
name: "Password reset CTA copy test",
status: "active",
startDate: "2026-03-01",
variants: {
control: {
id: "control",
weight: 0.5,
ctaCopy: "Reset password",
},
variant_a: {
id: "variant_a",
weight: 0.5,
ctaCopy: "Create new password",
},
},
},
} as const;
export type ABTestId = keyof typeof AB_TESTS;
export type VariantId<T extends ABTestId> = keyof typeof AB_TESTS[T]["variants"];status to pause tests without deleting config. Use weight to control traffic split (0.5 = 50%, 0.8 = 80%, etc.). Sum of weights should equal 1.0.Layer 2: Assignment logic (deterministic + consistent)
The golden rule: same user = same variant across sessions. Use a hash function (not Math.random()) to assign variants deterministically.
import { createHash } from "crypto";
import { AB_TESTS, type ABTestId } from "./ab-tests";
/**
* Assign a user to a variant deterministically.
* Same userId + testId always returns same variant.
*/
export function assignVariant<T extends ABTestId>(
testId: T,
userId: string
): keyof typeof AB_TESTS[T]["variants"] {
const test = AB_TESTS[testId];
// If test is not active, return control
if (test.status !== "active") {
return "control" as keyof typeof AB_TESTS[T]["variants"];
}
// Hash userId + testId to get deterministic 0-1 value
const hash = createHash("sha256")
.update(`${userId}:${testId}`)
.digest("hex");
// Convert first 8 hex chars to 0-1 range
const hashValue = parseInt(hash.substring(0, 8), 16) / 0xffffffff;
// Assign variant based on cumulative weights
let cumulative = 0;
const variants = Object.entries(test.variants);
for (const [variantId, config] of variants) {
cumulative += config.weight;
if (hashValue < cumulative) {
return variantId as keyof typeof AB_TESTS[T]["variants"];
}
}
// Fallback to control (should never hit)
return "control" as keyof typeof AB_TESTS[T]["variants"];
}
/**
* Get variant config for a user
*/
export function getVariantConfig<T extends ABTestId>(
testId: T,
userId: string
) {
const variantId = assignVariant(testId, userId);
return AB_TESTS[testId].variants[variantId];
}Math.random() would give different variants on each send.Layer 3: Rendering layer (template integration)
Now wire variants into your React Email templates. Keep it clean: fetch variant config, pass it as props, conditionally render.
Example: Subject line A/B test
import { render } from "@react-email/render";
import { resend } from "./resend";
import { assignVariant, getVariantConfig } from "./ab-assignment";
import { WelcomeEmail } from "@/emails/welcome";
export async function sendWelcomeEmail(userId: string, userEmail: string) {
// Assign variant
const variant = getVariantConfig("welcome-subject-test", userId);
// Render email with variant props
const html = render(<WelcomeEmail userName="Jane" />);
const text = render(<WelcomeEmail userName="Jane" />, { plainText: true });
// Send with variant subject line
const result = await resend.emails.send({
from: "Acme <hello@acme.com>",
to: userEmail,
subject: variant.subject, // Dynamic subject from variant
html,
text,
tags: [
{ name: "test_id", value: "welcome-subject-test" },
{ name: "variant_id", value: variant.id },
{ name: "user_id", value: userId },
],
});
// Log send event (for analysis)
await logABEvent({
testId: "welcome-subject-test",
variantId: variant.id,
userId,
event: "sent",
timestamp: new Date(),
metadata: { emailId: result.data?.id },
});
return result;
}Example: CTA copy A/B test
import { Button, Html, Text } from "@react-email/components";
interface PasswordResetEmailProps {
resetUrl: string;
ctaCopy: string; // Variant prop
}
export function PasswordResetEmail({ resetUrl, ctaCopy }: PasswordResetEmailProps) {
return (
<Html>
<Text>You requested a password reset.</Text>
<Button href={resetUrl}>{ctaCopy}</Button>
<Text>Expires in 1 hour.</Text>
</Html>
);
}import { render } from "@react-email/render";
import { resend } from "./resend";
import { getVariantConfig } from "./ab-assignment";
import { PasswordResetEmail } from "@/emails/password-reset";
export async function sendPasswordReset(userId: string, userEmail: string, resetUrl: string) {
const variant = getVariantConfig("reset-cta-test", userId);
const html = render(
<PasswordResetEmail resetUrl={resetUrl} ctaCopy={variant.ctaCopy} />
);
const result = await resend.emails.send({
from: "Acme <security@acme.com>",
to: userEmail,
subject: "Reset your password",
html,
tags: [
{ name: "test_id", value: "reset-cta-test" },
{ name: "variant_id", value: variant.id },
],
});
await logABEvent({
testId: "reset-cta-test",
variantId: variant.id,
userId,
event: "sent",
timestamp: new Date(),
});
return result;
}test_id and variant_id.Layer 4: Event tracking (sends, opens, clicks)
Track four events per variant: sent, delivered, opened, clicked. Store them in a way you can query later.
import { db } from "./db";
interface ABEvent {
testId: string;
variantId: string;
userId: string;
event: "sent" | "delivered" | "opened" | "clicked";
timestamp: Date;
metadata?: Record<string, unknown>;
}
export async function logABEvent(event: ABEvent) {
await db.abEvents.create({
data: {
testId: event.testId,
variantId: event.variantId,
userId: event.userId,
event: event.event,
timestamp: event.timestamp,
metadata: event.metadata || {},
},
});
}
/**
* Webhook handler: track opens and clicks
*/
export async function handleEmailWebhook(payload: {
type: "email.opened" | "email.clicked";
tags: Array<{ name: string; value: string }>;
userId?: string;
}) {
const testId = payload.tags.find((t) => t.name === "test_id")?.value;
const variantId = payload.tags.find((t) => t.name === "variant_id")?.value;
const userId = payload.tags.find((t) => t.name === "user_id")?.value;
if (!testId || !variantId || !userId) return; // Not an A/B test email
const event = payload.type === "email.opened" ? "opened" : "clicked";
await logABEvent({
testId,
variantId,
userId,
event,
timestamp: new Date(),
});
}Layer 5: Analysis + rollout (statistical significance)
Don't call winners too early. Use a chi-squared test (or similar) to check if differences are statistically significant.
import { db } from "./db";
interface VariantStats {
variantId: string;
sent: number;
opened: number;
clicked: number;
openRate: number;
clickRate: number;
}
export async function getTestResults(testId: string): Promise<VariantStats[]> {
const events = await db.abEvents.findMany({
where: { testId },
});
const variantMap = new Map<string, VariantStats>();
// Initialize stats
for (const event of events) {
if (!variantMap.has(event.variantId)) {
variantMap.set(event.variantId, {
variantId: event.variantId,
sent: 0,
opened: 0,
clicked: 0,
openRate: 0,
clickRate: 0,
});
}
}
// Count events
for (const event of events) {
const stats = variantMap.get(event.variantId)!;
if (event.event === "sent") stats.sent++;
if (event.event === "opened") stats.opened++;
if (event.event === "clicked") stats.clicked++;
}
// Calculate rates
for (const stats of variantMap.values()) {
stats.openRate = stats.sent > 0 ? stats.opened / stats.sent : 0;
stats.clickRate = stats.sent > 0 ? stats.clicked / stats.sent : 0;
}
return Array.from(variantMap.values());
}
/**
* Simple chi-squared test for significance
* (Use a real stats library in production)
*/
export function isSignificant(
controlClicks: number,
controlSent: number,
variantClicks: number,
variantSent: number,
alpha = 0.05 // 95% confidence
): boolean {
const controlRate = controlClicks / controlSent;
const variantRate = variantClicks / variantSent;
const pooledRate = (controlClicks + variantClicks) / (controlSent + variantSent);
const expectedControl = controlSent * pooledRate;
const expectedVariant = variantSent * pooledRate;
const chiSquared =
Math.pow(controlClicks - expectedControl, 2) / expectedControl +
Math.pow(variantClicks - expectedVariant, 2) / expectedVariant;
const criticalValue = 3.841; // Chi-squared critical value for alpha=0.05, df=1
return chiSquared > criticalValue;
}Declaring a winner and rolling out
Once a variant wins with statistical significance:
- Update the test status to "concluded" in your config
- Update the default template to use the winning variant
- Archive the test (keep the data, remove the conditional logic)
- Document the result for future reference
// Before: A/B test active
const variant = getVariantConfig("welcome-subject-test", userId);
const subject = variant.subject;
// After: Winner rolled out (variant_a won)
const subject = "Your Acme account is ready"; // Winning variant hardcodedCommon A/B testing patterns for emails
1. Subject line tests
Test clarity vs curiosity, length, personalization, urgency.
- Control: "Welcome to Acme"
- Variant A: "Your Acme account is ready"
- Metric: Open rate (primary), click rate (secondary)
2. CTA copy tests
Test action-oriented vs outcome-oriented language.
- Control: "Reset password"
- Variant A: "Create new password"
- Metric: Click-through rate
3. Send time tests
Test immediate vs delayed sends for onboarding sequences.
- Control: Send welcome email immediately on signup
- Variant A: Delay by 30 minutes (let user explore first)
- Metric: Open rate + activation rate
4. Content length tests
Test concise vs detailed explanations.
- Control: 3-sentence email with one CTA
- Variant A: 5-sentence email with bullets + CTA
- Metric: Click rate + support ticket volume
Gotchas and anti-patterns
- Sample size trap: Don't declare winners with <100 conversions per variant. Noise dominates.
- Peeking problem: Don't check results every hour and stop when you see a winner. Let the test run to completion.
- Segment confusion: Don't run the same test on different user cohorts without controlling for segments.
- Correlation ≠ causation: A variant that wins during a product launch might not win in steady state.
Production checklist
Before shipping your A/B testing infrastructure:
- ✅ Variant assignment is deterministic (same user always gets same variant)
- ✅ Test config includes start/end dates and status (active/paused)
- ✅ Every send is tagged with test_id and variant_id
- ✅ Webhook handler deduplicates events (unique opens/clicks per user)
- ✅ Analysis requires minimum sample size before declaring winner
- ✅ Paused tests fall back to control (not random or broken)
- ✅ Test data is queryable and exportable for manual analysis
When NOT to A/B test emails
A/B testing isn't always worth it. Skip it when:
- Low volume: If you send <100 emails/week, you won't reach significance for months
- Critical security emails: Don't test password resets or 2FA codes — ship the safest version
- Legal/compliance emails: Receipts, GDPR notices, TOS updates — these need consistency, not optimization
- Time-sensitive flows: If the email must send within seconds (OTP codes), don't add complexity
Good candidates for A/B testing: welcome emails, trial ending reminders, feature announcements, re-engagement campaigns.
If you want production-ready email templates to test against, see React Email templates for SaaS. For monitoring and tracking setup, check Webhook monitoring for React Email.