Adopt it in a Next.js app

Router detection, the derived provider.tsx and its fixtures, swagger.json plus MSW handlers from your API routes, --swagger-url and --api, the merged devDependencies and flawless:* scripts, and the .gitignore block.

Goal: get a runnable suite into an existing Next.js app — under flawless/, wired into that app’s own package.json, with no second npm project and no duplicated dependency tree.

--nextjs [path] — the path defaults to the current directory — turns six decisions into detections. Each is a place a wrong guess would be silent, so each section names what was detected, and what happens when it is wrong.

The smallest run that works

cd my-next-app
bunx flawless-react app/page.tsx --nextjs

Not a dry run: it writes into your project, edits your package.json and appends to your .gitignore. On an uncommitted repo, branch first.

What you’ll see

nextjs: /tmp/nextdemo (router=app, api-routes=2, app-resolver=/tmp/nextdemo/src/app/layout.tsx)
nextjs: app resolver is a server layout — derived flawless/provider.tsx (providers: Providers)
nextjs: wrote 5 mock file(s) under flawless/mocks/
nextjs: merged dev deps + flawless:* scripts into /tmp/nextdemo/package.json
nextjs: added 10 entry(ies) to .gitignore (flawless/reports/, flawless/heaps/, …, *.flawless.json)

Summary:
  [ok] unit-browser   files:3 findings:0  unit tests for 1 component(s): HomePage
  [ok] perf-static    files:1 findings:0  static perf analysis: 0 finding(s) across 1 root(s)

Read line one as a receipt: router=app says which directory won, api-routes=2 is how many route files the walk parsed (a zero means empty mocks), and app-resolver=… names the file it will wrap components in.

Router detection — first hit wins

Four directories are checked in order and the first that exists ends the search: app/, src/app/, pages/, src/pages/.

First hit wins is a decision, not a tie-break. A half-migrated app holding both is app-router, and the pages side goes invisible — API handlers included:

bunx flawless-react app/page.tsx --nextjs . --tasks perf-static --no-interactive
nextjs: /tmp/nextboth (router=app, api-routes=0, app-resolver=<none>)

api-routes=0 with a populated pages/api/ on disk is that failure: the walk only looks under the router root it chose, so pages/api/legacy.ts is never opened. There is no flag for the loser — mid-migration, pass --api with a spec covering both halves.

Two shapes make the run refuse rather than guess, both exit 2: does not look like a Next.js project (no next.config or "next" in package.json), and no app/ or pages/ directory under <path> — usually --nextjs aimed at a repo root.

The app resolver, and why it is usually not your layout

Under router=app the tool looks for layout.tsx, then .jsx, .ts, .js at the router root; under router=pages, for _app.tsx and the same three extensions. That file becomes the app resolver — the wrapper every mounted component renders inside, so context and CSS reach the test.

Then it usually refuses to use it. An app-router layout.tsx is typically an async server component importing next/font or next/headers, neither of which runs under jsdom, so mounting inside it fails every spec for reasons unrelated to the component. Instead the run derives a client-safe wrapper from the JSX chain enclosing {children}:

// flawless/provider.tsx — REGENERATED by --force
"use client";
export default function FlawlessProvider({ children }: { children: ReactNode }) {
  return <Providers theme="dark" fonts={providerFixtures.fonts}>{children}</Providers>;
}

Only "use client" components survive, and only literal attributes are copied. theme="dark" came across; fonts={inter} did not, because inter is a next/font call the wrapper cannot make — so instead of inventing a value the run leaves a typed hole beside it in provider.fixtures.ts, commented with the layout.tsx line it came from.

That split is the point: provider.tsx is regenerated, so a provider you add to layout.tsx next month reaches an existing suite through --force, while provider.fixtures.ts is write-once under every flag because its values are ones a human typed.

--app-resolver <file> overrides all of that — used verbatim, derivation skipped, no provider.tsx written. Note what the log does not say afterwards: line one still reads app-resolver=…/src/app/layout.tsx, reporting what was detected, not what is used. Aim it at something server-shaped and the run warns but obeys:

provider: src/app/layout.tsx looks like a server component (async or server-only imports) — unit specs may fail under jsdom

API routes become swagger.json + MSW handlers

The run walks the router’s api/ directory and emits flawless/swagger.json plus flawless/mocks/handlers.ts, the MSW handlers derived from it.

App router — every **/route.{ts,tsx,js,jsx,mjs} under app/api/. Methods come from the exported binding names (export async function GET, export const POST = …), and a file exporting none of the seven verbs is skipped. Segments map to path params: [id] becomes {id}, [...slug] and [[...slug]] both collapse to {slug}, and route groups drop out of the URL as Next.js drops them.

