Automated SEO Audits for Headless CMS Deployments
Crawlers stumble over ISR, edge-rendered responses, and deferred hydration — the exact patterns a headless stack relies on — so SEO regressions ship silently. Moving the audit into CI/CD catches them before deploy: validate schema and metadata pre-build, confirm server-injected tags post-build, and check CDN propagation post-deploy. This guide, part of Dynamic Sitemap Generation, builds that three-phase pipeline with a GitHub Actions workflow and a Playwright audit script that fails the build on missing canonical or Open Graph tags.
Pipeline Phases
The pipeline runs three phases, each targeting a distinct failure mode of decoupled delivery.
Pre-build validation checks schema conformity and metadata templates before the generator runs. Missing og:/twitter: tags, malformed canonicals, and inconsistent hreflang usually trace back to unvalidated CMS payloads. Parse the content graph so every published node maps to a valid route, and cross-reference locale prefixes against route definitions to prevent canonical collisions — part of broader Localization & SEO Optimization.
Post-build verification inspects the output directory for routing tables, asset hashes, and cache headers. Edge functions fetching from the CMS often serve stale metadata under aggressive CDN caching, so parse the compiled HTML to confirm meta tags are injected server-side, not deferred to hydration. Validate robots.txt and sitemap integrity here to keep orphaned routes out of production.
Post-deployment synthetic testing confirms CDN propagation and cache consistency across edge nodes. Headless-browser automation simulates real navigation, intercepts requests, and validates status codes. Trigger on main pushes or release tags against a local preview server on localhost:3000; parallel jobs cut CI time but need isolated ports and deterministic env vars.
CI/CD Orchestration
GitHub Actions or GitLab CI orchestrates the sequence: install, build, launch a preview server, run validation. This config isolates the audit environment and passes CMS secrets securely.
name: seo-audit-pipeline
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Start preview server
run: npm run preview &
env:
PORT: 3000
- name: Wait for server readiness
run: npx wait-on http://localhost:3000
- name: Execute SEO audit
run: npm run audit:seo
env:
BASE_URL: http://localhost:3000
CMS_WEBHOOK_SECRET: ${{ secrets.CMS_WEBHOOK_SECRET }}
AUDIT_THRESHOLD: 90
Audit Script
The script handles async route resolution and aggregates results. Headless frameworks defer route generation until first request, so synthetic navigation is what populates ISR caches. Playwright handles network interception and DOM inspection across Chromium, Firefox, and WebKit.
import { chromium, Page } from 'playwright';
import { readFileSync } from 'node:fs';
import { parseStringPromise } from 'xml2js';
interface AuditConfig {
baseUrl: string;
threshold: number;
sitemapPath: string;
}
interface AuditResult {
route: string;
status: number;
canonical: string | null;
metaTags: Record<string, string>;
passed: boolean;
}
async function parseSitemap(path: string): Promise<string[]> {
const xml = readFileSync(path, 'utf-8');
const result = await parseStringPromise(xml);
// Sitemap <loc> values are absolute production URLs; keep only the path so the audit can target any base URL.
return result.urlset.url.map((u: { loc: string[] }) => new URL(u.loc[0]).pathname);
}
async function runAudit(config: AuditConfig): Promise<AuditResult[]> {
const browser = await chromium.launch({ headless: true });
const routes = await parseSitemap(config.sitemapPath);
const results: AuditResult[] = [];
for (const route of routes) {
const page = await browser.newPage();
const response = await page.goto(`${config.baseUrl}${route}`, {
waitUntil: 'networkidle',
timeout: 10000,
});
const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
const metaTags: Record<string, string> = {};
await page.locator('meta[name], meta[property]').evaluateAll((els) => {
const tags: Record<string, string> = {};
els.forEach((el) => {
const name = el.getAttribute('name') || el.getAttribute('property');
const content = el.getAttribute('content');
if (name && content) tags[name] = content;
});
return tags;
}).then((data) => Object.assign(metaTags, data));
const status = response?.status() ?? 0;
const hasRequiredMeta = !!metaTags['og:title'] && !!metaTags['og:description'] && !!canonical;
results.push({
route,
status,
canonical,
metaTags,
passed: status === 200 && hasRequiredMeta,
});
await page.close();
}
await browser.close();
return results;
}
// Execution entry point
const config: AuditConfig = {
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
threshold: parseInt(process.env.AUDIT_THRESHOLD || '90', 10),
sitemapPath: './public/sitemap.xml',
};
runAudit(config).then((results) => {
const failures = results.filter((r) => !r.passed);
if (failures.length > 0) {
console.error(`Audit failed: ${failures.length} routes did not meet SEO requirements.`);
console.table(failures.map(({ route, status, canonical }) => ({ route, status, canonical })));
process.exit(1);
}
console.log(`Audit passed: ${results.length} routes validated successfully.`);
process.exit(0);
});
The flow: CI builds the site, serves it locally, and runs the script. The script parses the sitemap, visits each route, and waits for networkidle so ISR and edge functions finish hydrating. It extracts canonical and Open Graph tags, checks for HTTP 200, and aggregates results. Any failure exits non-zero and blocks the deploy.
Checking server HTML, not just the hydrated DOM
The browser-based audit sees the page after JavaScript runs, which is also what modern crawlers eventually see. But tags that only appear after hydration are indexed later and less reliably, and social sharing crawlers do not run JavaScript at all. Add a second, cheaper check that fetches each URL with a plain HTTP request and parses the raw HTML for the title, description, canonical, robots and Open Graph tags. Any tag present in the hydrated DOM but missing from the raw HTML is a finding in its own right: it means the metadata is injected on the client.
// audit/raw-html-check.ts
import { parse } from "node-html-parser";
export async function rawMeta(url: string) {
const html = await (await fetch(url, { headers: { "User-Agent": "seo-audit" } })).text();
const doc = parse(html);
return {
title: doc.querySelector("title")?.text ?? null,
canonical: doc.querySelector('link[rel="canonical"]')?.getAttribute("href") ?? null,
robots: doc.querySelector('meta[name="robots"]')?.getAttribute("content") ?? null,
ogTitle: doc.querySelector('meta[property="og:title"]')?.getAttribute("content") ?? null,
hreflang: doc.querySelectorAll('link[rel="alternate"][hreflang]').map((l) => l.getAttribute("hreflang")),
};
}
Cache Consistency Edge Cases
The recurring failure is a metadata-injection race: a content webhook fires, the CDN serves stale HTML while the origin regenerates the route. Validate Cache-Control alongside Last-Modified to catch it. Dynamic Sitemap Generation gives you the crawlable route set, but the audit must cross-reference that sitemap against the CMS content index to expose orphaned paths and failed regenerations.
Parse XML against Google’s sitemap specifications, especially for multilingual hreflang and nested routes. Missing canonicals usually trace to route-mapping mismatches in localized setups. Deterministic build-stage validation is what keeps index bloat, duplicate-content penalties, and CWV regressions out of every environment — turning SEO from a manual checklist into a build gate.
Gotchas & Edge Cases
- Auditing every URL on large sites. Crawling tens of thousands of URLs per deploy is slow. Audit a stratified sample per template and locale on every deploy, and the full set nightly.
- Preview servers with production data. The audit must run against content like production’s. Use the production delivery API read-only, or a recent snapshot, not an empty staging space.
- Flaky network checks. Timeouts on a few URLs should retry before failing the build, or the gate will be disabled within a week.
- Rules that differ by type. Some types are noindex by design. Encode expectations per content type rather than one rule for all pages.
Worked Example
A marketplace added the audit after a release that shipped product pages with canonicals pointing to the staging host, discovered three weeks later when traffic dropped. The pre-build phase now checks required SEO fields, the post-build phase parses raw HTML for 400 sampled URLs per template and locale, and a nightly job crawls the full sitemap. In its first two months, the gate blocked four deploys: two with client-only metadata after a component refactor, one with a wrong canonical host in a new locale, and one where a content type change removed descriptions.
Rollout Checklist
- Validate required SEO fields per content type before building.
- Parse raw HTML for canonical, robots, title and Open Graph tags after building.
- Crawl a stratified sample on each deploy and the full sitemap nightly.
- Compare sitemap URLs with CMS content to find orphans and failed regenerations.
- Block deploys on critical findings and report warnings.
- Retry transient network failures before failing.
Frequently Asked Questions
Is Lighthouse’s SEO category enough?
It checks basics on single pages. A site audit must check consistency across thousands of pages and against the sitemap and CMS, which Lighthouse does not do.
Should the audit run on pull requests?
Yes, the pre-build and post-build phases. The crawl phase needs a deployed preview, which many platforms provide per pull request.
How do we avoid slowing down deploys?
Sample per template, parallelize requests with a concurrency limit, and move the exhaustive crawl to a nightly job.
Who fixes audit failures?
The team whose change triggered them, usually visible from the diff. Content-caused failures go to the content owner with the entry id.
What should the audit report contain?
A short summary per template and locale, the list of failing URLs with the failed check, and links to the relevant entry in the CMS. Keep the full raw results as a build artifact for debugging, and compare them with the previous run to highlight new failures.
Can content editors run the audit?
A lighter version, yes: a preview panel that runs the raw-HTML checks for the current entry before publishing catches most content-caused issues at the source, before they ever reach a build.