Automated SEO and Accessibility Checks: Beyond Lighthouse

Automated SEO and Accessibility Checks: Beyond Lighthouse

Lighthouse catches a useful set of SEO and accessibility issues, but it misses a lot. This guide covers the tools and techniques that go deeper: axe-core for WCAG compliance, Pa11y for CI integration, custom Playwright assertions for your specific patterns, and how to combine them into a coherent automated check suite.

What Lighthouse Misses

Lighthouse's accessibility audit scores roughly 10–15% of WCAG success criteria automatically. It catches:

  • Missing alt text on images
  • Form elements without labels
  • Missing document language
  • Color contrast failures (for visible text only)
  • Missing ARIA roles for some patterns

It doesn't catch:

  • Keyboard navigation traps
  • Focus order issues
  • Dynamic content accessibility (ARIA live regions)
  • Complex widget patterns (custom selects, date pickers)
  • Content that becomes inaccessible after JavaScript runs
  • Most WCAG 2.1 AA criteria beyond the basics

For SEO, Lighthouse checks title/description presence but misses duplicate meta descriptions across pages, thin content, keyword cannibalization, and internal link issues.

axe-core for WCAG Testing

axe-core (by Deque) tests against WCAG 2.1 AA and is the most widely used automated accessibility testing engine. It runs in the browser against the live DOM, catching issues that static analysis misses.

Playwright + axe-core:

npm install @axe-core/playwright axe-core
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;

test.describe('Accessibility — Core pages', () => {
  test('homepage has no critical accessibility violations', async ({ page }) => {
    await page.goto('https://example.com');
    
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze();
    
    // Filter to violations only (not incomplete/needs-review)
    const violations = results.violations;
    
    if (violations.length > 0) {
      const report = violations.map(v => ({
        rule: v.id,
        impact: v.impact,
        description: v.description,
        nodes: v.nodes.length,
        selector: v.nodes[0]?.target?.join(' '),
      }));
      
      console.log('Violations:', JSON.stringify(report, null, 2));
    }
    
    // Block on critical and serious violations
    const criticalViolations = violations.filter(v =>
      ['critical', 'serious'].includes(v.impact)
    );
    
    expect(criticalViolations).toHaveLength(0);
  });

  test('product page accessible after dynamic content loads', async ({ page }) => {
    await page.goto('https://example.com/products/widget');
    
    // Wait for any dynamic content
    await page.waitForSelector('.product-details', { state: 'visible' });
    
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .exclude('.third-party-widget') // Exclude known third-party violations
      .analyze();
    
    expect(results.violations).toHaveLength(0);
  });

  test('modal is accessible when open', async ({ page }) => {
    await page.goto('https://example.com');
    await page.click('[data-testid="open-modal"]');
    await page.waitForSelector('[role="dialog"]', { state: 'visible' });
    
    const results = await new AxeBuilder({ page })
      .include('[role="dialog"]')
      .analyze();
    
    expect(results.violations).toHaveLength(0);
  });
});

Handling known violations:

const knownIssues = require('./known-a11y-issues.json');

test('no new accessibility violations', async ({ page }) => {
  await page.goto('https://example.com');
  
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2aa'])
    .analyze();
  
  // Filter out known issues (tracked for fixing)
  const newViolations = results.violations.filter(v =>
    !knownIssues.some(known => known.id === v.id && known.page === '/')
  );
  
  if (newViolations.length > 0) {
    // Log for reporting
    console.error('New violations:', JSON.stringify(newViolations, null, 2));
  }
  
  expect(newViolations).toHaveLength(0);
});

Pa11y for Bulk Page Testing

Pa11y is designed for testing many pages efficiently:

npm install -g pa11y pa11y-ci

Single page:

pa11y https://example.com --standard WCAG2AA --reporter cli

Config file for multiple pages:

// .pa11yci
{
  "defaults": {
    "standard": "WCAG2AA",
    "timeout": 30000,
    "wait": 1000,
    "chromeLaunchConfig": {
      "args": ["--no-sandbox", "--disable-setuid-sandbox"]
    }
  },
  "urls": [
    "https://example.com",
    "https://example.com/products",
    "https://example.com/about",
    {
      "url": "https://example.com/checkout",
      "actions": [
        "click element #add-to-cart",
        "wait for element .cart-total to be visible"
      ]
    }
  ]
}
pa11y-ci --config .pa11yci

