02Judgement5 min

Five migrations from ESLint to Biome: the decision is already made

When you do the same migration in five projects without stopping to think about it, it stops being a preference and becomes judgement. What breaks along the way and what you lose.

By Salva Castells

Cover image: Aris riyanto, CC BY-SA 4.0.
In this post

In an image generation SaaS I deleted .prettierrc, eslint.config.mjs and seven dependencies from package.json in the same commit. The lockfile lost 545 lines. The next day I did the same in another project, and another. By the fifth time I wasn't evaluating anything: I opened the repo, pulled out ESLint and Prettier, and wrote biome.json.

That's not a preference any more. It's judgement, and you should be able to defend it.

What pushed me the first time

It wasn't speed. It was the dependency tree. The config I removed was this:

const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];

A FlatCompat so the flat config could understand old configurations. An eslint-config-prettier whose only job is turning off the ESLint rules that clash with Prettier. An eslint-plugin-tailwindcss and a prettier-plugin-tailwindcss doing similar things from two different sides. Six packages whose reason to exist is keeping the other tools off each other's toes.

Biome does formatting and linting in the same binary. That problem disappears because there are no longer two tools to coordinate.

What breaks and nobody warns you about

The migration isn't deleting files and moving on. It's the formatting sweep immediately after: 78 files touched in one commit, where you can't review anything because it's all quotes and indentation. You do it and you accept it.

The real problem arrives when you turn the linter properly on. In a Telegram reminders bot, the first pass with the recommended rules gave 60 errors on the web side. Almost none were noise:

  • Unassociated labels. Login fields without htmlFor/id. ESLint with the Next config had never told me.
  • Decorative SVGs. The rule doesn't recognise the shorthand form of aria-hidden; you need an explicit aria-hidden="true".
  • Name collision. An imported TaskList type clashing with a component of the same name. It compiled perfectly and it was wrong.
  • Index keys. In a rotating text component, each character's key was the map index. The fix wasn't silencing the rule: it was precomputing the absolute offset in the memo so the key would be stable.

That's the interesting part. Of the 60, most were real bugs that had been sitting there for months. The rest were resolved with documented suppressions: a run-once effect, fixed-size skeleton loops, an !important overriding react-remove-scroll-bar, <img> for OAuth avatars.

A biome-ignore with no comment explaining why is debt. With a comment it's a decision.

Speed matters because of where it lets you put the lint

It's the thing repeated most in comparisons and explained worst. The number doesn't matter. What matters is where you can plug the command in once it's fast.

With ESLint the lint lived in CI, because putting it in the local build meant condemning yourself to waiting. With Biome the script ended up like this:

"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."

Lint in front of the build. If it's red, you don't compile. That line is the whole difference: the error stops being discovered fifteen minutes later in a runner and gets discovered before the build starts. With a slow tool that line is unbearable and you end up removing it.

What you lose

Two things, and they're worth naming.

The first is the plugin ecosystem. If you depend on one very specific ESLint plugin, it isn't there and probably won't be. I checked when I removed eslint-plugin-tailwindcss: class ordering is handled by the Tailwind formatter, not the linter, so I didn't miss it. But that's a lucky case, not a general rule.

The second is dumber and causes more grief: the default configuration isn't the same everywhere. One project came out with "indentStyle": "tab" and another with "indentStyle": "space" and "indentWidth": 2. Weeks later I had to make a commit whose only content was swapping tabs for spaces in biome.json so everything would match. Pin the $schema with the exact version and copy the same biome.json between projects. The convenience of this tool is that it has almost no configuration; take advantage of that and keep a single one.

My quality stack today

Biome, TypeScript in strict mode, tests, and knip for dead code. Not much else.

Knip is the piece fewest people use and the one that surprises most. One pass on the Telegram bot deleted 19 dead files and 21 unused dependencies: redundant @radix-ui/* subpackages alongside the meta-package, @react-three/fiber and three entirely, date-fns, and eslint, still installed months after being removed. A whole cluster of actions and use cases nobody called any more came out too.

But knip stays out of CI, on purpose. In a large repo the first pass gave 107 pre-existing findings: wiring it into the gate turns it red on day one and you end up ignoring the red. It goes in as a manual tool, every few months, with the pruning in its own commit and verified with tsc and the tests before merging.

The configuration has to be explicit about entrypoints or it deletes half your application. In a monorepo with Next you have to declare by hand which files are entry points:

"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]

The shadcn-generated components go in ignore because there are always more of them than you use and that's normal. sharp and tailwindcss go in ignoreDependencies because nobody imports them, they're used from the build.

A slow linter is a linter that ends up in a separate job nobody looks at. A fast linter goes into the build and becomes the project's floor.