Skip to content

Migrating to Biome: What We Learned

Biome migration lessons from a monorepo cutover, what breaks, how to stage the handoff, and when not to migrate mid-incident.

5 min read
Biome
Migration
ESLint
Prettier
Monorepo
Tooling

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:

  1. Match formatter output to the existing Prettier contract (tabs, width, quotes) so the first format PR is nearly empty.
  2. Shadow lint in CI (report only) on one app package.
  3. Fail CI on Biome for that package once noise is triageable.
  4. Remove Prettier from that package’s scripts when format diffs stay idle.
  5. Remove ESLint only when the rule gap is explicit and accepted.
  6. 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:

  1. Sort by actionability: unused vars and suspicious any beats stylistic nits you already defer to the formatter.
  2. Use overrides for generated or legacy folders; do not disable recommended globally to silence one path.
  3. 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:

PRContainsReviewer focus
1Add Biome + matched formatter configDiff noise near zero?
2Shadow / then failing biome check in CIActionable diagnostics?
3Hook scripts + remove Prettier for that packageLocal/CI parity?
4Remove 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:

  1. biome check fails CI on that package.
  2. Prettier / ESLint scripts are gone or documented as temporary exceptions with owners.
  3. Pre-commit runs Biome for the same globs CI checks.
  4. 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.