← Blog

SEO Quality Gates with GitHub Actions: A Step-by-Step Implementation Guide

Build a GitHub Actions workflow that audits your site's SEO on every pull request, posts results as PR comments, and blocks merges when scores drop below your threshold.

A broken meta tag in one pull request can silently tank your search rankings for weeks. By the time you spot the traffic drop in Google Search Console, the damage is done. The fix is simple: treat SEO like a test suite and run it automatically on every PR.

This guide walks through a real, production-ready GitHub Actions workflow using the SEO Audit Action. By the end, you'll have a workflow that audits your deployed site, posts a score report as a PR comment, and fails the build if the score drops below your threshold.

What You'll Build

Here's what the workflow does on every pull request:

  1. Waits for your preview deployment (Vercel, Netlify, Cloudflare Pages, etc.)
  2. Runs a 28-check SEO audit against the preview URL
  3. Posts the score, grade, and top issues as a PR comment
  4. Blocks the merge if the score is below 80

The full audit covers meta tags, technical SEO, Open Graph, performance, and accessibility — the same checks you'd get from running curl https://seoscoreapi.com/audit?url=yoursite.com.

Prerequisites

You need two things:

  1. A free SEO Score API keysign up here (takes 10 seconds, no credit card)
  2. A preview deployment in your pipeline — any system that gives you a URL for the PR branch (Vercel, Netlify, Render, etc.)

Add your API key as a repository secret:

Settings > Secrets and variables > Actions > New repository secret

The Basic Workflow

Start with the simplest version. Create .github/workflows/seo-audit.yml:

name: SEO Audit

on:
  pull_request:
    branches: [main]

jobs:
  seo:
    runs-on: ubuntu-latest
    steps:
      - name: SEO Audit
        id: seo
        uses: SeoScoreAPI/seo-audit-action@v1
        with:
          url: "https://your-site.com"
          api-key: ${{ secrets.SEO_SCORE_API_KEY }}
          threshold: 80

That's a working quality gate in 15 lines. If the SEO score is below 80, the workflow fails and the PR can't merge (assuming you have branch protection rules enabled).

The action produces a markdown summary in the GitHub Actions UI automatically — you'll see the score, grade, issue count, and top 5 issues right in the workflow run.

Adding PR Comments

The summary in the Actions tab is useful, but most reviewers look at the PR conversation, not the workflow logs. Let's post the results as a PR comment:

name: SEO Audit

on:
  pull_request:
    branches: [main]

jobs:
  seo:
    runs-on: ubuntu-latest
    steps:
      - name: SEO Audit
        id: seo
        uses: SeoScoreAPI/seo-audit-action@v1
        with:
          url: "https://your-site.com"
          api-key: ${{ secrets.SEO_SCORE_API_KEY }}
          threshold: 80

      - name: Comment on PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const score = '${{ steps.seo.outputs.score }}';
            const grade = '${{ steps.seo.outputs.grade }}';
            const issues = '${{ steps.seo.outputs.issues }}';
            const reportUrl = '${{ steps.seo.outputs.report-url }}';
            const passed = parseInt(score) >= 80;

            const body = [
              `## SEO Audit Results`,
              ``,
              `| Metric | Value |`,
              `|--------|-------|`,
              `| Score | **${score}/100** |`,
              `| Grade | **${grade}** |`,
              `| Issues | ${issues} |`,
              `| Threshold | 80 |`,
              `| Status | ${passed ? 'Passed' : 'Failed'} |`,
              ``,
              `[View Full Report](${reportUrl})`,
            ].join('\n');

            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body
            });

The if: always() is important — it ensures the comment is posted even when the audit step fails due to a low score. Without it, a failing score would skip the comment step and you'd have to dig into the logs to find out what went wrong.

Real-World Example: Vercel Preview Deployments

Most teams don't audit a static production URL — they audit the preview deployment that Vercel (or Netlify, etc.) creates for each PR. Here's how to wire that up:

name: SEO Audit on Preview

on:
  pull_request:
    branches: [main]
    paths:
      - '**.html'
      - '**.jsx'
      - '**.tsx'
      - '**.vue'
      - '**.svelte'
      - 'content/**'
      - 'public/**'

jobs:
  seo:
    runs-on: ubuntu-latest
    steps:
      - name: Wait for Vercel Preview
        uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
        id: preview
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          max_timeout: 300

      - name: SEO Audit
        id: seo
        uses: SeoScoreAPI/seo-audit-action@v1
        with:
          url: ${{ steps.preview.outputs.url }}
          api-key: ${{ secrets.SEO_SCORE_API_KEY }}
          threshold: 80

      - name: Comment on PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const score = '${{ steps.seo.outputs.score }}';
            const grade = '${{ steps.seo.outputs.grade }}';
            const issues = '${{ steps.seo.outputs.issues }}';
            const reportUrl = '${{ steps.seo.outputs.report-url }}';
            const previewUrl = '${{ steps.preview.outputs.url }}';
            const passed = parseInt(score) >= 80;

            const emoji = passed ? '✅' : '❌';

            const body = [
              `## ${emoji} SEO Audit`,
              ``,
              `Audited: ${previewUrl}`,
              ``,
              `| Metric | Value |`,
              `|--------|-------|`,
              `| Score | **${score}/100** |`,
              `| Grade | **${grade}** |`,
              `| Issues | ${issues} |`,
              `| Threshold | 80 |`,
              ``,
              `[Full Report](${reportUrl})`,
            ].join('\n');

            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body
            });

