The pattern that costs teams the most SEO traffic is the one nobody can post-mortem: a regression that shipped a month ago, that nobody noticed because traffic only dipped 8%, that nobody can attribute to a specific deploy. By the time someone runs an audit, twenty commits have landed and the cause is buried.
The fix is to audit every PR before it merges and fail the gate when the score drops materially. This post walks through that gate end-to-end — what to compare, what threshold to use, and the actual GitHub Actions / GitLab CI / generic Python implementation.
What a pre-deploy SEO gate actually checks
There are three distinct comparisons worth running on a PR, in order of how strict they should be:
- PR preview URL vs production. Most teams have preview deploys (Vercel, Netlify, Cloudflare Pages). Audit the preview, audit prod, fail if the gap is wider than threshold. This is the strictest, most useful check.
- Preview vs last preview. If you don't ship to production from main, or you want a check on a feature branch before it touches prod, audit this branch's preview vs the previous branch's preview.
- PR preview vs all-time best. Useful as a soft warning, not a hard gate — "this PR isn't bad, but the homepage was at 94 in February and you're at 89."
We'll focus on #1, which is what 90% of teams want.
The minimum viable gate
# .ci/seo-gate.py
import os, sys, requests
API_KEY = os.environ["SEOSCORE_API_KEY"]
PREVIEW_URL = os.environ["PREVIEW_URL"] # e.g., https://pr-142-foo.vercel.app
PROD_URL = os.environ["PROD_URL"] # e.g., https://yourdomain.com
BASE = "https://api.seoscoreapi.com"
THRESHOLD = -3.0
r = requests.post(
f"{BASE}/compare",
headers={"X-API-Key": API_KEY},
json={"urls": [PREVIEW_URL, PROD_URL]},
timeout=120,
)
r.raise_for_status()
data = r.json()
# /compare returns sites[] in submission order
preview_score = data["sites"][0]["score"]
prod_score = data["sites"][1]["score"]
delta = preview_score - prod_score
print(f"Preview: {preview_score} Prod: {prod_score} Delta: {delta:+.1f}")
if delta <= THRESHOLD:
print(f"::error::SEO regression of {delta:+.1f} points (threshold {THRESHOLD})")
sys.exit(1)
That's the entire gate. Twenty lines. One API call (the /compare endpoint audits both sides in parallel on our side). 30 seconds wall time.
Pick a useful threshold
-3.0 is the threshold we've landed on for most teams after watching this run on real PRs for a year. It's:
- Tight enough to catch real regressions. A 3-point drop is roughly "you broke a category" — typically performance from an unoptimized asset, or accessibility from a new component shipped without
alttext. - Loose enough to avoid false positives. SEO Score has some run-to-run noise of ~1 point (LCP timing, network variance).
-3.0is comfortably outside that band.
For high-stakes pages (homepage, top landing page), tighten to -2.0. For experimental routes, loosen to -5.0. For categories you care about specifically, gate per-category:
prod_cats = data["sites"][1]["categories"]
prev_cats = data["sites"][0]["categories"]
for cat, threshold in {"performance": -4, "accessibility": -2, "seo": -3}.items():
d = prev_cats[cat] - prod_cats[cat]
if d <= threshold:
print(f"::error::{cat} regressed {d:+.1f} (threshold {threshold})")
sys.exit(1)
Per-category gates are what turn this from "the gate fails sometimes" into "the gate tells me exactly what's wrong before I open the PR."
GitHub Actions wiring
# .github/workflows/seo-gate.yml
name: SEO regression gate
on:
deployment_status:
jobs:
gate:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.11"}
- run: pip install requests
- name: Run SEO gate
env:
SEOSCORE_API_KEY: ${{ secrets.SEOSCORE_API_KEY }}
PREVIEW_URL: ${{ github.event.deployment_status.target_url }}
PROD_URL: https://yourdomain.com
run: python .ci/seo-gate.py
The deployment_status trigger waits for Vercel/Netlify/etc. to finish building the preview, then runs the gate against the preview URL it provides. That timing matters — running on pull_request instead would audit a preview that hasn't deployed yet and either error or audit stale content.
GitLab CI and CircleCI versions look essentially identical; see the generic CI/CD post for those.
What about multi-page gates?
A single-URL gate misses regressions on routes that didn't change in this PR but were affected by a global change — a layout shift, a font swap, a navigation rewrite. The fix is to audit a list of "critical pages" in batch:
CRITICAL_PAGES = [
"/", "/pricing", "/blog", "/docs",
"/blog/your-top-post", "/your-top-product",
]
prev_urls = [f"{PREVIEW_URL}{p}" for p in CRITICAL_PAGES]
prod_urls = [f"{PROD_URL}{p}" for p in CRITICAL_PAGES]
prev = requests.post(f"{BASE}/audit/batch", headers=h,
json={"urls": prev_urls}).json()["results"]
prod = requests.post(f"{BASE}/audit/batch", headers=h,
json={"urls": prod_urls}).json()["results"]
failures = []
for prev_r, prod_r in zip(prev, prod):
delta = prev_r["score"] - prod_r["score"]
if delta <= THRESHOLD:
failures.append((prev_r["url"], delta))
if failures:
for url, d in failures:
print(f"::error::{url} regressed {d:+.1f}")
sys.exit(1)
Batch audit is paid-only — this is where the gate stops being a Free-tier experiment and starts being part of your shipping process. For a team shipping 5–10 PRs a day and auditing 8 critical pages each, that's ~2,000 audits a month, which is exactly the Pro tier ($39/mo) target.
Posting results back to the PR
The failure message in CI is fine. The team will see it. But a comment on the PR with the actual scores is much more useful — it turns the gate from "the build is red" into "the homepage went from 92 to 87 because LCP regressed by 1.2s."
gh pr comment $PR_NUMBER --body "## SEO gate
| URL | Previous | This PR | Delta |
|-----|----------|---------|-------|
| / | 92 | 87 | -5.0 ❌ |
| /pricing | 88 | 88 | 0.0 ✅ |
| /docs | 84 | 84 | 0.0 ✅ |
"
Wire that up with the GitHub CLI inside the action and you've got the full pattern. The github-actions-seo-audit post has a more elaborate version with collapsed details and per-category breakdowns.
Using /history instead of /compare
/compare is the right call for a PR gate — both sides are audited fresh, no historical data needed, works on any plan with batch access. But for a post-merge check ("did main just regress from where it was yesterday?"), the history endpoint is cheaper:
hist = requests.get(
f"{BASE}/history?url={PROD_URL}",
headers={"X-API-Key": API_KEY},
).json()
today = hist["history"][-1]["score"]
yesterday = hist["history"][-2]["score"]
if today - yesterday <= -3.0:
notify_slack(f"main regressed from {yesterday} to {today}")
Run that as a scheduled job after deploys merge. It uses one audit (today's) instead of two and gives you the same answer as long as you're already auditing the URL regularly.
What this catches that humans miss
A non-exhaustive list from real PRs we've seen the gate flag:
- A "loading skeleton" component that shipped without an
aria-label, knocking accessibility by 4 points sitewide. - A font swap from a local woff2 to a Google Fonts CDN, regressing LCP by 800ms and tanking performance.
- A new "trust banner" with three external images, all 1.2MB JPEGs that should have been ~80KB WebP.
- A Markdown→HTML refactor that dropped
<h2>semantics inside blog posts. - A new "share to social" widget that injected six tracking scripts before paint.
Every one of these would have shipped silently and been discovered weeks later in a quarterly audit if anyone ran one. With a PR gate, they fail the build and get fixed before merge.
Cost of the gate vs cost of the regression
Two pricing observations:
- The gate costs ~10 audits per PR (one batch of critical pages × pre/post). For a team shipping 100 PRs a month, that's 1,000 audits. Basic ($15/mo) covers it.
- The regression it catches costs you weeks of organic traffic to a top page. One catch pays for the next several years of Basic.
We've yet to talk to a team that turned a pre-deploy SEO gate on and then turned it off again. It's the single highest-leverage piece of API tooling for a team that ships frequently.
Getting started
Copy the script, drop it in .ci/, wire it to your deployment platform's preview hook, set the threshold to -3.0, and ship a no-op PR to verify it runs. The first time it fails on a real regression — and it will, within a month — you'll know it was worth it.
For per-category gates and history-based deltas, grab a Basic key. For multi-page batch gates on a team shipping daily, the math points at Pro.