React Email's preview mode is where most email bugs actually show up — not in production, but during development when you're testing edge cases.
The problem: preview data becomes an afterthought. You hardcode a couple of values, ship the template, and then production props drift away from what you tested.
The problem with lazy preview data
Here's what typical preview data looks like:
// ❌ Bad: Minimal, unrealistic preview data
PasswordResetEmail.PreviewProps = {
resetUrl: "https://example.com/reset",
userName: "John",
};This preview passes during development, but production sends:
- Long URLs with tracking parameters
- Edge case names (unicode, apostrophes, null values)
- Optional fields that may or may not exist
- Dynamic content based on user state
Result: Your email looks perfect in preview, breaks in production.
Pattern 1: Use a preview data factory
Instead of hardcoding props, create a factory function that generates realistic test data with variants.
// ✅ Good: Factory with multiple variants
export function createPasswordResetPreview(
variant: "default" | "long-name" | "tracked-url" = "default"
) {
const base = {
userName: "Jane Doe",
resetUrl: "https://app.example.com/auth/reset?token=abc123",
expiresInMinutes: 15,
};
const variants = {
"default": base,
"long-name": {
...base,
userName: "María José Gutiérrez-O'Neill",
},
"tracked-url": {
...base,
resetUrl:
"https://app.example.com/auth/reset?token=abc123&utm_source=email&utm_campaign=password_reset&ref=notification",
},
};
return variants[variant];
}
// Usage in your email template:
PasswordResetEmail.PreviewProps = createPasswordResetPreview();npm run email dev and manually test each variant to catch layout issues (long names breaking buttons, URLs wrapping awkwardly, etc.).Pattern 2: Mirror production schemas with Zod
If you validate props in production, use the same schema for preview data. This catches type mismatches before they ship.
import { z } from "zod";
// Production schema
export const InvoiceEmailSchema = z.object({
invoiceNumber: z.string(),
customerName: z.string(),
items: z.array(
z.object({
name: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})
),
total: z.number().positive(),
dueDate: z.string().datetime(),
});
export type InvoiceEmailProps = z.infer<typeof InvoiceEmailSchema>;
// Preview data validated at dev time
InvoiceEmail.PreviewProps = InvoiceEmailSchema.parse({
invoiceNumber: "INV-2026-00142",
customerName: "Acme Corp",
items: [
{ name: "Pro Plan (Annual)", quantity: 1, price: 299.0 },
{ name: "Additional seat", quantity: 3, price: 29.0 },
],
total: 386.0,
dueDate: new Date("2026-03-15").toISOString(),
});Now if production adds a new required field or changes a type, your preview breaks during development — not after users complain.
Pattern 3: Test with missing optional fields
Optional props are where runtime errors hide. Create preview variants that intentionally omit optionals to verify your fallbacks work.
export function createWelcomePreview(
variant: "full" | "minimal" = "full"
) {
const full = {
userName: "Alex Chen",
companyName: "TechCorp",
onboardingUrl: "https://app.example.com/onboarding",
accountManagerName: "Sarah Johnson",
accountManagerEmail: "sarah@example.com",
};
// Test what happens when optional fields are missing
const minimal = {
userName: full.userName,
onboardingUrl: full.onboardingUrl,
// No company, no account manager
};
return variant === "full" ? full : minimal;
}
// In your template component:
export default function WelcomeEmail({
userName,
companyName,
onboardingUrl,
accountManagerName,
}: WelcomeEmailProps) {
return (
<>
<Heading>Welcome{companyName ? ` to ${companyName}` : ""}, {userName}!</Heading>
<Button href={onboardingUrl}>Get Started</Button>
{accountManagerName && (
<Text>
Your account manager, {accountManagerName}, will reach out within 24 hours.
</Text>
)}
</>
);
}minimal variant, you'll ship emails with undefined showing up in production copy.Pattern 4: Centralize preview data registry
As your email count grows, preview data gets scattered. Create a central registry so you can test all emails from one place.
import { createPasswordResetPreview } from "./preview-data/auth";
import { createInvoicePreview } from "./preview-data/billing";
import { createWelcomePreview } from "./preview-data/onboarding";
export const PREVIEW_REGISTRY = {
"password-reset": {
default: createPasswordResetPreview(),
"long-name": createPasswordResetPreview("long-name"),
"tracked-url": createPasswordResetPreview("tracked-url"),
},
invoice: {
default: createInvoicePreview(),
"multi-item": createInvoicePreview("multi-item"),
},
welcome: {
full: createWelcomePreview("full"),
minimal: createWelcomePreview("minimal"),
},
} as const;
// Usage: Loop through variants in CI or a test script
export function getAllPreviewVariants() {
return Object.entries(PREVIEW_REGISTRY).flatMap(([templateId, variants]) =>
Object.entries(variants).map(([variantId, props]) => ({
templateId,
variantId,
props,
}))
);
}Now you can write a script that renders every variant and catches layout issues before they ship.
Pattern 5: Snapshot testing with realistic data
Use preview data in your tests to catch regressions. If a change breaks the rendered HTML, your snapshot test fails.
import { render } from "@react-email/components";
import { describe, it, expect } from "vitest";
import PasswordResetEmail from "@/emails/password-reset";
import { createPasswordResetPreview } from "@/lib/preview-data";
describe("PasswordResetEmail", () => {
it("renders with default preview data", () => {
const html = render(<PasswordResetEmail {...createPasswordResetPreview()} />);
expect(html).toMatchSnapshot();
});
it("handles long names without layout breaks", () => {
const html = render(
<PasswordResetEmail {...createPasswordResetPreview("long-name")} />
);
expect(html).toContain("María José Gutiérrez-O'Neill");
expect(html).toMatchSnapshot();
});
it("renders tracked URLs correctly", () => {
const props = createPasswordResetPreview("tracked-url");
const html = render(<PasswordResetEmail {...props} />);
expect(html).toContain(props.resetUrl);
});
});npm test before opening a PR. If your change alters the rendered HTML unexpectedly, the snapshot diff shows exactly what broke.Common anti-patterns (and how to avoid them)
1) "lorem ipsum" in preview data
Placeholder text doesn't reveal layout issues. Use realistic data: real names, real product names, real-length copy.
- ❌ Bad:
userName: "User" - ✅ Good:
userName: "Alejandra Rodríguez-Martínez"
2) No edge case variants
If you only test the happy path, edge cases break in production. Add variants for:
- Empty arrays (no items in invoice, no notifications)
- Long strings (URLs, addresses, product names)
- Special characters (unicode, apostrophes, quotes)
- Missing optional fields
3) Preview data that doesn't match production types
If you use TypeScript but don't validate preview data, you'll ship type mismatches. Always use the same type/schema for preview and production.
Implementation checklist
- Create a
lib/preview-datafolder with factory functions - Use Zod schemas to validate preview props at dev time
- Define at least 2-3 variants per template (default, edge case, minimal)
- Centralize preview data in a registry for easy testing
- Add snapshot tests that use realistic preview data
- Manually preview each variant before shipping
Related patterns
Preview data is one piece of email reliability. Also see:
- Testing React Email Templates — How to write unit tests and visual regression tests
- Error Handling for React Email — Defensive patterns for production sends
- Type-Safe Email Templates — Using Zod and generics to catch errors at compile time
If your preview data matches production reality, you catch bugs during development instead of after users complain. That's the whole point.