Most accessibility checkers are either "paste your HTML" tools or paid enterprise platforms. I needed something that runs on a real rendered page — not source HTML — and works automatically across dozens of sites.
Here's exactly how I built it using Browserless + axe-core, running inside Next.js serverless functions.
Why not just run axe-core locally?
The obvious approach: npm install axe-core, fetch the HTML, run the scan. The problem is that modern sites don't work that way.
A React or Next.js app returns a mostly empty shell from a plain fetch(). The actual DOM — the nav, the forms, the modals — only exists after JavaScript runs. If you scan the raw HTML you'll miss 60–80% of the real violations.
You need a real browser.
The second constraint: this runs inside Netlify serverless functions. You can't spin up Puppeteer or Playwright inside a serverless function — no persistent processes, no Chromium binary, 50MB package size limit.
Solution: Browserless — a self-hosted headless Chromium service you talk to over HTTP. Your function sends a script, Browserless executes it inside a real browser, returns the result. No Puppeteer dependency in your app at all.
The architecture
User triggers scan
→ POST /api/sites/:id/check-accessibility
→ returns { scanId } immediately (non-blocking)
→ background: runAxeScan(url) → analyzeAxeWithAI(result)
→ result stored in in-process Map
User polls
→ GET /api/sites/:id/check-accessibility?scanId=xxx
→ returns { status: 'pending' } or { status: 'done', result }
The scan takes 15–45 seconds depending on the site. Returning a scanId immediately keeps the HTTP response fast — serverless functions have a 15s limit, the scan itself can run async in the background.
Sending axe-core into Browserless
Browserless has a /function endpoint that accepts a JavaScript module. The module gets a page (Puppeteer Page) and context (your custom data):
const AXE_SCRIPT = /* js */ `
export default async ({ page, context }) => {
// Navigate — try networkidle2 first, fall back to load
try {
await page.goto(context.url, { waitUntil: 'networkidle2', timeout: 28000 });
} catch {
await page.goto(context.url, { waitUntil: 'load', timeout: 15000 });
}
// Inject axe-core from CDN into the live page
await page.addScriptTag({
url: 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js'
});
// Wait for any lazy-loaded content to settle
await new Promise(r => setTimeout(r, 800));
// Run the scan inside the browser context
const raw = await page.evaluate(async () => {
const res = await window.axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'best-practice'] },
reporter: 'v2',
});
return {
violations: res.violations.map(v => ({
id: v.id,
impact: v.impact, // 'critical' | 'serious' | 'moderate' | 'minor'
description: v.description,
help: v.help,
helpUrl: v.helpUrl,
nodes: v.nodes.slice(0, 3).map(n => ({
html: n.html.slice(0, 300),
failureSummary: n.failureSummary,
})),
})),
passes: res.passes.length,
incomplete: res.incomplete.length,
inapplicable: res.inapplicable.length,
};
});
return { data: raw, type: 'application/json' };
};
The key insight: page.addScriptTag() injects axe-core into the live page context — the real DOM, after all JavaScript has executed. Then page.evaluate() runs the scan inside that same context and serializes the result back to Node.
The function call from the serverless side:
async function runBrowserFunction<T>(script: string, context: object): Promise<T | null> {
const res = await fetch(`${BROWSERLESS_URL}/function?token=${TOKEN}&launch=${LAUNCH_FLAGS}`, {
method: 'POST',
headers: { 'Content-Type': 'application/javascript' },
body: script,
// Pass context as a header — Browserless reads it and injects as `context`
// Actually: embed context in the script or use /function with JSON body
})
if (!res.ok) return null
return (await res.json()) as T
}
The networkidle2 fallback
The first goto waits for networkidle2 — no more than 2 open network connections for 500ms. This catches lazy-loaded images, deferred scripts, async API calls.
But marketing sites with live chat widgets, analytics pings, or polling intervals never reach network idle. The timeout fires after 28s and we catch it:
try {
await page.goto(context.url, { waitUntil: 'networkidle2', timeout: 28000 });
} catch {
// Site has persistent network activity — scan what loaded
await page.goto(context.url, { waitUntil: 'load', timeout: 15000 });
}
The 800ms sleep after injection lets IntersectionObserver-triggered content render. Without it, elements that appear on scroll (common on landing pages) are missing from the DOM when axe-core runs.
What axe-core returns
A violations array with structured data:
{
"id": "html-has-lang",
"impact": "serious",
"help": "Ensure every HTML document has a lang attribute",
"nodes": [{
"html": "<html>",
"failureSummary": "Fix any of the following: The <html> element does not have a lang attribute"
}]
}
Each violation maps to a WCAG criterion. Impact levels: critical → serious → moderate → minor.
AI analysis layer
Raw axe output is useful for developers. It's not useful for showing to clients.
I pass the top 12 violations through Claude Haiku and ask for:
- Risk score (0–100) based on severity and count
- Plain-English summary of what's actually broken
- Legal exposure — EAA (EU) or ADA (US) depending on jurisdiction
- Priority action list ordered by impact/fix effort ratio Haiku is fast (< 2s) and cheap enough to run on every scan. The prompt returns strict JSON so there's no parsing ambiguity:
const prompt = `You are an accessibility expert. Analyze these axe-core WCAG 2.1 AA violations for ${url} and return ONLY a JSON object with shape: { riskScore, riskLevel, summary, legalRisk, topIssues, priorityActions }`
If the AI call fails, the fallback calculates a basic risk score from raw violation counts — the scan never completely breaks.
Hosting Browserless
I self-host Browserless on Fly.io. A single fly-1x-shared-cpu-1gb machine runs fine for a monitoring tool with async scans. Cost: ~$5–10/month.
The launch flags are required or Chrome hangs in the container:
--no-sandbox
--disable-dev-shm-usage ← /dev/shm is tiny on Fly, Chrome OOM-crashes without this
--disable-gpu
Result
The full pipeline — navigate, inject axe-core, scan, AI analysis — runs in 15–45 seconds depending on site complexity. The UI polls every 4 seconds and shows progress.
Running this across 30 client sites found that 22/30 had critical or high-risk WCAG violations. The most common: missing lang on , images without alt, form inputs without labels.
The free public version is at sitebrief.net/audit — paste any URL, get the full report.
Happy to answer questions about the Browserless setup or the axe-core integration.
Top comments (0)