React Email has a preview server. Most developers start it once, check that the email looks okay, and then ship.
That's fine for early prototypes. But in production, you need to know that your templates work across clients, with real data, and when props change. Manual spot-checking doesn't scale.
Local preview testing: the dev server workflow
React Email includes a preview server that hot-reloads your templates. It's the fastest feedback loop for design and layout work.
# Start the preview server
npm run email dev
# Or if you configured it differently
npx react-email devThe server runs on http://localhost:3000 by default and shows all your email templates with sample props.
Preview data: build factories, not inline objects
Most developers inline preview props at the top of each template. That works until you need to test edge cases or reuse preview data in tests.
Better pattern: centralize preview data in a factory file.
export const previewData = {
passwordReset: {
base: {
userName: "Alex",
resetLink: "https://app.example.com/reset/abc123",
expiresInMinutes: 15,
},
longName: {
userName: "Alexandria Konstantinopolous-Weatherby",
resetLink: "https://app.example.com/reset/abc123",
expiresInMinutes: 15,
},
expiringSoon: {
userName: "Alex",
resetLink: "https://app.example.com/reset/abc123",
expiresInMinutes: 1,
},
},
welcome: {
base: {
userName: "Jordan",
dashboardLink: "https://app.example.com/dashboard",
supportEmail: "help@example.com",
},
noName: {
userName: undefined,
dashboardLink: "https://app.example.com/dashboard",
supportEmail: "help@example.com",
},
},
};Now your template imports clean preview data, and your tests can reuse the same fixtures.
import { previewData } from "./preview-data";
export default function PasswordResetEmail(props: PasswordResetProps) {
// Template code...
}
// Preview props for the dev server
PasswordResetEmail.PreviewProps = previewData.passwordReset.base;Automated testing: catch breaks before deployment
Preview servers are manual. Automated tests catch regressions during code review.
Unit tests: validate props and rendering
Use Vitest (or Jest) to test that your email templates handle props correctly and don't crash on missing data.
import { describe, it, expect } from "vitest";
import { render } from "@react-email/render";
import PasswordResetEmail from "../password-reset";
import { previewData } from "../preview-data";
describe("PasswordResetEmail", () => {
it("renders with valid props", () => {
const html = render(
<PasswordResetEmail {...previewData.passwordReset.base} />
);
expect(html).toContain("Reset your password");
expect(html).toContain(previewData.passwordReset.base.resetLink);
});
it("handles missing userName gracefully", () => {
const html = render(
<PasswordResetEmail
{...previewData.passwordReset.base}
userName={undefined}
/>
);
expect(html).toContain("Reset your password");
expect(html).not.toContain("undefined");
});
it("includes expiration warning when < 5 minutes", () => {
const html = render(
<PasswordResetEmail {...previewData.passwordReset.expiringSoon} />
);
expect(html).toContain("expires soon");
});
it("does not crash with malformed props", () => {
expect(() => {
render(
<PasswordResetEmail
userName="Alex"
resetLink=""
expiresInMinutes={-1}
/>
);
}).not.toThrow();
});
});These tests are fast, run in CI, and catch the most common template failures: undefined values, broken conditionals, missing fallbacks.
Schema validation: enforce props at the boundary
Pair unit tests with schema validation (Zod, Yup) to catch bad data before render.
import { z } from "zod";
const PasswordResetPropsSchema = z.object({
userName: z.string().optional(),
resetLink: z.string().url(),
expiresInMinutes: z.number().positive(),
});
export type PasswordResetProps = z.infer<typeof PasswordResetPropsSchema>;
export default function PasswordResetEmail(props: PasswordResetProps) {
// Validate at runtime (throws if invalid)
const validated = PasswordResetPropsSchema.parse(props);
return (
// Use validated props...
);
}Visual regression testing: catch layout breaks
Unit tests validate logic. Visual regression tests validate appearance.
Use Playwright to screenshot your emails in the preview server, then compare against baseline images.
import { test, expect } from "@playwright/test";
test.describe("Email templates visual regression", () => {
test.beforeEach(async ({ page }) => {
// Assumes preview server is running on localhost:3000
await page.goto("http://localhost:3000");
});
test("password reset email matches baseline", async ({ page }) => {
await page.click('text="PasswordResetEmail"');
await page.waitForSelector('iframe[title="Preview"]');
const frame = page.frameLocator('iframe[title="Preview"]');
await expect(frame.locator("body")).toHaveScreenshot("password-reset.png");
});
test("welcome email matches baseline", async ({ page }) => {
await page.click('text="WelcomeEmail"');
await page.waitForSelector('iframe[title="Preview"]');
const frame = page.frameLocator('iframe[title="Preview"]');
await expect(frame.locator("body")).toHaveScreenshot("welcome.png");
});
});On first run, Playwright captures baseline screenshots. On future runs, it compares new renders against baselines and fails if pixels differ.
--update-snapshots disabled. Only update baselines deliberately after reviewing changes locally.Cross-client testing: Gmail, Outlook, Apple Mail
The hardest part of email development: what looks perfect in preview can break in Outlook, Gmail mobile, or dark mode.
You need to test real clients. Here are three practical approaches.
Option 1: Litmus or Email on Acid (paid)
Services like Litmus and Email on Acid render your emails in 90+ real email clients and provide side-by-side screenshots.
- Pros: comprehensive, includes dark mode and mobile clients
- Cons: expensive ($99+/month), manual workflow
Option 2: Self-hosted email testing (MailHog + real accounts)
Use MailHog or Mailpit to capture emails locally, then forward them to real Gmail, Outlook, and Apple Mail accounts for manual review.
# Run MailHog locally
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
# Configure your app to send to localhost:1025
# Visit http://localhost:8025 to view captured emailsForward each email to:
- A Gmail account (test web + mobile app)
- An Outlook.com account (test web + Windows client)
- An iCloud account (test Apple Mail on macOS/iOS)
Option 3: Automated cross-client testing with Playwright
If you need repeatable cross-client testing in CI, you can automate Gmail and Outlook web clients with Playwright.
import { test, expect } from "@playwright/test";
test.describe("Cross-client email rendering", () => {
test("password reset renders correctly in Gmail", async ({ page }) => {
// Send test email to Gmail test account
await sendTestEmail({
to: "test@gmail.com",
template: "password-reset",
});
// Log into Gmail and check rendering
await page.goto("https://mail.google.com");
await page.fill('input[type="email"]', process.env.GMAIL_TEST_EMAIL);
await page.click('button:has-text("Next")');
// ... full login flow ...
// Open the email and screenshot
await page.click('text="Reset your password"');
await expect(page).toHaveScreenshot("gmail-password-reset.png");
});
});This is advanced and brittle (Gmail UI changes break tests), but it's the only way to automate real client testing without a paid service.
CI integration: run tests on every PR
Add email tests to your CI pipeline so broken templates never reach main.
name: Email Tests
on:
pull_request:
paths:
- 'emails/**'
- 'tests/emails/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Run email unit tests
run: npm run test:emails
- name: Start preview server
run: npm run email dev &
env:
CI: true
- name: Wait for preview server
run: npx wait-on http://localhost:3000
- name: Run visual regression tests
run: npx playwright test tests/email-screenshots.spec.ts
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results/Dark mode testing: don't forget it
Gmail, Outlook, and Apple Mail all apply dark mode transformations. Sometimes they invert your colors. Sometimes they don't.
Add dark mode variants to your preview data and test manually.
// Use color-scheme meta tag to hint at dark mode support
<head>
<meta name="color-scheme" content="light dark" />
</head>
// Use inline styles that work in both modes
<div style={{
backgroundColor: "#ffffff",
color: "#000000",
// Avoid pure black/white that clients invert badly
}}>
{/* Content */}
</div>Test dark mode manually in:
- Gmail (web + mobile app with dark theme enabled)
- Outlook (Windows client with dark mode)
- Apple Mail (macOS with system dark mode)
Pre-deployment checklist
Before you ship a new email template, run through this checklist:
- ✅ Preview all variants in dev server (base + edge cases)
- ✅ Unit tests pass (props validation + rendering)
- ✅ Visual regression tests pass (layout unchanged)
- ✅ Sent test email to Gmail, Outlook, Apple Mail
- ✅ Checked dark mode rendering in all three clients
- ✅ Verified links work (no localhost URLs)
- ✅ Plain text version generated correctly
- ✅ Subject line and preheader set
If you skip steps, your first production send will be your cross-client test. Don't do that.
When to test (and when to ship fast)
Not every email needs the full testing gauntlet. Here's how to prioritize:
- High-stakes emails (password reset, verification, billing): full test suite, cross-client checks, visual regression
- Marketing emails (newsletters, announcements): preview + one real client test
- Internal notifications (admin alerts, logs): preview only, ship fast
Start with unit tests + preview. Add visual regression when template changes cause production incidents. Add cross-client testing when users complain about rendering.
For production-ready templates that come pre-tested, check out our SaaS template library — all core flows are already validated across Gmail, Outlook, and Apple Mail.