GitHub Actions:

- name: Run Pa11y accessibility tests
  run: |
    npm install -g pa11y-ci
    pa11y-ci --config .pa11yci --threshold 5
  # --threshold allows up to N errors before failing

Custom Keyboard Navigation Tests

Automated tools can't fully test keyboard accessibility, but Playwright can test the basics:

test('navigation is keyboard accessible', async ({ page }) => {
  await page.goto('https://example.com');
  
  // Skip to main content link should be first focusable element
  await page.keyboard.press('Tab');
  const firstFocused = await page.evaluate(() => document.activeElement?.textContent?.trim());
  
  // Many sites have a "Skip to main content" link
  // If yours does, validate it
  if (firstFocused?.toLowerCase().includes('skip')) {
    await page.keyboard.press('Enter');
    
    // Focus should jump to main content
    const mainFocused = await page.evaluate(() =>
      document.activeElement?.tagName?.toLowerCase()
    );
    expect(['main', 'h1', '[role="main"]']).toContain(mainFocused);
  }
});

test('modal traps focus correctly', async ({ page }) => {
  await page.goto('https://example.com');
  await page.click('[data-testid="open-modal"]');
  await page.waitForSelector('[role="dialog"]');
  
  // Tab through all focusable elements in modal
  const focusedElements = [];
  for (let i = 0; i < 20; i++) {
    await page.keyboard.press('Tab');
    const focused = await page.evaluate(() => ({
      tag: document.activeElement?.tagName,
      role: document.activeElement?.getAttribute('role'),
      inModal: document.activeElement?.closest('[role="dialog"]') !== null,
    }));
    focusedElements.push(focused);
    
    if (focused.tag === focusedElements[0].tag && i > 0) break; // Cycle complete
  }
  
  // All focused elements should be inside the modal
  const outsideModal = focusedElements.filter(el => !el.inModal);
  expect(outsideModal).toHaveLength(0);
  
  // Escape should close modal
  await page.keyboard.press('Escape');
  await expect(page.locator('[role="dialog"]')).toBeHidden();
});

test('dropdown menu accessible via keyboard', async ({ page }) => {
  await page.goto('https://example.com');
  
  // Find and focus the dropdown trigger
  await page.focus('[data-testid="main-nav-trigger"]');
  
  // Open with Enter or Space
  await page.keyboard.press('Enter');
  await expect(page.locator('[data-testid="main-nav-menu"]')).toBeVisible();
  
  // Navigate items with arrow keys
  await page.keyboard.press('ArrowDown');
  const focusedItem = await page.evaluate(() =>
    document.activeElement?.textContent?.trim()
  );
  expect(focusedItem).toBeTruthy();
  
  // Close with Escape
  await page.keyboard.press('Escape');
  await expect(page.locator('[data-testid="main-nav-menu"]')).toBeHidden();
});

Automated SEO Checks Beyond Lighthouse

Duplicate Title and Meta Description Detection

// check-duplicates.js
const { chromium } = require('playwright');

async function checkDuplicateMeta(urls) {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  
  const titles = new Map();
  const descriptions = new Map();
  const issues = [];
  
  for (const url of urls) {
    await page.goto(url);
    
    const title = await page.title();
    const description = await page.$eval(
      'meta[name="description"]',
      el => el.content
    ).catch(() => null);
    
    if (titles.has(title)) {
      issues.push({ type: 'duplicate-title', url, duplicateOf: titles.get(title), value: title });
    } else {
      titles.set(title, url);
    }
    
    if (description && descriptions.has(description)) {
      issues.push({ type: 'duplicate-description', url, duplicateOf: descriptions.get(description), value: description });
    } else if (description) {
      descriptions.set(description, url);
    } else {
      issues.push({ type: 'missing-description', url });
    }
  }
  
  await browser.close();
  return issues;
}

// Read URLs from sitemap
const urls = await getSitemapUrls('https://example.com/sitemap.xml');
const issues = await checkDuplicateMeta(urls);

if (issues.length > 0) {
  console.error('SEO issues found:', JSON.stringify(issues, null, 2));
  process.exit(1);
}
// check-links.js
async function checkInternalLinks(startUrl) {
  const domain = new URL(startUrl).hostname;
  const visited = new Set();
  const broken = [];
  const queue = [startUrl];
  
  while (queue.length > 0) {
    const url = queue.shift();
    if (visited.has(url)) continue;
    visited.add(url);
    
    try {
      const response = await fetch(url);
      
      if (!response.ok) {
        broken.push({ url, status: response.status, referrer: 'N/A' });
        continue;
      }
      
      const html = await response.text();
      
      // Extract all links
      const links = [...html.matchAll(/href="([^"]+)"/g)]
        .map(m => m[1])
        .filter(href => !href.startsWith('#') && !href.startsWith('mailto:') && !href.startsWith('tel:'))
        .map(href => {
          try {
            return new URL(href, url).href;
          } catch {
            return null;
          }
        })
        .filter(Boolean)
        .filter(link => new URL(link).hostname === domain);
      
      for (const link of links) {
        if (!visited.has(link) && !queue.includes(link)) {
          queue.push(link);
        }
      }
    } catch (error) {
      broken.push({ url, error: error.message });
    }
    
    // Rate limit
    await new Promise(resolve => setTimeout(resolve, 200));
  }
  
  return { checked: visited.size, broken };
}

Thin Content Detection

test('pages have sufficient content', async ({ page }) => {
  const urls = [
    'https://example.com',
    'https://example.com/about',
    'https://example.com/products',
  ];
  
  for (const url of urls) {
    await page.goto(url);
    
    // Get main content word count (exclude nav, header, footer)
    const wordCount = await page.evaluate(() => {
      const main = document.querySelector('main, [role="main"], article, .content');
      if (!main) return 0;
      
      return main.innerText
        .split(/\s+/)
        .filter(w => w.length > 2)
        .length;
    });
    
    expect(wordCount, `${url} has only ${wordCount} words`).toBeGreaterThan(200);
  }
});

Combining Checks in a Single Pipeline

A practical CI configuration that runs accessibility and SEO checks together:

# .github/workflows/seo-a11y.yml
name: SEO & Accessibility
on: [push, pull_request]

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      
      - run: npm ci
      - run: npm run build
      - name: Start server
        run: npx serve dist -p 3000 &
      - name: Wait for server
        run: npx wait-on http://localhost:3000
      
      # Lighthouse (performance + SEO)
      - name: Lighthouse CI
        run: npx lhci autorun --config=.lighthouserc.json
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
      
      # axe-core (deep accessibility)
      - name: Playwright accessibility tests
        run: npx playwright test tests/accessibility/
      
      # Pa11y (WCAG bulk check)
      - name: Pa11y
        run: npx pa11y-ci --config .pa11yci --threshold 0
      
      # Custom SEO checks
      - name: SEO checks
        run: node scripts/check-seo.js

Reporting and Tracking

Don't just fail CI — report findings in a way that developers can act on:

// generate-report.js
const { AxeBuilder } = require('@axe-core/playwright');
const { chromium } = require('playwright');

async function generateA11yReport(urls) {
  const browser = await chromium.launch();
  const report = { timestamp: new Date().toISOString(), pages: [] };
  
  for (const url of urls) {
    const page = await browser.newPage();
    await page.goto(url);
    
    const results = await new AxeBuilder({ page }).withTags(['wcag2aa']).analyze();
    
    report.pages.push({
      url,
      violations: results.violations.map(v => ({
        id: v.id,
        impact: v.impact,
        description: v.description,
        help: v.help,
        helpUrl: v.helpUrl,
        nodes: v.nodes.map(n => ({
          html: n.html.substring(0, 200),
          selector: n.target.join(' '),
          failureSummary: n.failureSummary,
        })),
      })),
    });
    
    await page.close();
  }
  
  await browser.close();
  
  // Write HTML report
  const html = generateHtmlReport(report);
  require('fs').writeFileSync('a11y-report.html', html);
  
  return report;
}

Upload the report as a CI artifact so developers can review specific failures without re-running locally.

The key insight: automated accessibility and SEO testing is not about catching everything — it's about catching regressions automatically so they never reach production. A tool that catches 30% of WCAG issues consistently on every PR is worth more than a comprehensive manual audit done once a quarter.

Read more

Start now free