Pages router — every file under pages/api/, index stripped from the tail. Because a pages handler is one default export switching on req.method, the walk cannot read methods off the source and assumes all five — /api/users and /api/users/{id} both come out carrying get, post, put, patch, delete. Deliberate over-generation: an unused PUT mock costs nothing, a missed method costs an unmocked fetch.

Those handlers load at both layers, so jsdom and Chromium agree on what /api/* returns: flawless/setup.ts calls server.listen() from mocks/node.ts for Vitest, flawless/host/main.tsx awaits startMockWorker() from mocks/browser.ts for Playwright. No msw init public/ needed — the generated host serves msw/mockServiceWorker.js from its own origin, since MSW registers the worker there and would otherwise 404 on every boot.

The failure mode: every body is {}

With no spec to read, every operation gets a 200 whose example is an empty object, so every handler comes out as HttpResponse.json({}). Nothing about that is red: your fetch specs pass, against {}. That is the state the next two flags exist to get you out of.

Getting real bodies in — --swagger-url and --api

Three sources feed the routes, later winning: the AST walk, a live spec over HTTP, a local file.

The auto-try. In --nextjs mode the run already tries http://localhost:3000/api/docs — the next-swagger-doc convention — with a 1500ms timeout, accepting the response only if it is application/json. Dev server up and serving a spec there, and you get real bodies with no flag at all. It is silent when it fails: on most projects nothing is listening, and a warning every run would be noise. Name the URL explicitly and the same failure becomes loud, because now you asked for it:

bunx flawless-react src/app/page.tsx --nextjs . --swagger-url http://localhost:3999/api/docs
nextjs: live spec at http://localhost:3999/api/docs unavailable (Unable to connect. Is the computer able to access the url?); using static AST output

--swagger-url=off skips the probe — worth it in CI, where the 1500ms is pure latency. Outside --nextjs the flag parses and is never read.

--api beats both. A local spec is the only reliable source when your API is a catch-all — Hono, tRPC, a single [[...route]]/route.ts. The AST walk finds one path there, and not one your components call.

bunx flawless-react src/app/page.tsx --nextjs . --api openapi.json --swagger-url=off
nextjs: enriched routes from openapi.json (paths=2)
http.get("/api/health", () => HttpResponse.json({})),
http.get("/api/items", () => HttpResponse.json({"items":[{"id":1,"name":"Widget"}]})),
http.post("/api/items", () => HttpResponse.json({"id":2,"name":"Gadget"})),
http.get("/api/items/:id", () => HttpResponse.json({"id":1,"name":"Widget"})),

Bodies are real now, and /api/items/:id appeared — a path the AST walk never saw, because the spec knows routes the filesystem does not. /api/health survived with its empty body: it is in the walk and not the spec, and the handler list is the union. swagger.json is not — under --api it is the spec verbatim, so /api/health is missing from it.

The trap that got narrower: handlers.ts refreshes on spec moves

Run once without a spec, then again with --api: if you have not edited handlers.ts, the second run refreshes it — mocks/handlers.ts: regenerated from a newer spec — because untouched generated output should track the spec it came from. Your edits are what hold it: a file whose // @flawless-generated stamp no longer matches its body is reported kept (hand-edited) — regenerate with --force or delete it, and a file with no stamp at all (hand-written, or written before stamping existed) is always kept. --force regenerates even a hand-edited file — the named escape hatch, and the reason realistic fixtures survive every other run.

What is left of the trap is the narrow case: swagger.json naming a route handlers.ts has never heard of, in a file you edited or that carries no stamp. --force or a delete-and-rerun reaches it. Its siblings browser.ts, node.ts and mocks/README.md hold nothing of yours and refresh every run either way.

Flags

FlagDefaultNotes
--nextjs [path]off; . when bareNames the project root; the entry positional still resolves from your cwd.
--app-resolver <file>auto-detectedOverrides the wrapper, skips the derivation. Alias of --provider here.
--api <spec>noneLocal OpenAPI JSON/YAML. Beats the walk and the live spec; written to swagger.json verbatim.
--swagger-url <url>auto-tries localhost:3000/api/docs1500ms, JSON only. An explicit URL warns on failure; the auto-try does not. off disables the probe.
--out <dir><project>/flawlessRelative to the project root. The gitignore block follows it.
--tasks <ids>perf-harnessDecides which flawless:* scripts get declared: perf-harness, unit-browser, perf-static, a11y, react-rules, e2e, load.
--colocateoffSpecs beside your sources; changes which spelling of flawless:unit is merged.
--forceoffRefreshes provider.tsx, colocated specs, e2e route specs, and handlers.ts even hand-edited. Never provider.fixtures.ts.

One thing no flag fixes: a11y and react-rules shell out to biome, which the standalone binary cannot carry inside itself — it looks for node_modules/.bin/biome in the project being scanned, every ancestor workspace root, then on PATH, and it needs biome 2: the sweeps write a biome 2 config and parse its JSON reporter, so a binary of another major is skipped in favour of one that can read the config, and a repo whose only biome is a 1.x gets a failure naming it, not a clean sweep. An app already linting with biome 2 satisfies that; otherwise bun add -d @biomejs/biome there, or take the source package (Install).

Where the files land

Everything under <nextRoot>/flawless/ — no separate npm project, no second node_modules, the suite runs on your app’s own dependency tree. A five-task run puts vitest.config.mts, setup.ts, provider.tsx, provider.fixtures.ts, swagger.json and REPORT.md at the root of it, then mocks/, unit/, browser/, host/, profile/, e2e/, load/ and reports/. REPORT.md carries no timestamp and every list is sorted, so a second identical run leaves git status empty.

In a monorepo, aim --nextjs at the app: everything lands there, not at the workspace root.

What it writes into your package.json

devDependencies, added only when the name is absent: msw, vitest, jsdom, @testing-library/react, @testing-library/user-event, @testing-library/jest-dom, @playwright/test, react-scan. Added, not merged — a version you already pin is never touched. An earlier build spread its own pins last, so a repo on vitest ^3.x came back on ^2.1.0. Your indentation survives too.

Scripts, each gated on the task that emits what it points at:

{
  "flawless:unit": "vitest run --config flawless/vitest.config.mts",
  "flawless:browser": "playwright test --config flawless/browser/playwright.config.ts",
  "flawless:perf": "playwright test --config flawless/playwright.config.ts --project=chromium flawless/profile",
  "flawless:e2e": "playwright test --config flawless/e2e/playwright.config.ts",
  "flawless:load": "crust -c 'test-pipes --target flawless/load/routes.pipes --timeout 60000'"
}

flawless:browser needs unit-browser and perf-harness, because the browser specs drive the perf host. Ungated, a --tasks load run declares flawless:perf too — and bun run flawless:perf then fails on a config nobody asked for.

A script you edited survives

nextjs: kept 1 script(s) with commands this tool does not recognize: flawless:unit — delete a key from package.json to take the tool's current command

The rule is ownership, not equality. The tool holds a list of its own spellings, current and past — flawless:unit has two, the --config form written under --colocate and the --root form without it — and a value on that list upgrades in place, so a command improvement reaches older projects. Anything else is your work: kept, and named in the summary, in REPORT.md’s > Kept script(s): … line, and in the --ci blob’s keptScripts, so it survives a quiet run. Not theoretical: a hand-customised flawless:load was once clobbered by a refresh, and that run then failed at setup for a reason that looked nothing like its cause.

One more warning under --colocate: Bun’s and Jest’s default globs match *.test.tsx anywhere, so an unscoped "test": "bun test" absorbs the colocated specs and runs them under a runner whose globals they lack. The run says so and does not fail — scoping is your call.

The .gitignore block

Ten entries, appended once, never duplicated:

# flawless-react — generated reports + sidecars
flawless/reports/
flawless/heaps/
flawless/playwright-report/
flawless/node_modules/
flawless/test-results/
flawless/browser/test-results/
flawless/e2e/test-results/
flawless/e2e/.auth.json
flawless/load/last-*.json
*.flawless.json

The paths follow --out: with --out qa you get qa/reports/ and the rest. An earlier version hard-coded flawless/, leaving qa/e2e/.auth.json unignored — the file the generated global-setup.ts tells you to persist a logged-in storageState into, i.e. session cookies staged for commit. *.flawless.json stays literal: the sidecars sit beside your sources.

Limits

Honest about what doesn’t work yet:

  • The AST walk cannot read a catch-all. [[...route]]/route.ts re-exporting a Hono app yields one path and no methods worth mocking; --api is the only way to useful mocks.
  • Pages-router methods are assumed, never read — all five per file, because the method lives inside a req.method switch.
  • handlers.ts only upgrades while it is untouched. Once you edit it — or if it predates stamping — no re-run touches it; --force or deleting it is the escape hatch.
  • MSW cannot mock a server component’s database access, so nothing here makes e2e mockable — it runs against your real dev server, on data you seeded.

Exit codes

SituationExit
Every selected task ok — kept scripts and files included, since keeping is a success0
A task failed, the Next.js emit threw, or --run-tests and a generated suite went red1
Not a Next.js project, no app//pages/ under the path, missing entry, bad --tasks value2

A failed Next.js emit belongs to no task, so it lands in the --ci blob’s errors array, not in results. Diagnostics reach stderr even under --ci.