Three things worth noting about this version:

Path filters — the paths block ensures the audit only runs when files that affect SEO are changed. Backend-only PRs (API routes, database migrations, config changes) skip the audit entirely. This is important because audits cost API calls, and you don't want to burn them on changes that can't affect your SEO score.

Preview URL — instead of hardcoding a URL, we pull it from the Vercel preview deployment. Every PR gets its own preview URL, so you're auditing the actual changes in that PR, not the current production site.

Timeoutmax_timeout: 300 gives Vercel 5 minutes to deploy. Adjust this based on your build times.

Netlify Variant

If you're on Netlify instead of Vercel, swap the preview step:

      - name: Wait for Netlify Preview
        uses: jakepartusch/wait-for-netlify-action@v1.4
        id: preview
        with:
          site_name: "your-netlify-site"
          max_timeout: 300

The rest of the workflow stays identical — just reference ${{ steps.preview.outputs.url }}.

Multi-Page Audits

For sites where SEO matters on more than one page, use a matrix strategy to audit multiple routes:

jobs:
  seo:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        path: ['/', '/pricing', '/blog', '/docs']
    steps:
      - name: SEO Audit - ${{ matrix.path }}
        id: seo
        uses: SeoScoreAPI/seo-audit-action@v1
        with:
          url: "https://your-site.com${{ matrix.path }}"
          api-key: ${{ secrets.SEO_SCORE_API_KEY }}
          threshold: 80

This runs 4 parallel audits — one per page. If any page drops below 80, that specific job fails. You can see exactly which page regressed in the Actions UI.

For larger sites (10+ pages), use the batch audit endpoint with a script step instead of the matrix approach to use fewer API calls.

Setting Up Branch Protection

The workflow only blocks merges if you configure branch protection:

  1. Go to Settings > Branches > Branch protection rules
  2. Add a rule for main
  3. Check Require status checks to pass before merging
  4. Search for and add the SEO Audit check

Now any PR where the SEO score drops below your threshold literally cannot be merged until the issues are fixed.

Choosing Your Threshold

Threshold Best For
90 Marketing sites, landing pages where SEO directly drives revenue
80 Most production sites — catches real issues without false positives
70 Apps with some public-facing pages where SEO is secondary
60 Internal tools with minimal SEO requirements

Start at 70 if your site has existing issues, then ratchet up to 80 once the team has fixed the backlog. Don't start at 90 — you'll frustrate developers with failures on day one and they'll disable the check.

API Key and Plan Considerations

The free tier gives you 5 audits/day, which works for solo projects with a couple PRs per week. For teams:

Plan Audits/mo Price Good For
Starter 200 $5/mo Small team, 1-2 PRs/day
Basic 1,000 $15/mo Active team, multi-page audits
Pro 5,000 $39/mo Multiple repos, matrix audits

Sign up free and upgrade when your PR volume outgrows the free tier.

The Complete Workflow

Here's the full production-ready workflow with all the pieces — preview deployment, PR comments, path filtering, and the quality gate:

name: SEO Quality Gate

on:
  pull_request:
    branches: [main]
    paths:
      - '**.html'
      - '**.jsx'
      - '**.tsx'
      - '**.vue'
      - '**.svelte'
      - 'content/**'
      - 'public/**'

jobs:
  seo-audit:
    runs-on: ubuntu-latest
    steps:
      - name: Wait for Preview Deploy
        uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
        id: preview
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          max_timeout: 300

      - name: Run SEO Audit
        id: seo
        uses: SeoScoreAPI/seo-audit-action@v1
        with:
          url: ${{ steps.preview.outputs.url }}
          api-key: ${{ secrets.SEO_SCORE_API_KEY }}
          threshold: 80

      - name: Post Results to PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const score = '${{ steps.seo.outputs.score }}';
            const grade = '${{ steps.seo.outputs.grade }}';
            const issues = '${{ steps.seo.outputs.issues }}';
            const reportUrl = '${{ steps.seo.outputs.report-url }}';
            const previewUrl = '${{ steps.preview.outputs.url }}';
            const passed = parseInt(score) >= 80;

            const emoji = passed ? '✅' : '❌';
            const status = passed ? 'Passed' : 'Failed — fix issues before merging';

            const body = [
              `## ${emoji} SEO Audit`,
              ``,
              `**${status}**`,
              ``,
              `| Metric | Value |`,
              `|--------|-------|`,
              `| Preview | ${previewUrl} |`,
              `| Score | **${score}/100** |`,
              `| Grade | **${grade}** |`,
              `| Issues | ${issues} |`,
              `| Threshold | 80 |`,
              ``,
              `[View Full Report →](${reportUrl})`,
            ].join('\n');

            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body
            });

Copy that into .github/workflows/seo-audit.yml, add your SEO_SCORE_API_KEY secret, enable branch protection, and you have an automated SEO quality gate protecting your site from regressions on every pull request.

Try SEO Score API free

Get Free API Key