You wouldn't ship a React component without tests. You wouldn't deploy an API without CI checks. But email templates? Most teams treat them like static assets — push changes, cross fingers, find out three hours later that the password reset email is broken in Outlook.
This guide covers how to build a CI/CD pipeline for React Email templates: type checking, snapshot tests, visual regression testing, and automated preview deployments.
Why email templates need CI
Email rendering is fragile. The same HTML can look different across 90+ email clients. A missing style attribute can break layout in Gmail. A wrong width value can overflow on mobile. And unlike web pages, you can't hotfix a sent email.
- Type errors caught before merge
- Visual diffs show exactly what changed
- Snapshot tests prevent accidental regressions
- Preview links in PRs for stakeholder review
- Automated rendering across email clients
- Type errors found when users report broken emails
- Visual changes noticed (maybe) by manual review
- Regressions discovered in production
- Screenshots shared in Slack for feedback
- "Looks fine in Gmail" (ships broken in Outlook)
Step 1: Type checking (the free win)
The simplest CI check: run the TypeScript compiler. This catches missing props, wrong types, and import errors — the bugs that Handlebars or MJML templates silently swallow.
name: Email Template CI
on:
pull_request:
paths:
- 'emails/**'
- 'emails/components/**'
- 'emails/tokens.ts'
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx tsc --noEmit
name: Type check email templatespaths filters to only run email CI when email files change. No point type-checking templates when someone edits a README.Step 2: HTML snapshot tests
Snapshot tests render each template to HTML and compare it against a stored baseline. If the HTML changes, the test fails — forcing you to review the diff before merging.
import { describe, test, expect } from "vitest";
import { render } from "@react-email/render";
import WelcomeEmail from "../welcome";
import PasswordResetEmail from "../password-reset";
import InvoiceEmail from "../invoice";
import {
welcomePreview,
passwordResetPreview,
invoicePreview,
} from "../preview-data";
describe("Email template snapshots", () => {
test("welcome email", async () => {
const html = await render(<WelcomeEmail {...welcomePreview()} />);
expect(html).toMatchSnapshot();
});
test("password reset email", async () => {
const html = await render(
<PasswordResetEmail {...passwordResetPreview()} />
);
expect(html).toMatchSnapshot();
});
test("invoice email", async () => {
const html = await render(<InvoiceEmail {...invoicePreview()} />);
expect(html).toMatchSnapshot();
});
test("invoice email with discount", async () => {
const html = await render(
<InvoiceEmail
{...invoicePreview({
discount: { code: "LAUNCH20", amountOff: 15.8 },
})}
/>
);
expect(html).toMatchSnapshot();
});
});Add the snapshot job to your CI workflow:
snapshots:
runs-on: ubuntu-latest
needs: typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest run emails/__tests__/snapshots
name: Run snapshot testsWhen a snapshot test fails, it means the rendered HTML changed. This is usually intentional (you updated the template). Run npx vitest --update to update the snapshot, then review the diff in your PR. The point is visibility, not blocking changes.
Step 3: Visual regression testing
Snapshot tests catch HTML changes. Visual regression tests catch rendering changes — the actual pixels on screen. Two different HTML strings can render identically, and identical HTML can render differently in different clients.
Using Playwright for visual regression
Render each email to HTML, open it in a headless browser, and take a screenshot. Compare against a baseline image.
import { test, expect } from "@playwright/test";
import { render } from "@react-email/render";
import WelcomeEmail from "../welcome";
import PasswordResetEmail from "../password-reset";
import { welcomePreview, passwordResetPreview } from "../preview-data";
const templates = [
{ name: "welcome", Component: WelcomeEmail, props: welcomePreview() },
{
name: "password-reset",
Component: PasswordResetEmail,
props: passwordResetPreview(),
},
];
for (const { name, Component, props } of templates) {
test(`${name} — desktop`, async ({ page }) => {
const html = await render(<Component {...props} />);
await page.setContent(html);
await page.setViewportSize({ width: 800, height: 600 });
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
maxDiffPixelRatio: 0.01,
});
});
test(`${name} — mobile`, async ({ page }) => {
const html = await render(<Component {...props} />);
await page.setContent(html);
await page.setViewportSize({ width: 375, height: 667 });
await expect(page).toHaveScreenshot(`${name}-mobile.png`, {
maxDiffPixelRatio: 0.01,
});
});
}maxDiffPixelRatio: 0.01 means the test tolerates up to 1% pixel difference. This accounts for minor anti-aliasing variations between CI environments. Adjust based on your tolerance.Add visual regression to your CI:
visual-regression:
runs-on: ubuntu-latest
needs: typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test emails/__tests__/visual
name: Visual regression tests
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diff-report
path: test-results/Step 4: Preview deployments for PR review
For design-heavy email changes, automated tests aren't enough. You want stakeholders to see the email in a browser. Deploy a preview of React Email's dev server for every PR.
name: Email Preview Deploy
on:
pull_request:
paths:
- 'emails/**'
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
# Build a static preview of all email templates
- run: npx react-email export --outDir email-preview
name: Export email previews
- name: Deploy to Vercel Preview
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_EMAIL_PREVIEW_PROJECT_ID }}
working-directory: email-preview
- name: Comment PR with preview link
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: ' + '$' + '{{ steps.deploy.outputs.preview-url }}'
})Step 5: Render validation (lint for email HTML)
HTML that works in a browser doesn't always work in email clients. Add validation rules that catch common email rendering problems.
import { describe, test, expect } from "vitest";
import { render } from "@react-email/render";
import WelcomeEmail from "../welcome";
import { welcomePreview } from "../preview-data";
describe("Email render validation", () => {
test("no external stylesheets (blocked by most clients)", async () => {
const html = await render(<WelcomeEmail {...welcomePreview()} />);
expect(html).not.toMatch(/<link[^>]*rel="stylesheet"/);
});
test("no JavaScript (stripped by all clients)", async () => {
const html = await render(<WelcomeEmail {...welcomePreview()} />);
expect(html).not.toMatch(/<script/);
});
test("all images have alt text", async () => {
const html = await render(<WelcomeEmail {...welcomePreview()} />);
const imgWithoutAlt = html.match(/<img(?![^>]*alt=)[^>]*>/g);
expect(imgWithoutAlt).toBeNull();
});
test("all images use HTTPS", async () => {
const html = await render(<WelcomeEmail {...welcomePreview()} />);
const httpImages = html.match(/src="http:///g);
expect(httpImages).toBeNull();
});
test("total HTML size under 102KB (Gmail clipping threshold)", async () => {
const html = await render(<WelcomeEmail {...welcomePreview()} />);
const sizeKB = Buffer.byteLength(html, "utf-8") / 1024;
expect(sizeKB).toBeLessThan(102);
});
});Gmail clips emails larger than 102KB. Outlook ignores external CSS. Every client strips JavaScript. These aren't edge cases — they're the baseline. Validate in CI so you catch them before your users do.
The complete pipeline
Here's the full CI workflow, combining all five steps:
name: Email Template CI
on:
pull_request:
paths:
- 'emails/**'
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
needs: typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npx vitest run emails/__tests__/snapshots
- run: npx vitest run emails/__tests__/validation
visual-regression:
runs-on: ubuntu-latest
needs: typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test emails/__tests__/visual
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diff-report
path: test-results/Scaling the pipeline
As your template count grows, the pipeline needs to stay fast. Here are the patterns that keep CI under 3 minutes even with 50+ templates:
- Only test changed templates: Use
git diffto identify which templates changed in the PR and only run visual regression on those. - Parallelize visual tests: Playwright supports
--shardfor splitting tests across CI workers. - Cache Playwright browsers: Cache the Chromium install between runs to save 30-60 seconds.
- Skip preview deploys for non-visual changes: If only
tokens.tschanged, run tests but skip the preview deployment.
CI/CD checklist for email templates
- TypeScript type checking on every PR
- HTML snapshot tests for all templates with preview data
- Render validation (no scripts, no external CSS, alt text, size limits)
- Visual regression tests for desktop and mobile viewports
- Preview deployments with PR comments
- Path filters so email CI only runs on email file changes
- Artifact upload for visual diff reports on failure
Start with type checking and snapshot tests — they take 30 minutes to set up and catch 80% of bugs. Add visual regression when you have more than 10 templates or when design accuracy matters (invoices, receipts, branded emails). The pipeline pays for itself the first time it catches a broken email before production.