You can't iterate on email design by deploying to production and checking your inbox. You need previews.
But most React Email preview setups stop at npm run dev — which works fine solo, but breaks down the moment you need teammates to review, QA to test across clients, or a safe way to preview templates in staging without sending real emails.
Pattern 1: Local Preview Server (React Email Dev Mode)
Start here. React Email ships with a built-in preview server that renders all templates with hot reload. Perfect for solo development.
# Install React Email
npm install react-email @react-email/components
# Add to package.json scripts
{
"email:dev": "email dev"
}
# Start preview server
npm run email:devThis starts a preview server at localhost:3000 with a sidebar listing all your email templates. Click any template to see it rendered with preview data.
preview-data.ts file that exports realistic data fixtures for each template. Makes iteration way faster than hardcoding props in each file.File Structure That Works
emails/
├── templates/
│ ├── welcome.tsx
│ ├── password-reset.tsx
│ └── invoice.tsx
├── preview-data/
│ ├── welcome.ts
│ ├── password-reset.ts
│ └── invoice.ts
└── components/
└── base-layout.tsxEach template gets a matching preview data file. React Email automatically picks up the exported default.
export default {
userName: "Alex Morgan",
ctaUrl: "https://app.yourproduct.com/onboarding",
trialDays: 14,
supportEmail: "help@yourproduct.com",
};Pattern 2: Team Preview Endpoint (for Design Review)
Local previews don't help teammates who aren't running the dev server. Add a /api/email-preview route that renders templates on-demand with query params.
import { render } from "@react-email/render";
import { NextRequest, NextResponse } from "next/server";
import WelcomeEmail from "@/emails/templates/welcome";
import PasswordResetEmail from "@/emails/templates/password-reset";
import previewData from "@/emails/preview-data";
// Only allow in non-production
if (process.env.NODE_ENV === "production") {
throw new Error("Preview route should not exist in production");
}
const TEMPLATES = {
welcome: WelcomeEmail,
"password-reset": PasswordResetEmail,
// Add more...
};
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const template = searchParams.get("template");
const variant = searchParams.get("variant") || "default";
if (!template || !TEMPLATES[template as keyof typeof TEMPLATES]) {
return NextResponse.json(
{ error: "Invalid template" },
{ status: 400 }
);
}
const TemplateComponent = TEMPLATES[template as keyof typeof TEMPLATES];
const props = previewData[template]?.[variant] || previewData[template];
const html = render(<TemplateComponent {...props} />);
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
}Now teammates can preview templates at:/api/email-preview?template=welcome
Pattern 3: Staging Preview Gallery (QA-Friendly)
Build a dedicated preview gallery page that lists all templates with variants. Great for QA teams testing across different email clients.
import { getAllTemplates } from "@/lib/email-registry";
export default function EmailPreviewPage() {
const templates = getAllTemplates();
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Email Preview Gallery</h1>
<div className="grid gap-4">
{templates.map((template) => (
<div key={template.slug} className="border rounded-lg p-4">
<h2 className="font-semibold">{template.name}</h2>
<p className="text-sm text-gray-600 mb-3">{template.description}</p>
<div className="flex gap-2">
<a
href={`/api/email-preview?template=${template.slug}`}
target="_blank"
className="text-blue-600 hover:underline text-sm"
>
Preview Default
</a>
{template.variants?.map((variant) => (
<a
key={variant}
href={`/api/email-preview?template=${template.slug}&variant=${variant}`}
target="_blank"
className="text-blue-600 hover:underline text-sm"
>
Preview {variant}
</a>
))}
</div>
</div>
))}
</div>
</div>
);
}Add a simple template registry to make this work:
export const EMAIL_TEMPLATES = [
{
slug: "welcome",
name: "Welcome Email",
description: "Sent after user signs up",
variants: ["with-trial", "immediate-activation"],
},
{
slug: "password-reset",
name: "Password Reset",
description: "Security email for password resets",
variants: ["standard", "suspicious-activity"],
},
// Add more...
];
export function getAllTemplates() {
return EMAIL_TEMPLATES;
}Pattern 4: CI-Deployed Previews (for PR Reviews)
Deploy preview environments for every pull request. Lets designers and stakeholders review email changes without pulling code locally.
Works great with:
- Vercel: Automatic preview deployments for every PR
- Netlify: Deploy previews with unique URLs
- GitHub Actions: Custom preview deployment workflow
name: Deploy Email Previews
on:
pull_request:
paths:
- 'emails/**'
- 'components/**'
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build email previews
run: npm run email:build
- name: Deploy to preview environment
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
npx vercel --token=$VERCEL_TOKEN --yes
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '📧 Email preview deployed: https://preview-${{ github.event.pull_request.number }}.yourapp.com/email-preview'
})Pattern 5: Production-Safe Preview Route (Read-Only Admin)
Sometimes you need to preview emails in production — to debug issues, show stakeholders what was sent, or test with real data. But you can't just expose a public preview endpoint.
Add an admin-only, read-only preview route with proper authentication:
import { render } from "@react-email/render";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET(request: NextRequest) {
// 1. Require authentication
const session = await getServerSession(authOptions);
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2. Rate limit
const ip = request.headers.get("x-forwarded-for") || "unknown";
const rateLimitOk = await checkRateLimit(ip, { max: 10, window: 60 });
if (!rateLimitOk) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{ status: 429 }
);
}
// 3. Render template with sanitized props
const searchParams = request.nextUrl.searchParams;
const template = searchParams.get("template");
const dataId = searchParams.get("dataId");
// Fetch safe preview data (never expose sensitive user data)
const safeProps = await getPreviewData(template, dataId);
const html = render(getTemplateComponent(template, safeProps));
return new NextResponse(html, {
headers: {
"Content-Type": "text/html",
"X-Robots-Tag": "noindex, nofollow",
"Cache-Control": "no-store, max-age=0",
},
});
}Security checklist for production previews:
- ✅ Require admin authentication
- ✅ Rate limit aggressively
- ✅ Never expose real user data (use sanitized fixtures)
- ✅ Add noindex headers
- ✅ Log all access for audit trail
- ✅ Consider IP allowlisting for extra paranoia
Bonus: Cross-Client Preview Testing
Previews in a browser aren't enough. Gmail, Outlook, and Apple Mail render HTML differently. Use these tools to test real client rendering:
- Email on Acid: Automated cross-client screenshots
- Litmus: Preview + spam testing
- Mailtrap: Safe inbox for staging email testing
- Playwright: Automated visual regression tests
import { test, expect } from "@playwright/test";
test("Welcome email renders correctly", async ({ page }) => {
await page.goto("http://localhost:3000/api/email-preview?template=welcome");
// Wait for email to render
await page.waitForSelector("body");
// Take screenshot for visual regression
await expect(page).toHaveScreenshot("welcome-email.png", {
fullPage: true,
});
// Verify critical elements
await expect(page.locator("h1")).toContainText("Welcome");
await expect(page.locator('a[href*="onboarding"]')).toBeVisible();
});Which Patterns to Use?
Solo developer / early startup:
- ✅ Pattern 1 (local preview)
- ✅ Pattern 2 (team preview API)
Small team (2-10 people):
- ✅ Pattern 1 (local preview)
- ✅ Pattern 3 (staging gallery)
- ✅ Pattern 4 (CI previews for PRs)
Production SaaS with compliance requirements:
- ✅ All patterns
- ✅ Pattern 5 (production admin previews)
- ✅ Automated cross-client testing
- ✅ Audit logging for preview access
Common Mistakes
1) Exposing preview routes in production
Don't leave /api/email-preview accessible in production without auth. Attackers can use it to:
- Enumerate email templates
- Discover internal URLs
- Test injection attacks
Fix: Add environment checks or move preview routes to admin-only paths.
2) Using production data in preview fixtures
Never pull real user data into preview environments. Use sanitized fixtures or synthetic data.
// ❌ Bad: Exposes real user data
const props = await db.user.findFirst();
// ✅ Good: Synthetic preview data
const props = {
userName: "Alex Morgan (Test User)",
email: "preview@example.com",
ctaUrl: "https://staging.yourapp.com/test",
};3) No variant support
Most templates have edge cases: long names, missing data, different states. Preview all variants, not just the happy path.
export default {
default: {
invoiceNumber: "INV-2024-001",
lineItems: [
{ name: "Pro Plan", amount: 49.00 },
{ name: "Additional seats (2)", amount: 20.00 },
],
total: 69.00,
},
singleItem: {
invoiceNumber: "INV-2024-002",
lineItems: [
{ name: "Starter Plan", amount: 19.00 },
],
total: 19.00,
},
longNames: {
invoiceNumber: "INV-2024-003",
lineItems: [
{
name: "Enterprise Plan with Additional Features and Custom Integration Support",
amount: 499.00
},
],
total: 499.00,
},
};Implementation Checklist
- ✅ Set up React Email dev server for local iteration
- ✅ Create preview data fixtures for all templates
- ✅ Add team preview API route (dev/staging only)
- ✅ Build preview gallery page for QA
- ✅ Set up CI preview deployments for PRs
- ✅ Add production admin preview route (if needed)
- ✅ Test across Gmail, Outlook, Apple Mail
- ✅ Add visual regression tests
Good preview environments turn email iteration from "deploy and pray" into a confident, collaborative workflow. Start with local previews, add team/staging patterns as you scale, and keep production previews locked down.