← all articles

Moving unlike repositories from ESLint to Biome

Topics: tooling · biome · dx

If we had one modern repository, moving from ESLint and Prettier to Biome would have been a short change. We do not. The repositories began at different times, use different React and TypeScript versions, and carry lint rules that record years of small decisions.

So I did not treat the migration as search and replace. The first question was: what is the current setup protecting us from? Only then could I ask which parts Biome should replace.

Speed was not the only reason

Biome is fast. That helps, but it was not the only daily problem. The same code could pass in one repository and fail in another, and upgrading React or TypeScript could start another compatibility round across ESLint plugins.

We wanted similar commands in every repository, configuration that did not require following a chain of extends files, and checks fast enough to run locally before an engineer waited for CI.

That does not make ESLint bad. Some of its rules go deeper than Biome, and some plugins solve real problems. The goal was not to remove ESLint because it was old. The goal was to reduce the number of moving parts without silently dropping useful safeguards.

Extract the effective configuration first

Before touching a repository, I inspect the ESLint configuration after all inherited files have been merged. The final rule set is often different from what the top-level file suggests.

I group the rules into things I can reason about: likely bugs, React behavior, accessibility, imports, formatting, and team conventions. Then each rule gets one answer:

  • Biome has a direct replacement;
  • TypeScript covers it better;
  • it needs a separate check;
  • it no longer earns its cost and can be removed.

This list matters more than the first biome.json. Without it, the migration can look successful because CI is green while a rule that prevented a real bug has disappeared.

Separate formatting from linting

I start with formatting because it is easier to review as a mechanical change. I configure Biome, run the formatter across the repository, and keep that commit free of manual edits.

{
  "formatter": {
    "enabled": true,
    "indentStyle": "space"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "double"
    }
  }
}

The commit may be large, but it should be boring. I add it to .git-blame-ignore-revs so it does not damage line history, then remove the old Prettier setup. I do not leave both formatters active. One disagreement between them is enough to create noise in every pull request.

After formatting settles, I move lint rules. Those changes are smaller and easier to inspect: replace a rule, add a narrow exception with a reason, or keep a temporary check outside Biome.

Do not migrate every repository in one day

Newer repositories are usually easier, so I begin with one of them. It reveals the final command and configuration without getting stuck immediately in the oldest exception.

Once it works, we have an example to repeat. I still do not copy the file blindly. The inventory from the first step tells me which repository differs and why. An older application may need a temporary setting, or it may be better to wait until active product work makes the migration worth the cost.

I am comfortable leaving one stable repository on ESLint for longer if it rarely changes. The problem is not temporary coexistence. The problem is claiming the migration is finished while different commands remain undocumented.

After each repository, I ask an engineer who did not perform the migration to run the commands. If they need a spoken explanation, the setup is not done. That small test catches things my machine hides: an editor plugin still active, an old README command, or a CI path that never uses the new configuration.

What usually breaks

Editor setup is first. Changing a file in Git is easier than making sure every team member has disabled Prettier and enabled Biome in the same way. I document the extension and save behavior, then keep CI as the final authority when local environments disagree.

Generated files are next. Either exclude them and let the generator own their formatting, or format them under the same policy. A mixed approach makes every code-generation run produce a diff unrelated to the feature.

Then come the ESLint rules Biome cannot replace. I do not hide them in a general TODO. I record the rule, the risk it covered, and whether TypeScript, a test, or review is enough for now.

CI is the judge

The editor makes the loop pleasant, but one command must decide whether a change can merge. I pin the Biome version, expose a clear local command, and use the same command in CI:

{
  "scripts": {
    "check": "biome check .",
    "check:ci": "biome ci ."
  }
}

Exceptions should remain visible and close to the code that needs them. If an older directory needs a broader override, it belongs in configuration with a comment explaining why. An exception without a reason becomes a permanent rule very quickly.

Before moving to the next repository, I record four things: Biome version, check command, current exceptions, and files outside the scope. That short record prevents the same discussion from restarting the following week.

When is the migration done?

Not when the last ESLint file is deleted. It is done when an engineer can open another repository, run the same command, and understand the result; and when the new tool catches new mistakes instead of merely reformatting old code.

Speed is a useful gain. The improvement I feel every day is having less environment drift from one repository to the next. In a large polyrepo, that drift is what consumes time.