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-coreconst { 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-ciSingle page:
pa11y https://example.com --standard WCAG2AA --reporter cliConfig 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 .pa11yciGitHub 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 failingCustom 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);
}Internal Link Checker
// 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.jsReporting 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.