Code Tips12 min read

CI/CD Pipeline for Email Templates: Automated Testing and Visual Regression

Stop shipping broken emails. Build a CI pipeline with TypeScript checks, HTML snapshot tests, Playwright visual regression, and automated preview deployments for React Email templates.

R

React Emails Pro

March 7, 2026

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.

We'll use GitHub Actions, Vitest, and Playwright for visual regression. The patterns work with any CI provider — the concepts transfer.

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.

With CI pipeline
  • 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
Without CI pipeline
  • 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.

.github/workflows/email-ci.yml
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 templates
Use paths 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.

emails/__tests__/snapshots.test.ts
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:

.github/workflows/email-ci.yml
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 tests
Key takeaway

When 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.

emails/__tests__/visual.test.ts
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:

.github/workflows/email-ci.yml
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.

.github/workflows/email-preview.yml
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 }}'
            })
You don't need Vercel for this. Any static hosting works — Netlify, Cloudflare Pages, even an S3 bucket with CloudFront. The point is giving reviewers a clickable 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.

emails/__tests__/validation.test.ts
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);
  });
});
Key takeaway

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:

.github/workflows/email-ci.yml
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 diff to identify which templates changed in the PR and only run visual regression on those.
  • Parallelize visual tests: Playwright supports --shard for 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.ts changed, run tests but skip the preview deployment.

CI/CD checklist for email templates

  1. TypeScript type checking on every PR
  2. HTML snapshot tests for all templates with preview data
  3. Render validation (no scripts, no external CSS, alt text, size limits)
  4. Visual regression tests for desktop and mobile viewports
  5. Preview deployments with PR comments
  6. Path filters so email CI only runs on email file changes
  7. 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.

R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates