← Back to Course Index

Module 5.5 — SEO Monitoring & QA in CI/CD

Phase: 5 — Advanced Specialist Topics

Every great technical SEO audit eventually runs into the same enemy: the next deploy. A developer merges a pull request, a CMS plugin auto-updates, or a template gets refactored — and suddenly canonical tags are broken, a noindex directive has spread to production, or your LCP hero image is lazy-loaded again. Without automated guardrails, you find out from a traffic drop, not a failed test.

This module teaches you how to embed SEO quality gates directly into your development pipeline so that regressions are caught before they ship — not after they've affected rankings.

Learning Objectives

1. What Is CI/CD and Why It Matters for SEO

CI/CD stands for Continuous Integration / Continuous Deployment (or Delivery). In practice it means:

As a technical SEO practitioner, your interest is the test stage. If you can write a test that catches "canonical tag is missing on all product pages," and that test runs on every pull request, you have effectively turned yourself into a persistent, automated member of the engineering review process.

The pipeline stages where SEO checks typically live:

2. Defining an SEO Regression

Before you can automate detection, you need to know what you are protecting. An SEO regression is any unintended change that degrades crawlability, indexability, performance, or structured-data validity. Regressions cluster into five categories:

2.1 Indexability Regressions

2.2 Crawlability Regressions

2.3 Performance Regressions

2.4 Structured Data Regressions

2.5 Metadata Regressions

3. Lighthouse CI — Enforcing Performance Budgets

Lighthouse CI (@lhci/cli) is the official tool for running Lighthouse audits programmatically in a CI environment. It measures Core Web Vitals proxies (TBT for INP, LCP, CLS), accessibility, SEO, and best practices against a deployed URL and asserts thresholds.

3.1 Installation

npm install -g @lhci/cli
# or as a dev dependency
npm install --save-dev @lhci/cli

3.2 Configuration: lighthouserc.js

Place this file at the root of your project. Lighthouse CI reads it automatically.

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      // URL(s) to audit — use your preview/staging URL in CI
      url: [
        'https://staging.example.com/',
        'https://staging.example.com/products/example-product/',
        'https://staging.example.com/blog/example-post/',
      ],
      numberOfRuns: 3,            // median of 3 runs for stability
      settings: {
        // Simulate a mid-range mobile device
        preset: 'desktop',        // or 'mobile'
        throttlingMethod: 'simulate',
      },
    },
    assert: {
      // Fail the CI build if these thresholds are breached
      assertions: {
        // Core Web Vitals proxies
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift':  ['error', { maxNumericValue: 0.1  }],
        'total-blocking-time':      ['warn',  { maxNumericValue: 200  }],

        // SEO category score must stay above 90
        'categories:seo':           ['error', { minScore: 0.9 }],

        // Specific SEO audits
        'document-title':           ['error', { minScore: 1 }],
        'meta-description':         ['error', { minScore: 1 }],
        'canonical':                ['error', { minScore: 1 }],
        'hreflang':                 ['warn',  { minScore: 1 }],
        'robots-txt':               ['error', { minScore: 1 }],
        'crawlable-anchors':        ['error', { minScore: 1 }],
        'link-text':                ['warn',  { minScore: 1 }],
        'image-alt':                ['error', { minScore: 1 }],

        // Performance budgets
        'uses-optimized-images':    ['warn',  { minScore: 1 }],
        'unused-javascript':        ['warn',  { minScore: 0.8 }],
        'render-blocking-resources':['warn',  { minScore: 1 }],
      },
    },
    upload: {
      // Store results in the temporary public storage (or self-host LHCI server)
      target: 'temporary-public-storage',
    },
  },
};

3.3 Running Locally

# Collect, assert, and upload in one command
lhci autorun

3.4 GitHub Actions Workflow

The following workflow runs Lighthouse CI on every pull request targeting main. It first deploys to a preview environment, then audits it. Adjust the deploy step for your platform (Vercel, Netlify, or a staging server).

# .github/workflows/lhci.yml
name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Build project
        run: npm run build

      # Example: deploy to a preview URL first (Vercel CLI shown)
      # - name: Deploy to preview
      #   run: vercel --token ${{ secrets.VERCEL_TOKEN }} --yes > preview-url.txt

      - name: Run Lighthouse CI
        run: npx lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

The LHCI_GITHUB_APP_TOKEN allows Lighthouse CI to post results as a status check on the pull request, so reviewers see pass/fail directly in the GitHub UI without reading logs.

4. Link and Redirect Checking

Lighthouse audits Lighthouse-specific SEO signals, but it will not crawl your full site. You need a dedicated link checker for broader coverage. Two solid options:

4.1 broken-link-checker (Node.js)

npm install -g broken-link-checker
# Check all internal links on a staging URL, report 4xx and 5xx
blc https://staging.example.com --recursive \
    --exclude-external \
    --filter-level 3

4.2 lychee (Rust, very fast)

lychee is a high-performance link checker available as a GitHub Action. It is well-suited to large sites because it parallelises requests efficiently.

# .github/workflows/link-check.yml
name: Link Check

on:
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'   # Also run weekly on Monday morning

jobs:
  links:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Link Checker
        uses: lycheeverse/lychee-action@v1
        with:
          args: |
            --verbose
            --no-progress
            --exclude-loopback
            --exclude 'mailto:*'
            --exclude 'tel:*'
            --accept 200,206,429
            'https://staging.example.com'
          fail: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4.3 Redirect Chain Assertions

A redirect chain longer than two hops wastes crawl budget and loses PageRank through each hop. You can assert chain length with a simple Node script using the built-in https module:

// scripts/check-redirects.mjs
import https from 'https';

const urlsToCheck = [
  'https://staging.example.com/old-page/',
  'https://staging.example.com/another-old-url/',
];

const MAX_HOPS = 2;

async function followRedirects(url, hops = 0) {
  return new Promise((resolve, reject) => {
    const req = https.get(url, { method: 'HEAD' }, (res) => {
      const { statusCode, headers } = res;
      if ([301, 302, 307, 308].includes(statusCode) && headers.location) {
        if (hops >= MAX_HOPS) {
          reject(new Error(`Redirect chain too long (>${MAX_HOPS} hops) starting at ${url}`));
        } else {
          resolve(followRedirects(headers.location, hops + 1));
        }
      } else {
        resolve({ url, statusCode, hops });
      }
    });
    req.on('error', reject);
  });
}

let failed = false;
for (const url of urlsToCheck) {
  try {
    const result = await followRedirects(url);
    console.log(`✓ ${result.url} — ${result.hops} hop(s) → ${result.statusCode}`);
  } catch (err) {
    console.error(`✗ ${err.message}`);
    failed = true;
  }
}

if (failed) process.exit(1);
# Add to your CI workflow as a step:
- name: Check redirect chains
  run: node scripts/check-redirects.mjs

5. Canonical and Meta Tag Assertion

Lighthouse checks whether a canonical tag exists, but it does not verify that the canonical points to the correct URL. A self-referencing canonical on every page is the baseline expectation. You need custom assertions for that.

The following script fetches a list of URLs and checks that each page's canonical tag matches its own URL (i.e., is self-referencing, as expected for canonical pages):

// scripts/check-canonicals.mjs
import { JSDOM } from 'jsdom';

const pagesToCheck = [
  'https://staging.example.com/',
  'https://staging.example.com/products/example-product/',
  'https://staging.example.com/blog/example-post/',
];

let failed = false;

for (const url of pagesToCheck) {
  const response = await fetch(url);
  const html     = await response.text();
  const dom      = new JSDOM(html);
  const document = dom.window.document;

  // --- Canonical check ---
  const canonicalEl = document.querySelector('link[rel="canonical"]');
  if (!canonicalEl) {
    console.error(`✗ [canonical] Missing canonical tag on: ${url}`);
    failed = true;
  } else {
    const canonical = canonicalEl.getAttribute('href');
    if (canonical !== url) {
      console.error(`✗ [canonical] Expected "${url}", got "${canonical}"`);
      failed = true;
    } else {
      console.log(`✓ [canonical] ${url}`);
    }
  }

  // --- noindex check ---
  const robotsMeta = document.querySelector('meta[name="robots"]');
  if (robotsMeta) {
    const content = robotsMeta.getAttribute('content') || '';
    if (content.includes('noindex')) {
      console.error(`✗ [noindex] NOINDEX found on indexable page: ${url} — content="${content}"`);
      failed = true;
    } else {
      console.log(`✓ [robots meta] ${url} — "${content}"`);
    }
  }

  // --- Title check ---
  const title = document.querySelector('title')?.textContent?.trim();
  if (!title) {
    console.error(`✗ [title] Missing <title> on: ${url}`);
    failed = true;
  } else if (title.length > 60) {
    console.warn(`⚠ [title] Title may be truncated (${title.length} chars): ${url}`);
  } else {
    console.log(`✓ [title] "${title}" — ${url}`);
  }

  // --- Meta description check ---
  const desc = document.querySelector('meta[name="description"]')?.getAttribute('content')?.trim();
  if (!desc) {
    console.error(`✗ [meta description] Missing on: ${url}`);
    failed = true;
  } else {
    console.log(`✓ [meta description] ${url}`);
  }
}

