Why preview deployments are the right place to catch visual bugs

Code review is bad at catching visual regressions. A reviewer reading a CSS diff has to mentally render the cascade — across every page that shares the changed component, in both themes, at every breakpoint. Nobody actually does this, which is why visual bugs ship in PRs that looked completely reasonable as text.

A screenshot diff against the preview deployment checks what the browser renders instead of what the diff says. The workflow below gives every PR a snapdiff/visual-test status check: green when every page matches its baseline, pending while a human reviews intentional changes, and — with branch protection on — blocking when something regressed.

The whole setup: one secret, one workflow file

Create a project in the SnapDiff dashboard, create an API key, and add it to your repo as the SNAPDIFF_API_KEY Actions secret. Then drop this into .github/workflows/visual-regression.yml:

name: Visual Regression
on: pull_request

jobs:
  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: corralimited/snapdiff-action@v1
        with:
          api-key: ${{ secrets.SNAPDIFF_API_KEY }}
          project: my-site          # your SnapDiff project slug
          pages: |
            homepage=/
            pricing=/pricing
            docs=/docs

There's deliberately nothing else in there — no VERCEL_TOKEN, no org or project IDs, no polling step for the preview URL. The action reads GitHub's deployments API, finds the Vercel preview for the PR's commit, and uses its environment_url as the base for every path in pages:. Vercel's GitHub integration already writes those deployment statuses; the action just reads them.

What happens on each pull request

  1. Vercel deploys the preview, as it already does.
  2. The action discovers the preview URL and creates a SnapDiff build that captures each page in your list.
  3. Each capture is diffed against the baseline for that page — baselines are tracked per branch, so long-running feature branches don't fight with main.
  4. A snapdiff/visual-test commit status lands on the PR. If every page matches, it goes green. If anything changed, the status links to a build review page in the dashboard.
  5. A human reviews the changed pages side-by-side (before, after, diff overlay) and clicks Approve — promoting the new screenshots as baselines and flipping the check green — or Request changes, keeping the merge blocked.
First run on a new branch: every page reports "changed." That's expected — the first capture is the baseline. Real diffs start on the second push.

Making it actually block bad merges

A status check only blocks merges if you tell GitHub it's required. In your repo: Settings → Branches → Branch protection rules → Require status checks to pass, and add snapdiff/visual-test. To enable status posting, connect a GitHub token (scope repo:status) in your project's settings page in the SnapDiff dashboard — GitHub config is per-project, not account-wide.

From then on a visual regression blocks merge the same way a failing unit test does.

Previews behind Deployment Protection

If your previews require Vercel's Deployment Protection, pass the automation bypass secret as a capture header:

- uses: corralimited/snapdiff-action@v1
  with:
    api-key: ${{ secrets.SNAPDIFF_API_KEY }}
    project: my-site
    extra-headers: |
      x-vercel-protection-bypass: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
      x-vercel-set-bypass-cookie: true
    pages: |
      homepage=/

Generate the secret under Vercel → Settings → Deployment Protection → Protection Bypass for Automation. Header values are sent on every capture and not persisted in SnapDiff's database. Cloudflare Access (CF-Access-Client-Id/CF-Access-Client-Secret) and basic auth use the same extra-headers shape.

Netlify, Cloudflare Pages, and everything else

Auto-discovery isn't Vercel-specific — it works with any host that writes GitHub deployment statuses, which includes Netlify and Cloudflare Pages. If the environment name differs, point preview-environment: at the right substring. For hosts that don't write deployments at all (self-hosted staging, custom pipelines), pass preview-url: explicitly, or use absolute URLs in pages: — discovery is skipped for any entry that already starts with http(s)://.

Keeping diffs quiet on dynamic pages

The classic failure mode of screenshot testing is noise: cookie banners, chat widgets, ads, dates, and animations producing diffs nobody asked for. SnapDiff's stabilization layer handles the first three automatically — it hides 40+ cookie consent banners and 20+ chat widgets and blocks 30+ ad/tracker domains before capturing. For content that's legitimately dynamic, mask it by CSS selector:

{
  "ignore_selectors": [".live-user-count", "[data-testid='last-updated']"]
}

Masked regions are excluded from the comparison entirely, so a ticking timestamp can't fail a build.

Beyond PRs: watching production

PR checks catch changes you made. They don't catch the CMS edit someone published on Friday, or the third-party embed that restyled itself. For that, the watch API (POST /v1/watch) checks a URL on a cron schedule and POSTs to your webhook when it visually changes beyond your threshold — set-and-forget monitoring for pages that change without a deploy.