Visual Regression Testing on Vercel Preview URLs with GitHub Actions
Every PR on a Vercel-connected repo already produces a deployed, publicly reachable copy of your app. That preview URL is a great target for visual regression testing: it's the real page, rendered for real, before anyone has reviewed a line. The setup below diffs it against your baselines on every PR — one workflow file, one secret.
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
- Vercel deploys the preview, as it already does.
- The action discovers the preview URL and creates a SnapDiff build that captures each page in your list.
- 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. - A
snapdiff/visual-testcommit 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. - 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.
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.
Frequently asked questions
Can SnapDiff block a pull request from merging?
Yes. The action posts a snapdiff/visual-test commit status that succeeds when every page matches its baseline and stays pending while changes await review. Add it to your branch protection rules ("Require status checks to pass") and unreviewed visual changes can't merge.
Does it find the Vercel preview URL automatically?
Yes. The action reads GitHub's deployments API, finds the preview deployment for the PR's commit, and uses its environment_url as the base for your pages: list. The only secret you need is SNAPDIFF_API_KEY — no Vercel tokens or IDs.
Does this work with Netlify, Cloudflare Pages, or self-hosted previews?
Hosts that write GitHub deployment statuses (Netlify, Cloudflare Pages) work with the same auto-discovery — use preview-environment: if the environment name differs. For anything else, pass preview-url: explicitly or put absolute URLs in pages:.
Why does every page report "changed" on the first run?
The first run on a new branch captures baselines — there's nothing to compare against yet. Real diffs start on the second push, and approving a build updates the baselines for subsequent runs.
What about previews behind Vercel Deployment Protection?
Pass the automation bypass secret via extra-headers: (x-vercel-protection-bypass plus x-vercel-set-bypass-cookie: true). Headers are sent on every capture and not persisted. Cloudflare Access and basic auth use the same mechanism.
How do I stop dynamic content from causing false positives?
Cookie banners, chat widgets, and ads are auto-stabilized before capture. For legitimately dynamic content — dates, counters, A/B slots — exclude elements from the comparison with ignore_selectors.
How is this different from other visual testing services?
Setup shape and pricing. SnapDiff diffs deployed preview URLs from a list of paths — no SDK or snapshot code inside your app. And plans are flat-rate from $19/mo rather than per-screenshot, so busy repos don't multiply the bill. See pricing.
One workflow file away
Free plan includes 200 diffs a month — enough to wire up a repo and see it catch something.
No credit card required.