The deploy went green. Every test passed. Two days later, someone in marketing posted a Search Console screenshot in Slack: pages were dropping out of the index. A template change had shipped a noindex tag that was only ever meant for staging.
Nothing in the pipeline caught it, because nothing in the pipeline was looking. Unit tests check that your code behaves. They don't check the HTML that Googlebot actually receives.
This post is a practical plan for running SEO checks in a CI pipeline. It covers which checks to automate, where each one should run, and which ones to leave to humans. Every script below was tested before publishing, and you can drop them into a GitHub Actions setup today. If you've wanted to start automated SEO testing but weren't sure where the line was, this is it.
Why SEO Breaks Quietly in Deploys
SEO regressions are sneaky because nothing errors out. The page loads, the design looks right, and users can click around normally. Meanwhile:
- A staging
noindexmeta tag orX-Robots-Tagheader goes to production. -
robots.txtships withDisallow: /copied from a preview environment. - Canonical tags point at the staging host.
- A layout refactor wipes page-specific titles and meta descriptions.
- Routes get renamed with no redirects, so old URLs return 404.
- A JSON-LD block breaks because someone added a trailing comma.
- The sitemap still lists URLs that no longer exist.
Each of these takes seconds to detect with the right check and weeks to notice from traffic graphs. That gap is exactly what CI is good at closing.
The Rule for What to Automate
Not every SEO check belongs in a pipeline. Use one simple rule:
- Automate checks that are deterministic, fast and binary. A tag is present or it isn't. A URL returns 200 or it doesn't. These make good gates.
- Monitor checks that are noisy or depend on real users. Lab performance scores vary between runs, and Core Web Vitals field data comes from real visitors over weeks. Track these, but don't block merges on them.
- Keep judgment human. Content quality, search intent, keyword targeting and title copywriting need a person.
The Four Layers at a Glance
- Pull request gate: lint the built HTML for missing or broken tags. Runs in seconds.
- Lighthouse sample: audit a handful of key templates for SEO and performance budgets.
- Post-deploy check: test the live server for robots rules, headers, redirects and sitemap health.
- Scheduled monitoring: catch slow drift such as broken external links and field performance.
To decide which individual rules to codify, start from a reference list rather than a blank page. SEO Site Checker's 70 SEO checks explained groups on-page, performance, security and advanced checks with plain-language explanations, which makes it easy to pick the deterministic ones for your linter.
Layer 1: Gate Pull Requests on the Built HTML
The most important habit is to test the build output, not your source templates. Your framework, your CMS data and your layout components only come together in the final HTML, and that HTML is what search engines see. Point the checks at dist/, out/ or whatever folder your static export produces.
Checks That Belong Here
- Exactly one non-empty
<title>, unique across the site, with a warning above 60 characters - A meta description, with a warning above 160 characters
- A canonical tag that is absolute and points to the production host
- No
noindexin meta robots, except on pages you deliberately allow - One
<h1>per page (warning only, since some layouts have good reasons) - A
langattribute on<html> - An
altattribute on every image (an emptyalt=""is fine for decorative images) - Basic Open Graph tags
- JSON-LD that parses and includes
@contextand@type, including inside@graph
A Small Checker Script
This script uses cheerio to parse every HTML file in the build folder. It separates errors, which fail the build, from warnings, which are only printed. Install it with npm install --save-dev cheerio.
// scripts/seo-check.mjs
// Usage: node scripts/seo-check.mjs <build-dir> <production-origin>
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import * as cheerio from 'cheerio';
const [dir = 'dist', origin = 'https://example.com'] = process.argv.slice(2);
const NOINDEX_ALLOWED = new Set(['404.html', 'search/index.html']);
const errors = [];
const warnings = [];
const titles = new Map();
async function findHtml(folder) {
const files = [];
for (const entry of await readdir(folder, { withFileTypes: true })) {
const full = path.join(folder, entry.name);
if (entry.isDirectory()) files.push(...(await findHtml(full)));
else if (entry.name.endsWith('.html')) files.push(full);
}
return files;
}
function checkPage(page, html) {
const $ = cheerio.load(html);
const error = (msg) => errors.push(`${page}: ${msg}`);
const warn = (msg) => warnings.push(`${page}: ${msg}`);
// Indexing
const robots = ($('meta[name="robots"]').attr('content') ?? '').toLowerCase();
if (robots.includes('noindex') && !NOINDEX_ALLOWED.has(page)) error('meta robots contains noindex');
// Title
const titleTags = $('head > title');
const title = titleTags.first().text().trim();
if (titleTags.length !== 1 || !title) {
error(`expected one non-empty <title>, found ${titleTags.length}`);
} else {
if (title.length > 60) warn(`title is ${title.length} characters`);
titles.set(title, [...(titles.get(title) ?? []), page]);
}
// Meta description
const description = $('meta[name="description"]').attr('content')?.trim();
if (!description) error('missing meta description');
else if (description.length > 160) warn(`meta description is ${description.length} characters`);
// Canonical
const canonical = $('link[rel="canonical"]').attr('href');
if (!canonical) {
error('missing canonical tag');
} else {
try {
const url = new URL(canonical);
if (url.origin !== origin) error(`canonical points to ${url.origin}`);
} catch {
error(`canonical is not an absolute URL: ${canonical}`);
}
}
// Headings, language, images, social
const h1Count = $('h1').length;
if (h1Count !== 1) warn(`found ${h1Count} <h1> elements`);
if (!$('html').attr('lang')) warn('missing lang attribute on <html>');
$('img:not([alt])').each((_, img) => warn(`image without alt: ${$(img).attr('src')}`));
if (!$('meta[property="og:title"]').length) warn('missing og:title');
// Structured data
$('script[type="application/ld+json"]').each((i, el) => {
let data;
try {
data = JSON.parse($(el).text());
} catch {
return error(`JSON-LD block ${i + 1} is not valid JSON`);
}
for (const block of Array.isArray(data) ? data : [data]) {
if (!block['@context']) error(`JSON-LD block ${i + 1} has no @context`);
const items = block['@graph'] ?? [block];
if (items.some((item) => !item['@type'])) error(`JSON-LD block ${i + 1} has an item without @type`);
}
});
}
for (const file of await findHtml(dir)) {
checkPage(path.relative(dir, file).split(path.sep).join('/'), await readFile(file, 'utf8'));
}
for (const [title, pages] of titles) {
if (pages.length > 1) errors.push(`duplicate title "${title}" on: ${pages.join(', ')}`);
}
warnings.forEach((w) => console.warn(`warning ${w}`));
errors.forEach((e) => console.error(`error ${e}`));
console.log(`\n${errors.length} errors, ${warnings.length} warnings`);
process.exit(errors.length ? 1 : 0);
Run against a sample build with a few planted mistakes, the output looks like this:
warning blog/post/index.html: found 2 <h1> elements
warning blog/post/index.html: missing lang attribute on <html>
warning blog/post/index.html: image without alt: b.png
warning blog/post/index.html: missing og:title
error about/index.html: canonical is not an absolute URL: /about/
error blog/post/index.html: meta robots contains noindex
error blog/post/index.html: missing meta description
error blog/post/index.html: canonical points to https://staging.example.com
error blog/post/index.html: JSON-LD block 1 is not valid JSON
error duplicate title "Home | Example" on: blog/post/index.html, index.html
6 errors, 4 warnings
Every message names the page and the rule, so whoever opened the PR knows exactly what to fix.
Wire It Into GitHub Actions
# .github/workflows/seo-pr.yml
name: SEO checks
on:
pull_request:
jobs:
seo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: Lint built HTML
run: node scripts/seo-check.mjs dist https://example.com
- name: Check internal links
uses: lycheeverse/lychee-action@v2
with:
args: --offline --no-progress --root-dir "${{ github.workspace }}/dist" "dist/**/*.html"
The lychee link checker runs in offline mode here, so it only checks links between your own files. That keeps it fast and immune to third-party sites being slow. External links get checked later, on a schedule.
Keep this whole job under about a minute. If an SEO gate becomes the slowest step in CI, people start looking for ways around it.
Layer 2: Lighthouse CI on Key Templates, Not Every Page
Lighthouse is useful, but it's slow, and its performance scores change from run to run. Running it on every page of every PR is a recipe for flaky builds. Instead, audit three to five representative templates: the homepage, an article, a product or service page and a listing page.
Lighthouse CI can serve your static build itself and assert on the results:
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"url": [
"http://localhost/index.html",
"http://localhost/blog/sample-post/index.html",
"http://localhost/pricing/index.html"
],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:seo": ["error", { "minScore": 0.9 }],
"categories:performance": ["warn", { "minScore": 0.8 }],
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["warn", { "maxNumericValue": 0.1 }]
}
},
"upload": {
"target": "filesystem",
"outputDir": "./lhci-reports"
}
}
}
Save that as lighthouserc.json and add one step to the workflow: npx @lhci/cli autorun.
Notice the split. The SEO category is an error, because its audits are mostly deterministic (crawlable links, valid hreflang, a document title and so on). Performance is a warning, because CI runners vary in speed. Three runs per URL lets Lighthouse CI use the median result, which smooths out the noise.
Layer 3: Check Production Right After Deploy
Some of the most damaging problems only exist on the live server. Response headers, robots.txt, redirects and host settings often come from CDN rules, server config or a hosting dashboard rather than your repository. A PR check can't see them, so test production immediately after every deploy.
robots.txt and X-Robots-Tag
Fail the run if robots.txt blocks everything for User-agent: *, or if key pages send noindex through an X-Robots-Tag header or a meta tag. The header is easy to miss because it never appears in the HTML. Google's robots meta tag and X-Robots-Tag docs explain how both are interpreted.
Be precise about Disallow: /. Plenty of sites block specific bots, such as AI crawlers, on purpose, so the check should only fail when the rule applies to every crawler.
A Redirect Map Test
Keep a redirects.csv file in the repository with two columns, from and to. After each deploy, assert that every old URL returns a single 301 or 308 straight to the expected target, and that the target returns 200. This catches missing redirects, temporary 302s, wrong destinations and chains.
Sitemap Sanity
Request every URL in the sitemap and expect a 200. Then compare the URL count against a stored baseline, and fail if it drops sharply. A sitemap that suddenly loses a large share of its URLs usually means a routing change or a broken data source, not a deliberate cleanup.
The Post-Deploy Script
// scripts/post-deploy-check.mjs
// Usage: node scripts/post-deploy-check.mjs https://example.com
import { readFile, writeFile } from 'node:fs/promises';
const origin = (process.argv[2] ?? 'https://example.com').replace(/\/$/, '');
const KEY_PATHS = ['/', '/blog/', '/pricing/'];
const MAX_SITEMAP_DROP = 0.1; // fail if the sitemap shrinks by more than 10%
const failures = [];
const fail = (msg) => failures.push(msg);
// 1. robots.txt must not block everything for all crawlers
function blocksAllCrawlers(txt) {
let agents = [];
let inRules = false;
for (const raw of txt.split(/\r?\n/)) {
const line = raw.replace(/#.*/, '').trim();
const sep = line.indexOf(':');
if (sep === -1) continue;
const key = line.slice(0, sep).trim().toLowerCase();
const value = line.slice(sep + 1).trim();
if (key === 'user-agent') {
if (inRules) { agents = []; inRules = false; }
agents.push(value);
} else if (key === 'allow' || key === 'disallow') {
inRules = true;
if (key === 'disallow' && value === '/' && agents.includes('*')) return true;
}
}
return false;
}
const robotsRes = await fetch(`${origin}/robots.txt`);
if (!robotsRes.ok) fail(`robots.txt returned ${robotsRes.status}`);
else if (blocksAllCrawlers(await robotsRes.text())) fail('robots.txt has "Disallow: /" for User-agent: *');
// 2. Key pages: 200, no noindex header, no noindex meta tag
for (const p of KEY_PATHS) {
const res = await fetch(origin + p);
if (res.status !== 200) { fail(`${p} returned ${res.status}`); continue; }
if (/noindex/i.test(res.headers.get('x-robots-tag') ?? '')) fail(`${p} sends X-Robots-Tag: noindex`);
const html = await res.text();
if (/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(html)) fail(`${p} has a noindex meta tag`);
}
// 3. Redirect map: one hop, permanent, correct target
const csv = await readFile('redirects.csv', 'utf8').catch(() => '');
for (const line of csv.split(/\r?\n/).slice(1).filter(Boolean)) {
const [from, to] = line.split(',').map((s) => s.trim());
const res = await fetch(origin + from, { redirect: 'manual' });
const location = res.headers.get('location');
if (![301, 308].includes(res.status)) { fail(`${from} returned ${res.status}, expected 301 or 308`); continue; }
const target = new URL(location, origin);
if (target.href !== new URL(to, origin).href) { fail(`${from} redirects to ${target.pathname}, expected ${to}`); continue; }
const final = await fetch(target, { redirect: 'manual' });
if (final.status !== 200) fail(`${from} -> ${to} ends in ${final.status} (chain or broken target)`);
}
// 4. Sitemap: every URL returns 200 and the count has not dropped sharply
const sitemapRes = await fetch(`${origin}/sitemap.xml`);
if (!sitemapRes.ok) {
fail(`sitemap.xml returned ${sitemapRes.status}`);
} else {
const urls = [...(await sitemapRes.text()).matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]);
const baseline = JSON.parse(await readFile('seo-baseline.json', 'utf8').catch(() => '{}'));
if (baseline.sitemapCount && urls.length < baseline.sitemapCount * (1 - MAX_SITEMAP_DROP)) {
fail(`sitemap has ${urls.length} URLs, down from ${baseline.sitemapCount}`);
}
for (let i = 0; i < urls.length; i += 10) {
await Promise.all(urls.slice(i, i + 10).map(async (url) => {
const res = await fetch(url, { redirect: 'manual' });
if (res.status !== 200) fail(`sitemap URL ${url} returned ${res.status}`);
}));
}
if (!failures.length) await writeFile('seo-baseline.json', JSON.stringify({ sitemapCount: urls.length }, null, 2));
}
failures.forEach((f) => console.error(`FAIL ${f}`));
console.log(failures.length ? `\n${failures.length} problems found` : 'All post-deploy SEO checks passed');
process.exit(failures.length ? 1 : 0);
Tested against a deliberately broken server, it reports:
FAIL robots.txt has "Disallow: /" for User-agent: *
FAIL /pricing/ sends X-Robots-Tag: noindex
FAIL /old-pricing redirects to /plans/, expected /pricing/
FAIL /old-blog returned 302, expected 301 or 308
FAIL sitemap URL http://localhost:8765/gone/ returned 404
5 problems found
A few notes on the script:
- The baseline file only updates after a clean run, so one bad deploy can't quietly lower the bar. On GitHub Actions, persist
seo-baseline.jsonwithactions/cacheor commit it, because each runner starts fresh. - It reads a single
sitemap.xml. If you use a sitemap index, fetch each child sitemap first. - Sitemap URLs are checked ten at a time to avoid hammering your own server.
Trigger it after a successful production deployment:
# .github/workflows/seo-post-deploy.yml
name: Post-deploy SEO check
on:
deployment_status:
jobs:
smoke:
if: github.event.deployment_status.state == 'success' && github.event.deployment.environment == 'production'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node scripts/post-deploy-check.mjs https://example.com
Check what your host calls the production environment. Some platforms report it as Production with a capital P. If you deploy from your own workflow instead, run the script as a final job that depends on the deploy job.
Layer 4: Scheduled Monitoring for What CI Can't Judge
Some problems develop slowly or depend on things outside your code. Handle them with a scheduled workflow that alerts people instead of failing builds:
on:
schedule:
- cron: "17 6 * * 1" # Mondays at 06:17 UTC
workflow_dispatch:
Good candidates for a weekly run:
- External link checks with lychee in online mode, since other sites change without telling you
- Core Web Vitals field data from the Chrome UX Report (CrUX) API, which reflects real users rather than a lab run
- Indexing coverage through the Search Console API, to spot a jump in excluded pages
- The post-deploy script again, to catch config changes made outside a deploy
Post the results to Slack or open a GitHub issue. Nobody should be blocked from merging because a partner's website went down.
Automation also doesn't replace a periodic human look at the whole site. Every month or so, run a full SEO audit on your key pages and compare it with the last one.
What Not to Automate
It's tempting to turn everything into a gate. Resist that for:
- Content quality and helpfulness. No linter can tell whether a page actually answers the searcher's question.
- Search intent and keyword choices. These are strategy decisions, not syntax errors.
- Title copywriting. You can check length automatically, but not whether the title is good.
- Internal linking strategy. You can check for broken links, but deciding what to link is editorial work.
- Lighthouse scores on every page as hard gates. They flake, people stop trusting the pipeline, and then they ignore real failures too.
Automation keeps the floor up. Humans still have to raise the ceiling. For the judgment side, a manual technical SEO audit checklist covers what a script can't.
Rolling It Out Without Annoying Your Team
- Start with warnings only. Run every check in warning mode for two weeks, fix the existing backlog, then promote the stable checks to errors.
-
Keep an allowlist. Some pages, like internal search results or a 404 page, should be
noindex. Put exceptions in one obvious place, likeNOINDEX_ALLOWEDin the script. - Make failures readable. Each message should name the page, the rule and the fix. Consider posting a summary as a sticky PR comment.
-
Give every check an owner. When a rule fires falsely, someone needs to fix the rule rather than add
|| trueto the workflow.
Frequently Asked Questions
Can you automate SEO testing?
Yes, for the technical layer. Anything with a clear pass or fail can be automated: tags, status codes, redirects, robots rules, structured data syntax and sitemap health. Content quality, search intent and strategy still need human review, so automation complements an SEO specialist rather than replacing one.
Is Lighthouse enough for SEO checks in CI?
No. Lighthouse's SEO category covers useful basics, but it audits one page at a time, doesn't compare titles across pages, can't see your redirects or robots.txt in production, and doesn't validate your structured data against your own rules. Use it as one layer alongside an HTML linter and post-deploy checks.
Should SEO checks fail the build or just warn?
Fail the build for deterministic, high-impact problems such as noindex on indexable pages, missing titles, broken canonicals and invalid JSON-LD. Warn for things that are noisy or subjective, like performance scores, title length or multiple H1 tags. Start everything as a warning, then promote checks once they've proven reliable.
How do I test for noindex and robots.txt mistakes before they reach production?
Lint the built HTML for noindex meta tags in every pull request, then run a post-deploy check against production for robots.txt rules and X-Robots-Tag headers. Headers and robots files often come from server or CDN config rather than your code, so the production check is essential.
How do I check for broken links in GitHub Actions?
Use a link checker such as lychee through its GitHub Action. Run it in offline mode on your build output during pull requests to check internal links quickly, then run it in online mode on a schedule to check external links without blocking merges when a third-party site is down.
Do SEO checks slow down a CI pipeline?
They don't have to. An HTML linter over a typical static build finishes in seconds. Lighthouse is the slow part, so limit it to a few key templates. Post-deploy and scheduled checks run outside the pull request flow entirely, so they never block developers.
Wrapping Up
A sensible SEO pipeline has four layers. It gates pull requests on the built HTML, samples key templates with Lighthouse, checks production right after deploy, and monitors slow drift on a schedule. Automate what's binary, monitor what's noisy, and keep judgment human.
When you're ready to add your next rule, browse the full list of on-page and technical SEO checks and pick one that your pipeline doesn't cover yet.
Top comments (2)
The clean-run-only sitemap baseline and explicit redirect map make a useful pairing. I'd store the URL set alongside sitemapCount: deleting ten established pages and adding ten unrelated ones leaves the count unchanged, so the current drop check would pass. Diffing those sets against redirects.csv would distinguish an intentional migration from missing routes. It also gives the person reviewing a deployment a concrete list of removed pages instead of just a reassuring total.
Testing the built HTML instead of the source templates is the line most teams miss. The template can be perfect and the shipped page still carries a staging noindex.