This is the migration playbook I wish I had before touching package.json on an
@acme-style monorepo. Run your own timings before you quote them in a PR
description.
Stage the cutover; do not big-bang
Order that has worked:
- Match formatter output to the existing Prettier contract (tabs, width, quotes) so the first format PR is nearly empty.
- Shadow lint in CI (report only) on one app package.
- Fail CI on Biome for that package once noise is triageable.
- Remove Prettier from that package’s scripts when format diffs stay idle.
- Remove ESLint only when the rule gap is explicit and accepted.
- Roll outward package by package.
json// apps/web/package.json, illustrative dual-run window { "scripts": { "lint": "eslint . --max-warnings=0", "lint:biome": "biome check .", "format": "prettier --write .", "format:biome": "biome format --write ." } }
Delete scripts in reverse of confidence, not in reverse of enthusiasm.
What broke (illustratively)
Import paths and ignores. A root biome.json that forgot **/*.gen.ts
produced thousands of noisy diagnostics on generated clients. Fix ignores before
arguing about rules.
Editor double-format. VS Code ran Prettier and Biome on save. Files oscillated. Fix: disable Prettier for the globs Biome owns, document it in the repo README, and land that doc in the same PR as the config.
Hooks assumptions. Some React patterns that ESLint’s hooks plugin flagged did not fail Biome the same way (or vice versa). Dual-run the hooks-heavy packages longer. Do not assume identical rule semantics from similar names.
Commit hooks. nano-staged / lint-staged setups still need to call the new binary. A hook that formats with Prettier while CI checks with Biome is how local green becomes CI red.
js// illustrative nano-staged config snippet export default { '*.{js,ts,tsx}': ['biome check --write', 'biome format --write'], };
Monorepo package boundaries. Running Biome from an app directory without the
root config’s overrides reintroduced rule drift, the same failure mode as
copied .eslintrc files in February.
Rule triage without boiling the ocean
When shadow mode opens a flood:
- Sort by actionability: unused vars and suspicious any beats stylistic nits you already defer to the formatter.
- Use
overridesfor generated or legacy folders; do not disablerecommendedglobally to silence one path. - Record intentional gaps: "ESLint remains for rules X/Y until Biome covers them." Make the gap a ticket, not guesswork.
json// biome.json, illustrative override { "overrides": [ { "include": ["**/legacy/**"], "linter": { "rules": { "complexity": { "noForEach": "off" } } } } ] }
When not to migrate
Hard stops:
- Active production incident: toolchain PRs steal review and CI capacity.
- Release freeze: formatting churn obscures cherry-picks.
- Unowned custom ESLint rules still load-bearing for compliance.
- No one available to fix editor setup the same week, you will train the team to hate the tool.
Soft delays:
- Major framework upgrade in flight (React 19 migration scars still count).
- You have not measured ESLint time; you are migrating on anecdote.
Ownership and review shape
Tooling PRs fail when they mix three concerns: binary bump, rule policy, and mass format. I keep them separable:
| PR | Contains | Reviewer focus |
|---|---|---|
| 1 | Add Biome + matched formatter config | Diff noise near zero? |
| 2 | Shadow / then failing biome check in CI | Actionable diagnostics? |
| 3 | Hook scripts + remove Prettier for that package | Local/CI parity? |
| 4 | Remove ESLint (or document exceptions) | Rule gap accepted? |
Same owner should shepherd editor docs; otherwise you migrate CI and leave half the team on Prettier-on-save.
Exit criteria for "done"
I call the package migrated when:
biome checkfails CI on that package.- Prettier / ESLint scripts are gone or documented as temporary exceptions with owners.
- Pre-commit runs Biome for the same globs CI checks.
- A short ADR notes remaining ESLint plugins and the revisit date.
Until then it is a pilot, not a migration.
Partial vs big-bang (decision criteria)
Prefer partial / sequenced when rule gaps are real, format diffs would drown review, or editors are not ready. Prefer flipping a package fully (no half-Prettier left inside it) when inventory says Biome covers what you enforce and CI + pre-commit can switch together.
Big-bang for the whole monorepo in one PR is almost always wrong. Big-bang per package after a leaf pilot is usually right.
Thin ESLint allowlist is allowed, only with a sunset date and an owner. Hybrid without a sunset is dual-stack forever (the Biome migration post's escape-hatch axis).
Failure modes
Permanent dual lint. Two sources of truth forever. Pick an end state.
Mega-PR that reformats the monorepo. Reviewers stop reading. Format per package; keep behavior PRs separate from tool PRs.
Invented victory metrics. If you want a number, paste CI wall-clock before and after from your runners, do not borrow someone else’s percentage.
Suppressions deleted wholesale. Translate eslint-disable to biome-ignore
with reasons, or fix the code. Silent deletion hides debt inside a tooling PR.
Continuity
The lint arc continues later with oxlint / oxfmt; this wave stops at Biome lessons. TypeScript 6.0 as a bridge release follows: prepare defaults and deprecations without treating tsgo / TS 7 as the default toolchain today.
Takeaway
Migrate Biome package-by-package with shadow CI and matched formatting, never mid-incident, and never with a permanent dual-lint tax as the quiet end state.