---
title: "SEO tests in CI/CD: check before and after deploy."
description: "Add your SEO checks to commits, staging, and production monitoring. Test canonicals, noindex, JSON-LD, and performance, then route failures."
canonical: "https://scalewithsearch.com/articles/seo-testing-ci-cd-pipeline"
date: "2026-03-20"
modified: "2026-09-25"
---
## Site navigation

- [Scale With Search](https://scalewithsearch.com/)
- Real estate
  - Real estate
    - [Real estate](https://scalewithsearch.com/for/real-estate)
- Work
  - Start here
    - [Send your brief](https://scalewithsearch.com/work#send-your-brief)
    - [Prepare your six-question brief](https://scalewithsearch.com/work#prepare-your-six-question-brief)
  - Build
    - [Site build, content library with SEO, signal desk](https://scalewithsearch.com/work)
- For your business
  - Trades and home services
    - [Auto body and collision shops](https://scalewithsearch.com/for/auto-body-and-collision-shops)
    - [Foundation and home repair contractors](https://scalewithsearch.com/for/foundation-and-home-repair)
    - [Garage door and fencing contractors](https://scalewithsearch.com/for/garage-door-and-fencing-contractors)
    - [HVAC contractors](https://scalewithsearch.com/for/hvac-contractors)
    - [Janitorial and commercial cleaning companies](https://scalewithsearch.com/for/janitorial-and-commercial-cleaning)
    - [Locksmiths](https://scalewithsearch.com/for/locksmiths)
    - [Moving companies](https://scalewithsearch.com/for/moving-companies)
    - [Pest control companies](https://scalewithsearch.com/for/pest-control-companies)
    - [Plumbing and electrical contractors](https://scalewithsearch.com/for/plumbing-and-electrical-contractors)
    - [Restoration and water or fire damage companies](https://scalewithsearch.com/for/restoration-and-water-fire-damage)
    - [Roofing companies](https://scalewithsearch.com/for/roofing-companies)
    - [Towing companies](https://scalewithsearch.com/for/towing-companies)
    - [Tree services and landscaping companies](https://scalewithsearch.com/for/tree-services-and-landscaping)
    - [Solar installers](https://scalewithsearch.com/for/solar-installation)
    - [General contractors](https://scalewithsearch.com/for/general-contractors-and-construction)
    - [Paving, concrete, and flooring contractors](https://scalewithsearch.com/for/paving)
  - Practices and professional services
    - [Bookkeeping and tax practices](https://scalewithsearch.com/for/bookkeeping-and-tax-practices)
    - [Dental practices](https://scalewithsearch.com/for/dental-practices)
    - [Family and criminal defense law firms](https://scalewithsearch.com/for/family-and-criminal-defense-law-firms)
    - [Med spas and aesthetics practices](https://scalewithsearch.com/for/med-spas-and-aesthetics)
    - [Personal injury law firms](https://scalewithsearch.com/for/personal-injury-law-firms)
    - [Veterinary clinics](https://scalewithsearch.com/for/veterinary-clinics)
    - [Gyms and fitness studios](https://scalewithsearch.com/for/fitness)
    - [Therapy and outpatient health practices](https://scalewithsearch.com/for/therapy-and-outpatient-health)
    - [Medical billing companies](https://scalewithsearch.com/for/medical-billing)
    - [Insurance agencies](https://scalewithsearch.com/for/insurance-agencies)
    - [Financial advisors](https://scalewithsearch.com/for/financial-advisors)
    - [Property management companies](https://scalewithsearch.com/for/property-management)
    - [Recruiting and staffing agencies](https://scalewithsearch.com/for/recruiting-and-staffing)
    - [Architects and interior designers](https://scalewithsearch.com/for/architects-and-interior-designers)
    - [Logistics and supply chain companies](https://scalewithsearch.com/for/logistics-and-supply-chain)
  - Agencies, MSPs, and manufacturing
    - [IT and managed service providers](https://scalewithsearch.com/for/it-and-managed-service-providers)
    - [Machine shops and precision manufacturers](https://scalewithsearch.com/for/machine-shops-and-precision-manufacturing)
    - [Marketing agencies and freelancers](https://scalewithsearch.com/for/marketing-agencies-and-freelancers)
    - [SEO agencies and consultants](https://scalewithsearch.com/for/seo-agencies-and-consultants)
    - [Small manufacturers and fabricators](https://scalewithsearch.com/for/small-manufacturers-and-fabricators)
  - Restaurants, shops, studios, and nonprofits
    - [Restaurants and hospitality businesses](https://scalewithsearch.com/for/restaurants-and-hospitality)
    - [Retail stores and ecommerce sellers](https://scalewithsearch.com/for/retail-and-ecommerce)
    - [Photographers, event planners, and travel agents](https://scalewithsearch.com/for/photographers)
    - [Churches and nonprofits](https://scalewithsearch.com/for/churches-and-nonprofits)
  - [All industries](https://scalewithsearch.com/for/)
- Learn
  - For your office
    - [Office job guides](https://scalewithsearch.com/guides/)
    - [Browser calculators](https://scalewithsearch.com/tools/)
  - Start here
    - [How it works](https://scalewithsearch.com/how-it-works)
    - [Free Starter Kit](https://scalewithsearch.com/kit/business-memory-starter-kit.zip)
    - [Synthetic specimen](https://scalewithsearch.com/specimen/working-session-specimen.zip)
  - Guides
    - [The Complete Guide to Business Memory for AI Agents](https://scalewithsearch.com/articles/business-memory-for-ai-agents-guide)
    - [The Complete Small-Business Guide to AI Agent Governance](https://scalewithsearch.com/articles/ai-agent-governance-guide-small-business)
    - [The Complete Guide to Leaving Vendor AI Memory](https://scalewithsearch.com/articles/leaving-vendor-ai-memory-guide)
  - Articles by cluster
    - [Business memory](https://scalewithsearch.com/articles/business-memory-for-ai-agents-guide)
    - [Agent governance](https://scalewithsearch.com/articles/ai-agent-governance-guide-small-business)
    - [Migration and ownership](https://scalewithsearch.com/articles/leaving-vendor-ai-memory-guide)
  - For machines
    - [llms.txt](https://scalewithsearch.com/llms.txt)
    - [llms-full.txt](https://scalewithsearch.com/llms-full.txt)
    - [Machine view](https://scalewithsearch.com/?view=machine)
- Company
  - Evidence
    - [Proof](https://scalewithsearch.com/proof)
  - Company
    - [About](https://scalewithsearch.com/about)

# Add SEO tests to your CI/CD pipeline to catch ranking breaks before deploy.

Your engineering team deploys 47 times each week. Last Thursday's release put a `noindex` tag on 2,000 product pages by accident. You found it on Monday, when organic traffic was down 40%. By then, Google had already dropped half the catalog from its index.

Fast release cycles break SEO when nothing guards it. Manual QA cannot catch each meta tag regression or canonical error across thousands of pages. Slower deploys are not the fix. Put SEO checks into the CI/CD pipeline, so that a broken change cannot reach production unnoticed.

This framework treats SEO tests like unit tests. They are fast and automated, and they block the deploy when a critical check fails.

## Build three test layers

| Layer | When it runs | Time budget | Purpose |
|---|---|---|---|
| Pre-commit hooks | Before code enters the repository | Under 5 seconds | Stop obviously broken commits |
| Build-time tests | In CI, before a merge to main | Under 2 minutes for fast checks | Check SEO requirements across the application and a staging deploy |
| Post-deploy monitoring | Continuously in production | Daily or hourly | Alert when live issues appear despite passing tests |

Many teams start with post-deploy monitoring only. That is reactive, because users and Google see the problem first. Pre-commit and build-time tests move SEO checks earlier, where fixes cost least.

## Layer 1: add pre-commit hooks

Install these checks in `.git/hooks/pre-commit`, or manage them with Husky for JavaScript projects or pre-commit for Python projects.

### Check title tags and meta descriptions

This check catches missing title tags and meta descriptions over the length limit. Block the commit on a missing title. Only warn on description length.

```bash
#!/usr/bin/env bash
status=0
while IFS= read -r file; do
  if ! grep -q "<title>" "$file"; then
    echo "ERROR: missing title tag in $file"
    status=1
  fi
  desc=$(sed -n 's/.*name="description" content="\([^"]*\)".*/\1/p' "$file" | head -n 1)
  if [ "${#desc}" -gt 160 ]; then
    echo "WARNING: meta description over 160 characters in $file (${#desc})"
  fi
done < <(find src -name '*.html')
exit "$status"
```

This runs in under 2 seconds on a codebase with fewer than 1,000 templates.

### Check canonical tags

This check catches pages with no canonical tag or with more than one. Block the commit on either error in a core template. The [canonical tags guide for developers](/articles/canonical-tags-developers) covers the rules that this test enforces.

```javascript
// scripts/check-canonicals.js
const fs = require('fs');
const { globSync } = require('glob');
const cheerio = require('cheerio');

let failed = false;
for (const file of globSync('src/**/*.html')) {
  const $ = cheerio.load(fs.readFileSync(file, 'utf8'));
  const count = $('link[rel="canonical"]').length;
  if (count === 0) {
    console.error(`ERROR: missing canonical tag in ${file}`);
    failed = true;
  } else if (count > 1) {
    console.error(`ERROR: ${count} canonical tags in ${file}`);
    failed = true;
  }
}
process.exit(failed ? 1 : 0);
```

This runs in under 3 seconds for 500 files.

### Confirm each robots.txt change

A single accidental `Disallow: /` has knocked entire sites out of search. Require an explicit confirmation for any change to robots.txt.

```bash
#!/usr/bin/env bash
if git diff --cached --name-only | grep -q "robots.txt"; then
  exec < /dev/tty
  echo "WARNING: robots.txt changed. Review the diff before you commit."
  git diff --cached -- robots.txt
  read -r -p "Proceed with the commit? (y/n) " reply
  if [[ ! "$reply" =~ ^[Yy]$ ]]; then
    exit 1
  fi
fi
```

Git hooks do not read from the terminal by default, so the `exec < /dev/tty` line is necessary for the prompt.

## Layer 2: add build-time tests in CI

Run these tests in GitHub Actions, GitLab CI, CircleCI, or Jenkins before a pull request merges. Most run against a staging deploy.

### Crawl the staging site

This test catches broken internal links and pages that do not return 200. Block the merge on any 404 or 5xx error on a key page. Key pages are the home page, product pages, and the 10 highest-traffic URLs. You can also run Screaming Frog in headless mode instead of a custom crawler.

```javascript
// tests/seo/crawl-test.js
const { chromium } = require('playwright');

async function crawlSite(baseUrl, maxPages = 500) {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const visited = new Set();
  const queue = [baseUrl];
  const errors = [];

  while (queue.length > 0 && visited.size < maxPages) {
    const url = queue.shift().split('#')[0];
    if (visited.has(url)) continue;
    visited.add(url);

    const response = await page.goto(url, { waitUntil: 'load' });
    if (!response || response.status() !== 200) {
      errors.push(`${url} returned ${response ? response.status() : 'no response'}`);
      continue;
    }

    const links = await page.$$eval('a[href]', (anchors) =>
      anchors.map((a) => a.href));
    queue.push(...links.filter((href) => href.startsWith(baseUrl)));
  }

  await browser.close();
  if (errors.length > 0) {
    console.error('Crawl errors:', errors);
    process.exit(1);
  }
}

crawlSite(process.env.STAGING_URL);
```

A crawl takes 30 seconds to 2 minutes, by site size. If it times out, limit it to the critical paths.

### Validate structured data

This test catches malformed JSON-LD and missing `@type` values. Block the merge on malformed JSON or missing required fields on product and article pages. Each page takes 5 to 10 seconds. The [JSON-LD guide for developers](/articles/structured-data-jsonld-developers) lists the required fields for each type.

```javascript
// tests/seo/schema-validation.js
const cheerio = require('cheerio');

async function validateSchema(url) {
  const html = await (await fetch(url)).text();
  const $ = cheerio.load(html);
  const blocks = $('script[type="application/ld+json"]');

  if (blocks.length === 0) {
    console.error(`ERROR: no JSON-LD on ${url}`);
    process.exit(1);
  }

  blocks.each((_, el) => {
    try {
      const data = JSON.parse($(el).text());
      const items = Array.isArray(data) ? data : [data];
      for (const item of items) {
        if (!item['@type']) throw new Error('missing @type');
        // Add the required-field checks for each type here.
      }
    } catch (error) {
      console.error(`ERROR: invalid JSON-LD on ${url}: ${error.message}`);
      process.exit(1);
    }
  });
}
```

### Guard Core Web Vitals with Lighthouse CI

This test catches new JavaScript or CSS that blocks rendering and hurts Core Web Vitals. Lighthouse runs in a lab, so it cannot measure Interaction to Next Paint (INP), which needs real users. Use Total Blocking Time as the lab proxy for INP. Block the merge when lab LCP exceeds 2.5 seconds or CLS exceeds 0.1. Each URL takes 20 to 40 seconds.

```json
{
  "ci": {
    "collect": {
      "url": [
        "https://staging.example.com/",
        "https://staging.example.com/product-page/"
      ]
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
        "total-blocking-time": ["warn", { "maxNumericValue": 200 }]
      }
    }
  }
}
```

Save this file as `lighthouserc.json` and run `npx @lhci/cli autorun` in the pipeline. The [Lighthouse in GitHub Actions guide](/articles/lighthouse-github-actions-seo) covers the full setup.

### Check indexability

This test catches `noindex` in the meta robots tag or the `X-Robots-Tag` header, and canonical chains. Block the merge on either error for a page that should be indexed. Each URL takes 2 to 5 seconds.

```javascript
// tests/seo/indexability-check.js
const cheerio = require('cheerio');

async function canonicalOf(url) {
  const $ = cheerio.load(await (await fetch(url)).text());
  return $('link[rel="canonical"]').attr('href');
}

async function checkIndexability(url) {
  const response = await fetch(url);
  const header = response.headers.get('x-robots-tag') || '';
  if (header.includes('noindex')) {
    console.error(`ERROR: ${url} sends noindex in X-Robots-Tag`);
    process.exit(1);
  }

  const $ = cheerio.load(await response.text());
  const robots = ($('meta[name="robots"]').attr('content') || '').toLowerCase();
  if (robots.includes('noindex')) {
    console.error(`ERROR: ${url} has a noindex meta tag`);
    process.exit(1);
  }

  const canonical = $('link[rel="canonical"]').attr('href');
  if (canonical && canonical !== url) {
    const next = await canonicalOf(canonical);
    if (next && next !== canonical) {
      console.error(`ERROR: canonical chain ${url} -> ${canonical} -> ${next}`);
      process.exit(1);
    }
  }
}
```

## Layer 3: monitor production after deploy

Tests before deploy do not catch everything. CDN misconfigurations, database migrations that change dynamic content, and third-party script changes all appear only in production.

### Track index status for key URLs

The Search Console URL Inspection API reports the index status of one URL for each request. Check a list of key URLs each day. As of September 2026, the API allows 2,000 inspections each day and 600 each minute for each property. Add the service account as a user of the Search Console property first.

```python
# scripts/monitor-index-status.py
import sys
from google.oauth2 import service_account
from googleapiclient.discovery import build

creds = service_account.Credentials.from_service_account_file(
    'service-account.json',
    scopes=['https://www.googleapis.com/auth/webmasters.readonly'])
service = build('searchconsole', 'v1', credentials=creds)

SITE = 'https://example.com/'
key_urls = [line.strip() for line in open('key-urls.txt') if line.strip()]

not_indexed = []
for url in key_urls:
    result = service.urlInspection().index().inspect(
        body={'inspectionUrl': url, 'siteUrl': SITE}).execute()
    status = result['inspectionResult']['indexStatusResult']
    if status.get('verdict') != 'PASS':
        not_indexed.append((url, status.get('coverageState')))

if len(not_indexed) > 0.10 * len(key_urls):
    print(f'ALERT: {len(not_indexed)} of {len(key_urls)} key URLs are not indexed')
    for url, state in not_indexed:
        print(url, state)
    sys.exit(1)
```

Run it each day from cron or a scheduled CI job. Investigate when more than 10% of key URLs fail. For the site-wide count, use the page indexing report in the Search Console interface. The [Search Console API guide for data teams](/articles/search-console-api-data-teams) covers quotas and authentication.

### Watch field Core Web Vitals

The Chrome UX Report (CrUX) API gives real-user Core Web Vitals at the 75th percentile. Query it each day and alert when any metric leaves the "good" range. Interaction to Next Paint (INP) replaced First Input Delay as a Core Web Vital in March 2024, so query INP.

```javascript
// scripts/monitor-cwv.js
async function checkCWV(origin) {
  const endpoint = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
  const response = await fetch(`${endpoint}?key=${process.env.CRUX_API_KEY}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ origin, formFactor: 'PHONE' }),
  });
  const { record } = await response.json();
  const p75 = (name) => Number(record.metrics[name].percentiles.p75);

  const lcp = p75('largest_contentful_paint');
  const inp = p75('interaction_to_next_paint');
  const cls = p75('cumulative_layout_shift');

  if (lcp > 2500 || inp > 200 || cls > 0.1) {
    console.error(`CWV regression: LCP=${lcp}ms INP=${inp}ms CLS=${cls}`);
    process.exit(1);
  }
}

checkCWV('https://example.com');
```

### Detect drops in organic traffic

Query the GA4 Data API and compare yesterday's organic sessions with the average of the previous seven days. Alert on a drop of more than 20%. Run it daily, or hourly on high-traffic sites.

```python
# scripts/monitor-traffic.py
import sys
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
    DateRange, Dimension, Filter, FilterExpression, Metric, RunReportRequest)

client = BetaAnalyticsDataClient()
request = RunReportRequest(
    property='properties/123456789',
    dimensions=[Dimension(name='date')],
    metrics=[Metric(name='sessions')],
    date_ranges=[DateRange(start_date='8daysAgo', end_date='yesterday')],
    dimension_filter=FilterExpression(filter=Filter(
        field_name='sessionDefaultChannelGroup',
        string_filter=Filter.StringFilter(value='Organic Search'))),
)
rows = client.run_report(request).rows
by_day = sorted((r.dimension_values[0].value, int(r.metric_values[0].value)) for r in rows)
*history, (day, latest) = by_day
baseline = sum(v for _, v in history) / len(history)

if latest < baseline * 0.8:
    print(f'ALERT: organic sessions on {day} were {latest}, 7-day average {baseline:.0f}')
    sys.exit(1)
```

## Wire the tests into the pipeline

This GitHub Actions workflow runs the fast checks first, deploys to staging, and then runs the staging tests:

```yaml
# .github/workflows/seo-tests.yml
name: SEO tests
on:
  pull_request:
    branches: [main]

jobs:
  seo-validation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Meta tag check
        run: npm run test:meta-tags
      - name: Canonical check
        run: npm run test:canonicals
      - name: Deploy to staging
        run: npm run deploy:staging
        env:
          STAGING_KEY: ${{ secrets.STAGING_KEY }}
      - name: Wait for staging
        run: sleep 30
      - name: Crawl staging
        run: npm run test:crawl
        env:
          STAGING_URL: https://staging.example.com
      - name: Schema check
        run: npm run test:schema
      - name: Lighthouse CI
        run: npx @lhci/cli autorun
      - name: Indexability check
        run: npm run test:indexability
```

Replace the fixed `sleep 30` with a poll of a staging health endpoint if deploy times vary. The same stages work in GitLab CI:

```yaml
# .gitlab-ci.yml
stages: [validate, build, deploy, test]

meta-tag-validation:
  stage: validate
  script: [npm run test:meta-tags]

canonical-validation:
  stage: validate
  script: [npm run test:canonicals]

build-staging:
  stage: build
  script: [npm run build]
  artifacts:
    paths: [dist/]

deploy-staging:
  stage: deploy
  script: [npm run deploy:staging]
  environment:
    name: staging
    url: https://staging.example.com

crawl-test:
  stage: test
  script: [npm run test:crawl]

schema-validation:
  stage: test
  script: [npm run test:schema]

lighthouse-test:
  stage: test
  image: cypress/browsers:latest
  script: [npx @lhci/cli autorun]
```

## Decide which failures block a deploy

SEO tests must fail loudly on critical issues and only warn on minor ones.

| Blocks the deploy (exit code 1) | Warns only (exit code 0, logged) |
|---|---|
| Missing title tag on an indexable page | Meta description over 160 characters |
| More than one canonical tag on a page | Missing alt text |
| A 500 error on any tested page | Redirect chains off critical paths |
| `noindex` on key pages such as the home page or product pages | Minor HTML validation errors |
| A Core Web Vitals regression of more than 20% | A lower Lighthouse score while Core Web Vitals still pass |
| Malformed JSON-LD | |

Give developers a logged override. A commit message flag such as `[skip-seo-warnings]` can bypass warnings. A flag such as `[override-seo-test: reason]` can bypass a blocking test, with the reason logged for review. Track how often people use it. Frequent overrides mean the tests are misconfigured.

## Add tests for your framework

**React and Next.js.** Server rendering and hydration can produce different content. Compare the raw server response with the rendered DOM:

```javascript
// tests/seo/ssr-test.js
const { chromium } = require('playwright');
const cheerio = require('cheerio');

async function testSSR(url) {
  const serverHtml = await (await fetch(url)).text();
  const serverTitle = cheerio.load(serverHtml)('title').text();

  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'load' });
  const renderedTitle = await page.title();
  await browser.close();

  if (serverTitle !== renderedTitle) {
    console.error(`Title mismatch: server="${serverTitle}" rendered="${renderedTitle}"`);
    process.exit(1);
  }
}
```

**WordPress.** Plugin updates can break SEO settings in Yoast or Rank Math. Test the stored values in a WordPress test environment:

```php
// tests/test-seo-plugin.php
class SEOPluginTest extends WP_UnitTestCase {
    public function testMetaTags() {
        $post_id = self::factory()->post->create();
        update_post_meta($post_id, '_yoast_wpseo_title', 'Sample title');
        update_post_meta($post_id, '_yoast_wpseo_metadesc', 'Sample description');

        $meta_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
        $meta_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);

        $this->assertNotEmpty($meta_title, 'Meta title must not be empty');
        $this->assertLessThanOrEqual(60, strlen($meta_title), 'Title exceeds 60 characters');
        $this->assertLessThanOrEqual(160, strlen($meta_desc), 'Description exceeds 160 characters');
    }
}
```

**Shopify.** A change to a Liquid template can leave variables unrendered. Check the rendered page:

```javascript
// tests/seo/liquid-test.js
const cheerio = require('cheerio');

async function testLiquidTemplates(productUrl) {
  const $ = cheerio.load(await (await fetch(productUrl)).text());
  const title = $('title').text();
  const ogTitle = $('meta[property="og:title"]').attr('content') || '';

  if (title.includes('{{') || ogTitle.includes('{{')) {
    console.error('ERROR: unrendered Liquid variables');
    process.exit(1);
  }

  const types = $('script[type="application/ld+json"]')
    .map((_, el) => JSON.parse($(el).text())['@type'])
    .get();
  if (!types.includes('Product')) {
    console.error('ERROR: no Product schema on the product page');
    process.exit(1);
  }
}
```

The same tests work with a headless CMS. Deploy the frontend to staging and test the rendered HTML; the tests do not care where the content comes from.

For pages behind a login, sign in with Playwright first and store the session. For each locale, run a separate suite that validates hreflang tags. It also checks that each canonical points to the right language version. For personalization and A/B tests, turn personalization off in staging or send a cookie that returns the default version. Google crawls the default version, so test that version.

## Route alerts by severity

| Severity | Examples | Response |
|---|---|---|
| 1, critical | Indexed key URLs drop more than 20%, the home page does not return 200, the whole site is noindexed | Page the on-call engineer through Slack or PagerDuty at once |
| 2, high | Indexed key URLs drop 10 to 20%, Core Web Vitals fail on key pages, product pages return errors | Slack alert, fix within 4 hours |
| 3, medium | Organic traffic drops 15 to 25%, schema errors on some pages | Email alert, fix within 24 hours |
| 4, low | Minor meta tag issues, redirect chains on low-traffic pages | Log it, fix in the next sprint |

## Get engineering to adopt the tests

Present the tests as protection against lost revenue, not as process. Show past incidents with their cost. For example: "Last quarter, one deploy noindexed 1,000 pages. We lost a month of their organic traffic." Tests replace firefighting; they do not add work.

If the tests slow the pipeline too much, run the fast checks on each commit. Run the slow checks on staging deploys or each night. Cache crawl results and retest only the changed pages. Write each new SEO requirement as a story with a testable acceptance criterion. The [SEO user stories guide](/articles/seo-user-stories-engineering) shows the format.

----

```text
                  .|########||.                                       .|########||.                                       .|########||.
               |##||.      .||##|.                                 |##||.      .||##|.                                 |##||.      .||##|.
             |#|.              .|#|.                             |#|.              .|#|.                             |#|.              .|#|.
           |#|                    |#|                          |#|                    |#|                          |#|                    |#|
         .#|                        |#.                      .#|                        |#.                      .#|                        |#.
        .#.                          .#|                    .#.                          .#|                    .#.                          .#|
       |#.                            .#|                  |#.                            .#|                  |#.                            .#|
      |#             ......             #|                |#             ......             #|                |#             ......             #|
     .#           ||#########|           #|              .#           ||#########|           #|              .#           ||#########|           #|
    .#.         |######||######|.        .#.            .#.         |######||######|.        .#.            .#.         |######||######|.        .#.
    #.        .##|###|##|#|######|        .#            #.        .##|###|##|#|######|        .#            #.        .##|###|##|#|######|        .#
   ||        |##|#||||||||||||#||#|        ||          ||        |##|#||||||||||||#||#|        ||          ||        |##|#||||||||||||#||#|        ||
   #        |#||||||||||||||||||||#|        #.         #        |#||||||||||||||||||||#|        #.         #        |#||||||||||||||||||||#|        #.
  ||       |#||||||||||||||||||||||#|       ||        ||       |#||||||||||||||||||||||#|       ||        ||       |#||||||||||||||||||||||#|       ||
  #       .#||||||||||||||||||||||||#|       #        #       .#||||||||||||||||||||||||#|       #        #       .#||||||||||||||||||||||||#|       #
 ||   ....|||#||||||##|#|||#|#||##||||....|. ||      ||   ....|||#||||||##|#|||#|#||##||||....|. ||      ||   ....|||#||||||##|#|||#|#||##||||....|. ||
 #.  .  ....|#  ....#|||   ||| .#|||  ....#. .#      #.  .  ....|#  ....#|||   ||| .#|||  ....#. .#      #.  .  ....|#  ....#|||   ||| .#|||  ....#. .#
 #   .  ||||#| .#####||  . .#| .#|||  ||||#   #.     #   .  ||||#| .#####||  . .#| .#|||  ||||#   #.     #   .  ||||#| .#####||  . .#| .#|||  ||||#   #.
.|   |||||  #. |#|||||. ||  #. |#||. .|||||   ||    .|   |||||  #. |#|||||. ||  #. |#||. .|||||   ||    .|   |||||  #. |#|||||. ||  #. |#||. .|||||   ||
||   |....  #. ....|#.      |. ...|. ....||   ||    ||   |....  #. ....|#.      |. ...|. ....||   ||    ||   |....  #. ....|#.      |. ...|. ....||   ||
#.  .||||||##||||||##||####|||||||#||||||#|   .#    #.  .||||||##||||||##||####|||||||#||||||#|   .#    #.  .||||||##||||||##||####|||||||#||||||#|   .#
#    .||####|###########################|.     #    #    .||####|###########################|.     #    #    .||####|###########################|.     #
#      .#||||||.#.|| # |. ..# #| #|||||#|      #    #      .#||||||.#.|| # |. ..# #| #|||||#|      #    #      .#||||||.#.|| # |. ..# #| #|||||#|      #
#      .#|||||| ..  |# ## |#| ...#|#|||#|      #    #      .#|||||| ..  |# ## |#| ...#|#|||#|      #    #      .#|||||| ..  |# ## |#| ...#|#|||#|      #
#      .#|||#|# .# .#| #| ##|.#..#|||||#|      #    #      .#|||#|# .# .#| #| ##|.#..#|||||#|      #    #      .#|||#|# .# .#| #| ##|.#..#|||||#|      #
#      .#||||||############|######|||||#|      #    #      .#||||||############|######|||||#|      #    #      .#||||||############|######|||||#|      #
#   |...||#||||||#||||#|#||||||#|||||#||| |.|  #    #   |...||#||||||#||||#|#||||||#|||||#||| |.|  #    #   |...||#||||||#||||#|#||||||#|||||#||| |.|  #
#. .. ||||# .|||##|.  |#| .|| || .|||#. #|  # .#    #. .. ||||# .|||##|.  |#| .|| || .|||#. #|  # .#    #. .. ||||# .|||##|.  |#| .|| || .|||#. #|  # .#
|| |  ...||  ...##| |  #| .|. |. #####  .. .| ||    || |  ...||  ...##| |  #| .|. |. #####  .. .| ||    || |  ...||  ...##| |  #| .|. |. #####  .. .| ||
|| ||||| || ||||#|  .  |. |. |#. ||||| |#| || ||    || ||||| || ||||#|  .  |. |. |#. ||||| |#| || ||    || ||||| || ||||#|  .  |. |. |#. ||||| |#| || ||
.# |.....#|....|#.||||.|||##.|#|....||.#||.#. #.    .# |.....#|....|#.||||.|||##.|#|....||.#||.#. #.    .# |.....#|....|#.||||.|||##.|#|....||.#||.#. #.
 #.|||||||#############################| ||| .#      #.|||||||#############################| ||| .#      #.|||||||#############################| ||| .#
 ||       ##||#||#||||||#||#||#||#||##.      ||      ||       ##||#||#||||||#||#||#||#||##.      ||      ||       ##||#||#||||||#||#||#||#||##.      ||
  #       .#|||||||||#||#|||||||||||#|       #        #       .#|||||||||#||#|||||||||||#|       #        #       .#|||||||||#||#|||||||||||#|       #
  ||       |#||||||||||||||||||||||#|       ||        ||       |#||||||||||||||||||||||#|       ||        ||       |#||||||||||||||||||||||#|       ||
  .#        |#||||||||||||||||||||#|        #.        .#        |#||||||||||||||||||||#|        #.        .#        |#||||||||||||||||||||#|        #.
   ||        |#||||||||||||||||||#|        ||          ||        |#||||||||||||||||||#|        ||          ||        |#||||||||||||||||||#|        ||
    #.        .######|#|#########|        .#            #.        .######|#|#########|        .#            #.        .######|#|#########|        .#
    .#.         |#####||||#####|         .#.            .#.         |#####||||#####|         .#.            .#.         |#####||||#####|         .#.
     |#           ||########||           #.              |#           ||########||           #.              |#           ||########||           #.
      |#             ......             #|                |#             ......             #|                |#             ......             #|
       |#.                            .#|                  |#.                            .#|                  |#.                            .#|
        |#.                          .#.                    |#.                          .#.                    |#.                          .#.
         .#|                        |#.                      .#|                        |#.                      .#|                        |#.
           |#|                    |#|                          |#|                    |#|                          |#|                    |#|
            .|#|.              .|#|.                            .|#|.              .|#|.                            .|#|.              .|#|.
               |##||.      .||##|                                  |##||.      .||##|                                  |##||.      .||##|
                 .||########||.                                      .||########||.                                      .||########||.

Scale With Search  2026  [scalewithsearch.com](https://scalewithsearch.com)
```
