Next.js mode
How --nextjs detects your router, derives MSW handlers and a swagger.json from your API routes, and writes the suite into your existing project
What --nextjs does
Pass --nextjs [<path>] to target an existing Next.js project. Pass nothing — or --nextjs . — to use the current directory.
cd my-next-app
bunx flawless-react app/page.tsx --nextjs
Outside this mode, flawless writes a standalone suite with its own package.json, its own tsconfig.json, and its own copy of every dependency. That makes sense when the suite lives beside a component library. It makes much less sense inside a Next.js app, which already has React, a TypeScript config, and a dependency tree you would rather not duplicate.
So in --nextjs mode flawless stops behaving like a generator of new projects and behaves like a generator of files for your project. It:
- detects whether you are on the App Router or the Pages Router, and which directory is the router root
- picks up
app/layout.tsx(orpages/_app.tsx) as the test-time provider, so context and CSS carry through to mounted components - walks your API routes and emits
swagger.jsonplus a set of MSW handlers - wires those handlers into both the Playwright host bundle and Vitest’s setup file
- writes everything under
<nextRoot>/flawless/, including its own suite-local manifest trio —package.json,tsconfig.jsonandvitest.config.mts— so the suite’s toolchain (Playwright, Vitest, esbuild, MSW) never collides with yours; the one rule is that the suitepackage.jsoncarries no"type"key, because the Next.js loader switch is the tool’s own key and a stray"type": "module"breaks__dirnamein the Playwright config - merges the dependencies the generated suite needs into your project’s
package.jsonasdevDependencies(msw,vitest,jsdom, the Testing Library set,@playwright/test,react-scan— with@testing-library/reactpinned to 12 for React 17 apps), and addsflawless:*scripts - appends the generated output paths to your
.gitignore(this happens in every mode, not only here)
Before any of that runs, the directory has to look like a Next.js project: flawless requires a next.config.{ts,js,mjs,cjs}, or a next entry under dependencies or devDependencies in package.json. If neither is there it exits rather than guessing.
Router detection
Four directories are checked, in this order, and the first hit decides both the router style and the router root:
| Directory | Router |
|---|---|
app/ | App Router |
src/app/ | App Router |
pages/ | Pages Router |
src/pages/ | Pages Router |
If none of them exist, flawless stops with an error — there is nothing to walk.
The detection result is printed before the tasks run, so you can confirm it matched what you expected:
nextjs: /home/you/my-next-app (router=app, api-routes=7, app-resolver=/home/you/my-next-app/app/layout.tsx)
If you do not pass an entry path or glob at all, flawless looks for components/, src/components/, or app/components/ and defaults the entry to <that dir>/**/*.tsx, printing which one it chose.
The app resolver
A component pulled out of a Next.js app and mounted bare will usually render wrong or not at all: it expects the providers, the fonts, and the global stylesheet that the root layout sets up. The app resolver is flawless’s answer to that. It is the same mechanism as --provider — a file that default-exports a wrapper component — but detected for you instead of passed by hand.
Detection depends on the router:
- App Router — the first of
layout.tsx,layout.jsx,layout.ts,layout.jsfound at the router root. - Pages Router — the first of
_app.tsx,_app.jsx,_app.ts,_app.jsfound at the router root.
If neither exists, no resolver is set and components mount bare.
Override the detection with --app-resolver <file> when your real wrapper lives somewhere else — a custom Providers.tsx, say, or a test-only variant that stubs out authentication:
bunx flawless-react app/page.tsx --nextjs --app-resolver ./test/FlawlessProviders.tsx
--app-resolver and --provider are aliases in this mode. If you pass both, --provider wins. Whichever you pass takes precedence over the auto-detected file.
Finding the file is only the first half, because what it is decides whether it can be mounted. An App Router layout.tsx is usually an async server component importing next/font or next/headers, none of which run under jsdom. A Pages Router _app.tsx has a different problem: it renders <Component /> and never mentions {children}, so mounting it as the wrapper would wrap nothing. In both cases flawless derives a client-safe flawless/provider.tsx from the chain that does enclose the tree — for _app.tsx, anchored on <Component /> — and mounts that instead, naming the layers it kept and the layers it dropped with the reason for each:
nextjs: app resolver cannot mount under jsdom — derived flawless/provider.tsx (providers: ThemeProvider — dropped SessionContextProvider (auth-gated))
Refresh it with --force. The flawless/provider.fixtures.ts file beside it is yours and is never rewritten.
An auto-derived wrapper skips any provider layer whose own module imports an auth library (@clerk/*, next-auth, and the Supabase auth layer — @supabase/auth-helpers-* and @supabase/ssr; the @supabase/supabase-js SDK itself is deliberately left alone, because a stubbed from().select() chain would turn a named failure into undefined quietly flowing through an assertion). The unit and browser planes replace those libraries with a signed-out stub, so a provider that gates its children on a session renders nothing — every wrapped spec would inherit the blank tree — while the stub already satisfies the context reads auth-consuming components make. The skip is decided per layer, not for the whole tree: theme, query, Redux and the rest still mount. Pass your own --provider when you want the auth layer actually mounted under test, and point it at a variant that seeds a session.
The app resolver’s own imports are collected as well, even though it is not an analysed component. That matters when the client is constructed there: a pages _app.tsx that builds a browser client in a useState initializer throws from the constructor before any spec runs — either NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY env variables or supabaseUrl and supabaseKey are required — and every spec file dies on it, 26 tests in 17 files, without reaching an assertion. Reading the resolver’s import table is all the collector needs to stub the specifier it asked for.
API routes become handlers
Components that fetch need something to fetch from. Rather than asking you to write mocks, flawless reads the API routes you already have and derives them.
App Router. It globs app/api/**/route.{ts,tsx,js,jsx,mjs} and parses each file with the TypeScript compiler API, collecting exported identifiers that name an HTTP method — GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD — in either the export const GET = … or the export function GET() { … } form. A route file exporting none of those is skipped.
Pages Router. It globs pages/api/**/*.{ts,tsx,js,jsx,mjs}. A Pages handler is a single default export that switches on req.method internally, so there is nothing to read statically; flawless assumes GET, POST, PUT, DELETE, and PATCH for every file. A trailing index segment is dropped, so pages/api/users/index.ts becomes /api/users.
Dynamic segments are translated on the way out:
| Next.js segment | OpenAPI path | MSW path |
|---|---|---|
[id] | {id} | :id |
[...slug] | {slug} | :slug |
[[...slug]] | {slug} | :slug |
(marketing) | removed | removed |
Catch-all segments collapse to a single path parameter, which is lossy but keeps the emitted spec valid. Route groups are a filesystem convention only and never appear in the URL, so they are dropped.
swagger.json
The route list is turned into an OpenAPI 3.0.0 document at <nextRoot>/flawless/swagger.json — one operation per method per path, each with a 200 response carrying an empty application/json example. It is a readable inventory of your API surface derived from the code rather than maintained alongside it, and it is regenerated on every run.
mocks/handlers.ts
The same route list produces one MSW handler per method per path:
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users", () => HttpResponse.json({})),
http.post("/api/users", () => HttpResponse.json({})),
http.get("/api/users/:id", () => HttpResponse.json({})),
];
The empty bodies are the honest default: static analysis of a route handler can tell you the method and the path, but not what the response looks like. Replacing HttpResponse.json({}) with a realistic fixture is the one piece of work the tool cannot do for you — and your fixtures survive later runs: handlers.ts is regenerated only when the API spec changed and the file still carries its untouched generated stamp, or when you pass --force. A file you have edited is kept and reported.
Three more files land next to it, also written only when absent:
mocks/browser.ts—setupWorker(...handlers)plus astartMockWorker()helper, configured withonUnhandledRequest: "bypass".mocks/node.ts—setupServer(...handlers)for Node-side fetches.mocks/README.md— a short note on how the layers fit together.
Where the handlers get wired in
Both generated test layers pick the handlers up, so the same mocks intercept fetch wherever it is called:
- The perf harness’s
host/main.tsximportsstartMockWorkerfrom../mocks/browser.tsand boots the service worker before the first mount, sofetchfrom inside a Playwright-driven page is intercepted. - The unit suite emits a
setup.tsthat importsserverfrom./mocks/node.ts, and the Vitest config lists it undersetupFiles, sofetchfrom a jsdom render is intercepted too.
Pulling a live spec with --swagger-url
Static analysis gives you paths and methods. It cannot give you response shapes. If your project already serves an OpenAPI document — next-swagger-doc is the common way to get one — flawless would rather read that.
So unless you opt out, it tries. Before emitting anything it fetches http://localhost:3000/api/docs with a 1.5 second timeout and an accept: application/json header. The short timeout is the point: when your dev server is not running, the attempt costs almost nothing and the run carries on with the static output.
The response has to be a success status and have a JSON content type to be used. When it is:
- routes whose path matches a statically-discovered route have their response bodies filled in and any extra methods merged in
- paths that only the live spec knows about are appended to the route list
swagger.jsonis written verbatim from the fetched spec rather than derived from the ASTmocks/handlers.tsgets real bodies instead of{}— either it does not exist yet, or it exists untouched and the spec digest moved, so the refresh reaches it
Point it at a different URL when your docs endpoint is not at the default:
bunx flawless-react app/page.tsx --nextjs --swagger-url http://localhost:4000/openapi.json
Failure is handled differently depending on how the URL was chosen. The automatic attempt fails silently — it is speculative, and a warning on every run in a project that has no docs endpoint would be noise. An explicit --swagger-url that fails prints the reason to stderr and falls back to the static output:
nextjs: live spec at http://localhost:4000/openapi.json unavailable (HTTP 502); using static AST output
To skip the automatic attempt entirely, pass the escape hatch:
bunx flawless-react app/page.tsx --nextjs --swagger-url=off
Worth doing in CI, or anywhere something else is listening on port 3000.
What lands in your project
The output directory
Unless you pass --out, everything goes to <nextRoot>/flawless/:
my-next-app/
├── app/
├── package.json # merged: devDependencies + flawless:* scripts
├── .gitignore # appended: generated output paths
└── flawless/
├── swagger.json # regenerated every run
├── package.json # suite-local deps — react pinned to the app's exact version
├── pnpm-workspace.yaml # write-once — pnpm 10+ build-script allowance for esbuild
├── setup.ts # Vitest setup; jest-dom, jsdom gaps, native-import stubs, MSW where fetched
├── mocks/
│ ├── handlers.ts # yours to edit; kept while your edits are in it
│ ├── browser.ts
│ ├── node.ts
│ └── README.md
├── host/
├── profile/
├── unit/
├── browser/
└── reports/
With unit-browser in the run, the suite installs its own dependencies from
flawless/package.json — a self-contained node_modules that never disturbs the app’s —
and flawless/tsconfig.json type-checks the suite in isolation. The dependency that makes
the suite-local install necessary rather than merely tidy is React itself: the suite pins
react and react-dom to the app’s exact resolved version, because two React copies in
one tree break hooks, and an app’s caret range resolves to whatever is newest on the day you
install. A pnpm repo also gets a write-once flawless/pnpm-workspace.yaml carrying the
esbuild build-script allowance — without it pnpm 10+ refuses esbuild’s postinstall, which
is its binary, and vitest dies at startup. A run of perf-harness alone skips the
suite-local install: the perf suite then runs on the app’s own @playwright/test.
package.json
The dependencies the generated suite needs are added to your project’s devDependencies
(only where absent — your version ranges are yours), and a script is added for each plane the
run selected — up to six: flawless:unit, flawless:perf, flawless:browser, flawless:e2e,
flawless:load, flawless:report — so you can run each layer from the project root:
{
"devDependencies": {
"msw": "^2.6.0",
"vitest": "^2.1.0",
"jsdom": "^25.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@testing-library/jest-dom": "^6.5.0",
"@playwright/test": "^1.49.0",
"react-scan": "^0.4.3"
},
"scripts": {
"flawless:unit": "vitest run --root flawless",
"flawless:perf": "playwright test --config flawless/playwright.config.ts --project=chromium flawless/profile",
"flawless:browser": "playwright test --config flawless/browser/playwright.config.ts"
}
}
The script paths are written relative to the project root, so they follow --out if you moved the output directory. The merge only ever touches its own keys — and since 0.3.0 it does not even overwrite those blindly: a flawless:* value you have customised (say, adding --env-file to a load script) is recognised as your work and kept, because the tool only upgrades values it can identify as one of its own past spellings. Kept keys are named in the run summary, in REPORT.md as > Kept script(s): ..., and in the --ci blob’s keptScripts. Delete the key and re-run to take the tool’s current command.
On a pnpm project the merge writes one more key: pnpm.ignoredBuiltDependencies listing @playwright/test and msw, and only when the repo is pnpm-installed (pnpm-lock.yaml present). pnpm 10 treats an unrecorded build-script decision as an error, and both packages ship a postinstall, so the merge introducing them was enough to make the app’s own pnpm run dev fail with ERR_PNPM_IGNORED_BUILDS — a test scaffold that breaks the app it scaffolds for. Anything you already approved through onlyBuiltDependencies stays approved; the merge records only its own decisions, and never rewrites an entry you wrote.
.gitignore
Ten entries are appended, under a comment marking where they came from:
# 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
They follow --out, so a run with --out qa ignores qa/… rather than a flawless/ directory it never created. flawless/e2e/.auth.json matters most of the ten: that is where the generated global-setup.ts tells you to persist a logged-in storageState, which is to say session cookies staged for commit.
Since 0.3.0 this happens in every mode, not only under --nextjs. Outside Next.js mode the entries go to the git root — where git reads a repo-wide ignore, and where your existing one already is — falling back to the nearest package.json, then the output directory’s parent. Before that, a plain flawless-react "src/**/*.tsx" run ignored nothing at all.
Only entries not already present are added, so re-running does not accumulate duplicates. The generated tests, host, and mocks are not ignored — they are meant to be committed and edited, and *.flawless.json covers the per-component sidecar summaries that get dropped next to your source files.
Your lint will see the suite
The tool formats every file it writes with your formatter before finishing — your biome if the repo has a biome config, your prettier otherwise, neither if you use neither — so a run never churns your formatting state, and the emitted suite already carries your quotes and your line width.
That is formatting. Linting is a different question, and one part of it needs a line from you. flawless/ is tool-managed and rewritten per run; a finding inside a generated mock is the tool’s bug to fix, not your CI’s failure to triage, and a red biome check . over generated code trains you to ignore lint output where it matters. So the run appends the suite directory to .prettierignore and, in an eslintrc project, to .eslintignore — the same append-only, keep-your-edits treatment as .gitignore, and only where those tools are configured. The ignore governs your sweeps; the tool keeps formatting its own output to your prettier config by passing an empty ignore path when it formats the files it just wrote. A dogfood run wrote the prettier line for a real reason: prettier 3.0.3 cannot parse the with { type: "json" } attribute the profile specs use (import attributes arrived in 3.1), so without the ignore that repo’s prettier --check . fails on files it does not own.
Biome is the one tool that keeps a manual step, because there is no ignore file to append to — the exclusion is a files.includes marker in your config, and its shape is version-gated JSONC the tool will not rewrite into your settings. For biome 2.x:
"files": { "includes": ["**", "!!**/flawless"] }
(Use !!**/.flawless for a plain-mode suite generated without --nextjs — that is the default output directory there.) The tool reads that exclusion back: it runs your biome with --no-errors-on-unmatched, so its own format pass over suite files stays quiet once the config excludes them. For a flat-config ESLint (eslint.config.js), ignore flawless/** through ignores — flat configs do not read .eslintignore, so the tool does not write one there.
Which install channel to use
--nextjs mode works from either channel, but the task set you choose can matter. perf-harness, unit-browser, and perf-static run anywhere. 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, then on PATH.
A Next.js project that already lints with biome satisfies that for free. If yours does not, either add biome to it (bun add -d @biomejs/biome), or use the source package, which carries biome as a real dependency and needs no further setup:
bun add -g @lariocpt/flawless-react The standalone binary needs no Bun on the target machine at all:
curl -fsSL https://raw.githubusercontent.com/lariocpt/flawless-react/main/install.sh | bash