if (failed) process.exit(1);
npm install jsdom   # dependency for the script above

6. Structured Data Validation

The Google Rich Results Test is a browser tool — not scriptable in CI. For automation, use the Schema.org validator or the unofficial schema-dts type checker together with a JSON-LD extraction and validation script.

6.1 Extracting and Validating JSON-LD

// scripts/check-schema.mjs
import { JSDOM } from 'jsdom';

const pagesToCheck = [
  { url: 'https://staging.example.com/products/example-product/', requiredType: 'Product' },
  { url: 'https://staging.example.com/blog/example-post/',        requiredType: 'Article' },
  { url: 'https://staging.example.com/',                          requiredType: 'Organization' },
];

let failed = false;

for (const { url, requiredType } of pagesToCheck) {
  const response = await fetch(url);
  const html     = await response.text();
  const dom      = new JSDOM(html);
  const scripts  = dom.window.document.querySelectorAll(
    'script[type="application/ld+json"]'
  );

  if (scripts.length === 0) {
    console.error(`✗ [schema] No JSON-LD found on: ${url}`);
    failed = true;
    continue;
  }

  let found = false;
  for (const script of scripts) {
    let parsed;
    try {
      parsed = JSON.parse(script.textContent);
    } catch (e) {
      console.error(`✗ [schema] Invalid JSON in JSON-LD block on: ${url} — ${e.message}`);
      failed = true;
      continue;
    }

    // Handle @graph arrays
    const blocks = parsed['@graph'] ? parsed['@graph'] : [parsed];
    for (const block of blocks) {
      if (block['@type'] === requiredType) {
        found = true;
        console.log(`✓ [schema] Found @type="${requiredType}" on: ${url}`);

        // Type-specific required-property checks
        if (requiredType === 'Product') {
          if (!block.name)  { console.error(`✗ [schema] Product missing "name" on: ${url}`);  failed = true; }
          if (!block.offers){ console.error(`✗ [schema] Product missing "offers" on: ${url}`); failed = true; }
        }
        if (requiredType === 'Article') {
          if (!block.headline)     { console.error(`✗ [schema] Article missing "headline" on: ${url}`);     failed = true; }
          if (!block.datePublished){ console.error(`✗ [schema] Article missing "datePublished" on: ${url}`); failed = true; }
        }
      }
    }
  }

  if (!found) {
    console.error(`✗ [schema] @type="${requiredType}" not found on: ${url}`);
    failed = true;
  }
}

if (failed) process.exit(1);

6.2 Using the Schema.org Validator API

The Schema Markup Validator provides a testable endpoint. You can POST a URL to it and parse the response for errors. At the time of writing, the API is not officially versioned for public automation; use the extraction script above for robust CI integration, and reserve the online validator for manual audits and debugging.

7. Sitemap Validation

Your sitemap must be reachable, valid XML, and not contain URLs that return 4xx or are marked noindex. A simple check in CI:

// scripts/check-sitemap.mjs
import { JSDOM } from 'jsdom';

const SITEMAP_URL = 'https://staging.example.com/sitemap.xml';
const MAX_URLS_TO_SAMPLE = 20;   // check a sample, not every URL in large sitemaps

const response = await fetch(SITEMAP_URL);
if (!response.ok) {
  console.error(`✗ [sitemap] Could not fetch ${SITEMAP_URL} — status ${response.status}`);
  process.exit(1);
}

const xml  = await response.text();
const dom  = new JSDOM(xml, { contentType: 'text/xml' });
const locs = Array.from(dom.window.document.querySelectorAll('loc'))
                  .map(el => el.textContent.trim());

if (locs.length === 0) {
  console.error('✗ [sitemap] Sitemap contains no <loc> entries.');
  process.exit(1);
}
console.log(`✓ [sitemap] ${locs.length} URL(s) found.`);

// Sample up to MAX_URLS_TO_SAMPLE URLs and HEAD-check them
const sample  = locs.slice(0, MAX_URLS_TO_SAMPLE);
let   failed  = false;

for (const url of sample) {
  const res = await fetch(url, { method: 'HEAD' });
  if (res.status !== 200) {
    console.error(`✗ [sitemap] ${url} returned ${res.status}`);
    failed = true;
  } else {
    console.log(`✓ [sitemap] ${url} — 200`);
  }
}

