I Published a WCAG Audit CLI. Then It Became the Engine of a SaaS.
My first npm package is @belikenikola/wcag-audit. It is not clever. It loads a URL in headless Chromium, runs axe-core against WCAG 2.1, and turns the result into a score, a ranked to-do list, and an exit code. That's it.
It started as a script I ran for clients who had received accessibility demand letters, became a CLI because I was tired of copying the script around, and then became the engine of Alfa Audit because the people who most needed the audit could not run a terminal. This post is the engineering story, plus the practical bits: what the tool can and cannot prove, and how to put it in CI without it blocking every merge.
What's inside
Three dependencies do the work:
- Playwright opens the page in a real Chromium, waits for network idle, and gives axe a live DOM. Static HTML parsing can't see computed colour contrast, focus order, or anything JavaScript renders; a browser can.
- axe-core is the rules engine. It runs with tag filters per level:
wcag2aandwcag21afor A; addwcag2aa,wcag21aaandwcag22aafor AA; add the AAA tags on top. axe is deliberately conservative. It only reports what it can prove, which is why its false-positive rate is low and why it covers only about half of the WCAG criteria. The other half needs a human. - A to-do builder of my own. Raw axe output is a list of violations with node selectors. What a team needs is a list ordered by what to fix first. Each item gets a priority from impact and effort, the task in plain words, the WCAG criteria, the number of affected elements, and an effort estimate.
Then a score (0 to 100, weighted by impact), three report formats (JSON, HTML, Markdown), and exit codes: 0 clean, 1 violations found, 2 login wall, 3 bad input, 4 something broke.
The login-wall detection deserves a sentence. Audit tools love to "pass" a login page with a perfect score because a login form has very little to get wrong. The CLI checks the HTTP status, redirects, form fields and page text, and refuses to pretend it audited your app when it only saw the front door.
From script to library
The interesting decision was making it a library, not only a binary.
When I started building Alfa Audit, the obvious move was to copy the audit code into the SaaS. I had done that before on other projects and regretted it every time: two copies, two sets of bugs, and a CLI that silently fell behind. So the package exposes the functions the CLI is built on:
import { runAudit, buildTodoList, generateReport, shutdown } from '@belikenikola/wcag-audit';
const result = await runAudit('https://example.com', { level: 'AA' });
const todos = buildTodoList(result.violations);
const html = generateReport(result, 'html');
await shutdown();
The SaaS npm installs the same package the public uses. When I fix a rule mapping, both get it. The CLI is the engine; the SaaS is the dashboard.
The serverless problem
Alfa Audit runs on Vercel. A Vercel function has a size budget, and full Playwright ships a browser that is far larger than that. The fix is well known in the Lambda world: playwright-core (the driver without browsers) plus @sparticuz/chromium (a Chromium build trimmed for serverless).
The CLI needed one new seam for that: a pluggable browser launcher.
import { setBrowserLauncher, runAudit, shutdown } from '@belikenikola/wcag-audit';
import { chromium } from 'playwright-core';
import chromiumBin from '@sparticuz/chromium';
setBrowserLauncher(async () =>
chromium.launch({
args: chromiumBin.args,
executablePath: await chromiumBin.executablePath(),
headless: true,
})
);
const result = await runAudit('https://example.com');
await shutdown();
On a laptop, the default launcher lazily imports full Playwright with its matched Chromium. In the function, the SaaS injects the trimmed one. The audit code doesn't know or care.
Two things bit me on the first deploy, in case they bite you:
- Version pairing.
@sparticuz/chromiumbuilds track specific Chromium versions, andplaywright-coreexpects a compatible one. Pin them together and test the pair on the first deploy; the local run tells you nothing about this. - Runtime file reads.
playwright-corereadsbrowsers.jsonand@sparticuz/chromiumreads its binary at runtime through the filesystem, which Vercel's dependency tracer does not see. Add both packages tooutputFileTracingIncludesfor the audit route, or the function boots and then fails to find the browser.
With those two fixed, an audit of a typical page runs in four to ten seconds inside the function and well under the duration limit.
What it will not do
I'd rather you hear this from me than from a screen-reader user.
- It audits one URL. No crawler. If you want the whole site, loop over your sitemap.
- It covers roughly half of WCAG. Whether alt text is meaningful, whether the keyboard flow makes sense, whether a PDF is tagged: those need a person. The report marks items that need manual review; it does not hide them.
- It cannot audit behind a login yet. It tells you so with exit code 2 instead of lying.
- Dynamic SPAs sometimes need a longer timeout than the default 30 seconds.
A clean run means "axe found nothing it could prove." It does not mean the site conforms to WCAG 2.1 AA, and nobody should put a badge up on the strength of it alone.
Using it in CI without annoying everyone
The CLI exits 1 on any violation. That is the right default for a tool, and the wrong policy for most teams: one minor issue on a marketing page should not block a payment fix.
So let it write JSON and apply your own threshold:
- run: npx playwright install --with-deps chromium
- run: npx @belikenikola/wcag-audit "$PREVIEW_URL" --format json -o report.json || true
- run: |
node -e '
const r = require("./report.json");
const bad = r.violations.filter(v => ["critical","serious"].includes(v.impact));
console.log(`score ${r.summary.score}/100, ${bad.length} critical/serious`);
process.exit(bad.length ? 1 : 0);
'
Start by failing only on critical and serious. Once the backlog is empty, tighten to moderate. Keep report.json as an artifact so a failed check comes with the list of what to fix, not just a red X.
The full CLI reference has the options, exit codes and a complete GitHub Actions job.
Why give the engine away
Because the engine was never the product. Developers who want a CLI will find one, and if mine is good they'll use it and tell the non-developer next to them. That person wants a URL box, a weekly re-run, a score that goes up over time, and a report they can forward. That is Alfa Audit, and it is built on exactly the code you can npm install today.
If you try it and something is wrong, the issue tracker is open. If you'd rather talk, the Inner Circle has a free consultation form and I read every one.



