DEV Community

Cover image for Lint your React Email templates in CI (before Gmail strips half your CSS)
KAM Sie Philippe ANgelo
KAM Sie Philippe ANgelo

Posted on

Lint your React Email templates in CI (before Gmail strips half your CSS)

Your email looks perfect in the React Email preview. Then Gmail strips half the CSS, and Outlook on Windows renders it with Word's engine from 2007. Nobody notices until a customer replies with a screenshot.

Frontend developers solved this problem years ago: we lint, we type-check, we run visual tests in CI. Email developers mostly still send a test to themselves and squint at it on their phone. This post shows how to close that gap for React Email projects: compile your templates in CI, lint the output against real email client support data, and fail the PR when someone introduces CSS that Outlook will eat.

Full disclosure up front: I built the linter used below, Emailens. The engine is open source (MIT), the free tier covers this workflow, and the approach works even if you swap in your own checks.

Why linting beats "send a test email"

Render-testing services like Litmus screenshot your email in real clients. That's valuable, but it's slow, costs per test, and happens late. Linting is the complement: static analysis of your compiled HTML against a database of what each client actually supports (think caniemail.com, but as a build step). It runs in seconds, costs nothing per run, and catches the boring 80% of breakage: unsupported CSS properties, missing MSO conditionals, dark mode surprises, oversized HTML that Gmail clips at 102KB.

The React Email abstraction makes this more important, not less. JSX components hide the compiled output from you. You never see the HTML that ships, which means you never see the border-radius that silently does nothing in Outlook.

Step 1: compile your templates to HTML

React Email templates are React components, so first render them to static HTML. The react-email CLI does this in one command:

npx react-email export
Enter fullscreen mode Exit fullscreen mode

This writes plain HTML files to an out/ directory, one per template. That compiled output is what your users' inboxes receive, and it's what we lint.

Step 2: lint the compiled output

npx @emailens/cli lint out/*.html
Enter fullscreen mode Exit fullscreen mode

The output looks like a familiar code linter, except the rules come from email client support data:

out/welcome.html
  error  outlook-windows     border-radius           Not supported in Outlook Windows
  warn   spam                caps-ratio              20%+ of words are ALL CAPS

out/newsletter.html
  pass   No issues found

2 files | 1 error | 1 warning
Enter fullscreen mode Exit fullscreen mode

Under the hood it flattens several audits into one issue list: CSS compatibility across 15 clients, accessibility, link validation, image checks, spam signals, size limits, and leftover template variables (the {{first_name}} that escaped into production, we've all been there).

The exit codes are designed for CI:

  • 0 clean
  • 1 errors found
  • 2 warnings only, when you pass --fail-on-warning or exceed --max-warnings

You can also lint JSX directly with --format jsx if you'd rather skip the export step, but linting the compiled HTML is closer to what actually ships.

Step 3: fail the PR in GitHub Actions

name: Email lint

on:
  pull_request:
    paths:
      - "emails/**"

jobs:
  lint-emails:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: Compile React Email templates
        run: npx react-email export
      - name: Lint compiled emails
        run: npx @emailens/cli lint out/*.html --max-warnings 5
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. A PR that adds position: sticky to an email now fails with a named client and a named property instead of a confused customer three weeks later.

Two useful refinements once the basics work:

Scope the noisy checks. Early on, spam heuristics on a transactional receipt can be noise. Skip categories you don't care about yet: --skip spam,inboxPreview.

Get machine-readable output. --json gives you structured results if you want to post a summary comment on the PR or track score trends over time.

What about the issues a linter can't fix?

Some Outlook problems aren't one-property fixes; they need structural changes like table-based layouts or VML fallbacks. For those there's an AI-assisted mode (emailens fix email.html, bring your own Anthropic API key) that rewrites the structure and prints the diff. I treat it like a code-mod: useful suggestion, human reviews before merge.

And linting doesn't replace screenshots entirely. My workflow is: lint on every PR (fast, free, catches most regressions), screenshot-test before big campaign sends (slow, thorough, catches the weird stuff). The linter's per-client compatibility score, 0 to 100 per client, is also a decent early-warning metric: when the Outlook score drops ten points in one PR, someone should look at that diff.

The bigger point

Email HTML is the last corner of frontend where "works on my machine" is still the default QA strategy. It doesn't have to be. Your email templates are code; treat them like code. Compile them, lint them, gate merges on the result, and save the manual eyeballing for the things machines can't judge, like whether your copy is any good.

Links, for the curious: Emailens (free tier: 30 previews/day), the open-source engine, the CLI on npm, and an MCP server if you want Claude or Cursor to run these checks for you.

Questions or war stories about emails breaking in production? I'd love to hear them in the comments.

Top comments (0)