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.
Here's what the workflow does on every pull request:
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.
You need two things:
Add your API key as a repository secret:
Settings > Secrets and variables > Actions > New repository secret
SEO_SCORE_API_KEYStart 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.
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.
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.
Timeout — max_timeout: 300 gives Vercel 5 minutes to deploy. Adjust this based on your build times.
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 }}.
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.
The workflow only blocks merges if you configure branch protection:
mainNow any PR where the SEO score drops below your threshold literally cannot be merged until the issues are fixed.
| 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.
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.
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