if (failed) process.exit(1);

8. Putting It All Together: A Complete GitHub Actions Workflow

The following workflow runs on every pull request. It installs dependencies, builds, and sequentially runs all SEO checks. Any failure blocks the merge.

# .github/workflows/seo-qa.yml
name: SEO QA

on:
  pull_request:
    branches: [main]

jobs:
  seo-checks:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Use Node.js 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      # --- Deploy to preview URL first ---
      # (Replace with your actual preview deployment step)
      # - name: Deploy preview
      #   run: npx vercel --token ${{ secrets.VERCEL_TOKEN }} --yes

      - name: Lighthouse CI
        run: npx lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

      - name: Check canonical tags and meta
        run: node scripts/check-canonicals.mjs

      - name: Check structured data
        run: node scripts/check-schema.mjs

      - name: Check sitemap
        run: node scripts/check-sitemap.mjs

      - name: Check redirect chains
        run: node scripts/check-redirects.mjs

      - name: Link check (internal only)
        uses: lycheeverse/lychee-action@v1
        with:
          args: '--verbose --no-progress --exclude-external https://staging.example.com'
          fail: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9. Post-Deploy Smoke Tests

CI gates protect before deploy. Post-deploy smoke tests verify that the deployment to production succeeded without introducing unexpected changes. Keep these lightweight — they run against live production, so they should be read-only and fast.

A minimal post-deploy smoke test checks:

// scripts/smoke-test.mjs
const PRODUCTION_URL = 'https://www.example.com';

const checks = [
  { url: `${PRODUCTION_URL}/`,         expectStatus: 200 },
  { url: `${PRODUCTION_URL}/sitemap.xml`, expectStatus: 200 },
  { url: `${PRODUCTION_URL}/robots.txt`,  expectStatus: 200 },
];

let failed = false;

for (const { url, expectStatus } of checks) {
  const res = await fetch(url, { method: 'HEAD' });
  if (res.status !== expectStatus) {
    console.error(`✗ ${url} — expected ${expectStatus}, got ${res.status}`);
    failed = true;
  } else {
    console.log(`✓ ${url} — ${res.status}`);
  }
}

// robots.txt sanity check
const robots = await (await fetch(`${PRODUCTION_URL}/robots.txt`)).text();
if (robots.includes('Disallow: /\n') || robots.trim() === 'User-agent: *\nDisallow: /') {
  console.error('✗ [robots.txt] Site appears to be blocking all crawlers!');
  failed = true;
} else {
  console.log('✓ [robots.txt] No blanket Disallow found.');
}

if (failed) process.exit(1);

Trigger this script as a final step in your CD workflow, after the deployment step, via a workflow_run event or a deployment environment hook.

10. Scheduling Ongoing Monitoring

CI/CD gates catch regressions at deploy time. But some issues arise outside deploys: a third-party script changes its payload, a CMS editor accidentally removes a canonical, or a hosting provider's CDN starts stripping a header. Schedule your checks to run regularly even when no code is being pushed.

# Add a scheduled trigger to any workflow:
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    # Run full SEO QA suite every Monday at 07:00 UTC
    - cron: '0 7 * * 1'

For production monitoring at scale, consider complementing your CI scripts with:

11. What to Automate vs What Requires Human Judgment

Automation is powerful, but it is not a substitute for SEO expertise. Know the boundary:

A test suite that fails noisily on legitimate editorial decisions (e.g., a deliberate noindex on a staging page that crept into the URL list) becomes a nuisance that the team starts ignoring. Keep your automated assertions tight and deterministic. Use warnings (warn in Lighthouse CI, console warnings in scripts) for grey-area signals that should prompt review without blocking deployment.

12. Recommended Tooling Summary

Milestone Task

You have completed this module when you can do the following without referring to these notes:

  1. Set up a GitHub Actions workflow on a real (or realistic test) project that runs Lighthouse CI and fails the pull request if LCP exceeds 2500 ms or the Lighthouse SEO category score drops below 0.90.
  2. Write and run a Node.js script that checks a list of five URLs for: correct self-referencing canonicals, absence of noindex, presence of a non-empty <title>, and valid JSON-LD with the correct @type for each page.
  3. Add a scheduled weekly run to your workflow and a post-deploy smoke test that verifies robots.txt is not blocking all crawlers.
  4. Demonstrate one deliberate regression (e.g., add noindex to a page in your test environment) and show that your pipeline catches it before it could be merged.