Setting Up axe-core in a Next.js Turborepo Monorepo
Sharing one accessibility configuration across many Next.js apps in a pnpm + Turborepo workspace is harder than dropping a single test file into one repo. Each app needs the same rule set, the same WCAG 2.2 tags, and the same severity gating, but you do not want to copy the setup five times. This guide is part of axe-core Configuration & Setup, and it shows how to build a shared @acme/a11y-config package, consume it from every app’s Playwright suite, and register the a11y task in turbo.json so unchanged apps are skipped via the cache.
Key implementation targets:
- One shared package exports the axe rule set; every app imports it — no copy-paste drift.
- Per-app Playwright scripts run the same scan with app-specific URLs.
turbo.jsoncaches thetest:a11ytask so only changed apps re-scan.
Root Cause / Context
The usual failure mode in a monorepo is configuration drift. One team disables color-contrast to silence a noisy banner, another forgets to add the WCAG 2.2 tags, and within a quarter no two apps gate on the same rules. The fix is to make the rule set a versioned dependency rather than inline test code. Because Playwright and axe-core both run in Node, a plain ESM package that exports an options object is enough — every app imports the same axeOptions and the same runScan helper, so a rule change is one commit in one package.
The second problem is wasted CI minutes. Without task graph awareness, every push re-scans every app even when only one changed. Turborepo solves this by hashing each task’s declared inputs (source files, the shared config, lockfile) and caching the result; an app whose inputs are identical to a previous run is restored from cache instead of re-run.
Configuration
The workspace uses pnpm workspaces. Declare the apps and packages in pnpm-workspace.yaml:
# pnpm-workspace.yaml
packages:
- "apps/*" # Next.js applications
- "packages/*" # shared libraries incl. a11y-config
The shared package exports the axe options and a reusable scan helper. It depends on @axe-core/playwright so every app inherits the same axe-core version:
// packages/a11y-config/package.json
{
"name": "@acme/a11y-config",
"version": "1.0.0",
"type": "module",
"exports": { ".": "./src/index.js" },
"dependencies": {
"@axe-core/playwright": "^4.10.0"
}
}
// packages/a11y-config/src/index.js
import { AxeBuilder } from '@axe-core/playwright';
// One rule set every app shares. WCAG 2.2 AA tags only.
export const axeTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
// Severities that fail CI. 'minor'/'moderate' are reported but do not gate.
const GATING_IMPACTS = new Set(['serious', 'critical']);
// Scan the current page and return only gating violations.
export async function runScan(page, { include } = {}) {
let builder = new AxeBuilder({ page }).withTags(axeTags);
if (include) builder = builder.include(include); // optional scoping selector
const results = await builder.analyze();
return results.violations.filter((v) => GATING_IMPACTS.has(v.impact));
}
Each Next.js app adds the shared package as a workspace dependency and a Playwright config that boots its own dev server:
// apps/web/package.json (excerpt)
{
"name": "@acme/web",
"scripts": {
"test:a11y": "playwright test --config=playwright.a11y.config.ts"
},
"devDependencies": {
"@acme/a11y-config": "workspace:*",
"@playwright/test": "^1.48.0"
}
}
// apps/web/playwright.a11y.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/a11y',
reporter: [['json', { outputFile: 'a11y-report.json' }]], // CI artifact
webServer: {
command: 'pnpm next start -p 3100', // serve the built app
url: 'http://localhost:3100',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: { baseURL: 'http://localhost:3100' },
});
The app’s test imports the shared helper, so the rule set lives in exactly one place:
// apps/web/tests/a11y/home.spec.ts
import { test, expect } from '@playwright/test';
import { runScan } from '@acme/a11y-config';
const routes = ['/', '/pricing', '/login']; // app-specific URLs
for (const route of routes) {
test(`a11y: ${route}`, async ({ page }) => {
await page.goto(route);
await page.waitForLoadState('networkidle'); // let client render settle
const violations = await runScan(page);
expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);
});
}
Finally, register the task in turbo.json. Declaring the shared config as an input means changing a rule invalidates every app’s cache; changing only apps/admin leaves apps/web cached:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"] },
"test:a11y": {
"dependsOn": ["build"], // scan the built app, not dev source
"inputs": ["src/**", "app/**", "tests/a11y/**"],
"outputs": ["a11y-report.json"], // cache the report as the task output
"env": ["CI"]
}
}
}
Run the whole workspace with pnpm turbo run test:a11y. Turbo builds the dependency graph, runs the scan only for affected apps, and restores reports for the rest from cache.
Validation
Confirm the cache works by running twice with no source changes. The second run should report full cache hits and finish in well under a second:
pnpm turbo run test:a11y # first run: executes scans, populates cache
pnpm turbo run test:a11y # second run: should be FULL TURBO
# Expected tail of the second run:
# Tasks: 2 successful, 2 total
# Cached: 2 cached, 2 total
# Time: 180ms >>> FULL TURBO
To prove the shared config is authoritative, edit packages/a11y-config/src/index.js (add a tag), then re-run. Because the package is in the dependency graph, every dependent app’s cache is invalidated and re-scanned, while editing a single app invalidates only that app.
Edge Cases & Conditional Guards
- Client-side hydration:
waitForLoadState('networkidle')is not always enough for streamed React Server Components. Wait on a stable selector (page.getByRole('main')) before callingrunScanso axe does not snapshot a mid-hydration tree. - Per-app overrides: if one app genuinely needs a rule disabled, pass an override into
runScanrather than forking the package — keep the disable list in the app’s test so the shared default stays strict. - Lockfile as an input: add
pnpm-lock.yamlto the global inputs so an axe-core version bump busts every app’s cache; otherwise a cached run can mask a rule-set change.
Pipeline Impact
In CI the gate is the Playwright exit code: any gating violation fails the test:a11y task and, through Turbo, the overall run. The a11y-report.json per app is both the cached output and the uploadable artifact, so a failing PR carries the exact node selectors. Because Turbo only re-runs affected apps, the accessibility gate costs near-zero CI minutes on PRs that touch unrelated packages, which keeps the check fast enough to be required rather than advisory.
Common Pitfalls
- Forgetting
workspace:*on@acme/a11y-config, which pulls a stale published version instead of the local source. - Listing the shared package only as a runtime dep so Turbo does not treat it as a task input, leaving caches stale after a rule change.
- Running the scan against
next devinstead ofnext start, which surfaces dev-only overlay nodes as violations. - Caching
node_modulesor.next/cacheas task outputs, which bloats the cache and causes spurious restores. - Gating on
moderateimpacts without a remediation budget, which floods PRs and trains reviewers to ignore the check.
FAQ
Should I use jest-axe instead of @axe-core/playwright here?
Use jest-axe when you scan rendered component markup in a jsdom unit test and @axe-core/playwright when you scan a real running app in a browser. In a Next.js monorepo most teams want both: jest-axe in component packages and Playwright at the app level. The shared config pattern is identical — export the rule set and import it in both runners.
Why scan the built app rather than the dev server?
next dev injects an error overlay and unminified development-only DOM that axe flags as violations, producing noise that never ships. Declaring dependsOn: ["build"] and serving with next start scans the artifact your users actually receive.
Does Turbo caching ever hide a real regression?
Only if your inputs are wrong. If every file that can change the scan result (source, tests, shared config, lockfile) is a declared input, a cache hit is a genuine no-change result. Audit the inputs list whenever you add a new source directory.
Related
- axe-core Configuration & Setup — the parent section covering base axe-core wiring and tags.
- How to Configure axe-core for React and Vue Applications — framework-specific setup that the shared package builds on.
- Reducing False Positives in Automated Accessibility Scanners — tune the shared rule set before rolling it across apps.