{"id":533,"date":"2026-08-01T13:03:02","date_gmt":"2026-08-01T13:03:02","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/biome-vs-eslint-the-400-lines-of-config-i-deleted\/"},"modified":"2026-08-01T13:03:02","modified_gmt":"2026-08-01T13:03:02","slug":"biome-vs-eslint-the-400-lines-of-config-i-deleted","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/biome-vs-eslint-the-400-lines-of-config-i-deleted\/","title":{"rendered":"Biome vs ESLint: The 400 Lines of Config I Deleted"},"content":{"rendered":"<p>Confession: I had an <code>eslint.config.js<\/code> sitting in a client repo that I hadn&rsquo;t read in about eleven months. I could explain maybe four of the rules in it. The rest showed up via <code>extends<\/code>, got argued about once in a code review sometime in 2024, and then just sat there. Every few weeks CI would fail on a rule nobody could defend, someone would drop an <code>\/\/ eslint-disable-next-line<\/code> on it, and we&rsquo;d move on with our lives.<\/p>\n<p>What finally pushed me to switch wasn&rsquo;t speed. Everybody leads with speed and I think it&rsquo;s the least interesting part. It was running <code>npm ls<\/code> one afternoon and counting eleven ESLint-adjacent devDependencies in a mid-size Next.js app, with a tree deep enough that Renovate was opening two or three PRs a week against tooling that produces zero user-facing value.<\/p>\n<p>So I moved that repo to Biome. The formatting half went great. The linting half did not go great, and I want to be specific about why, because most of the posts I read on this were written by people who migrated a personal blog and declared victory.<\/p>\n<h2 id=\"what-i-was-actually-maintaining\">What I was actually maintaining<\/h2>\n<p>Here&rsquo;s the before. Four config files plus a chunk of <code>package.json<\/code>:<\/p>\n<pre><code class=\"language-js\">\/\/ eslint.config.js\nimport js from &quot;@eslint\/js&quot;;\nimport tseslint from &quot;typescript-eslint&quot;;\nimport react from &quot;eslint-plugin-react&quot;;\nimport reactHooks from &quot;eslint-plugin-react-hooks&quot;;\nimport jsxA11y from &quot;eslint-plugin-jsx-a11y&quot;;\nimport importX from &quot;eslint-plugin-import-x&quot;;\nimport unicorn from &quot;eslint-plugin-unicorn&quot;;\nimport next from &quot;@next\/eslint-plugin-next&quot;;\nimport prettier from &quot;eslint-config-prettier&quot;;\n\nexport default tseslint.config(\n  js.configs.recommended,\n  ...tseslint.configs.recommendedTypeChecked,\n  { languageOptions: { parserOptions: { projectService: true } } },\n  react.configs.flat.recommended,\n  { plugins: { &quot;react-hooks&quot;: reactHooks }, rules: reactHooks.configs.recommended.rules },\n  jsxA11y.flatConfigs.recommended,\n  importX.flatConfigs.recommended,\n  unicorn.configs[&quot;flat\/recommended&quot;],\n  { plugins: { &quot;@next\/next&quot;: next }, rules: next.configs[&quot;core-web-vitals&quot;].rules },\n  prettier,\n  { rules: { \/* ~40 lines of local overrides *\/ } },\n);\n<\/code><\/pre>\n<pre><code class=\"language-json\">\/\/ .prettierrc.json\n{ &quot;semi&quot;: true, &quot;singleQuote&quot;: false, &quot;trailingComma&quot;: &quot;all&quot;, &quot;printWidth&quot;: 100 }\n<\/code><\/pre>\n<p>Plus <code>.eslintignore<\/code>, <code>.prettierignore<\/code>, and four npm scripts to run lint and format in both check and write mode. About 180 lines of configuration when you add it all up, and that&rsquo;s before the overrides file I&rsquo;m not showing you.<\/p>\n<p>The after is one file:<\/p>\n<pre><code class=\"language-json\">\/\/ biome.json\n{\n  &quot;$schema&quot;: &quot;https:\/\/biomejs.dev\/schemas\/2.5.0\/schema.json&quot;,\n  &quot;vcs&quot;: { &quot;enabled&quot;: true, &quot;clientKind&quot;: &quot;git&quot;, &quot;useIgnoreFile&quot;: true },\n  &quot;formatter&quot;: { &quot;indentStyle&quot;: &quot;space&quot;, &quot;indentWidth&quot;: 2, &quot;lineWidth&quot;: 100 },\n  &quot;linter&quot;: {\n    &quot;rules&quot;: { &quot;recommended&quot;: true },\n    &quot;domains&quot;: { &quot;next&quot;: &quot;recommended&quot;, &quot;react&quot;: &quot;recommended&quot;, &quot;test&quot;: &quot;recommended&quot; }\n  },\n  &quot;javascript&quot;: { &quot;formatter&quot;: { &quot;quoteStyle&quot;: &quot;double&quot;, &quot;semicolons&quot;: &quot;always&quot; } }\n}\n<\/code><\/pre>\n<p>One devDependency. One binary. No parser, no plugin resolution, no <code>eslint-config-prettier<\/code> sitting at the bottom of the array to stop two tools from fighting over semicolons.<\/p>\n<h2 id=\"the-migrate-command-does-most-of-it-then-hands-you-homework\">The migrate command does most of it, then hands you homework<\/h2>\n<p>Biome ships <code>biome migrate eslint --write<\/code> and <code>biome migrate prettier --write<\/code>, and they genuinely work. The <a href=\"https:\/\/biomejs.dev\/guides\/migrate-eslint-prettier\/\" rel=\"nofollow noopener\" target=\"_blank\">migration guide<\/a> is honest about the limits, which I appreciated: it needs Node to load your flat config, it can choke on plugins that export cyclic references, and it won&rsquo;t read YAML configs.<\/p>\n<p>Two things caught me out.<\/p>\n<p>First, the migration sets <code>\"recommended\": false<\/code> and then lists every rule it could map explicitly. That&rsquo;s the safe behavior, but you end up with a 200-line <code>biome.json<\/code> that&rsquo;s a translation of your old mess rather than a fresh start. I ran the migration, read the diff to see which rules I&rsquo;d actually been relying on, then threw the output away and hand-wrote the nine-line config above. Took twenty minutes and I understand every line of it now.<\/p>\n<p>Second, by default Biome skips rules it considers &ldquo;inspired by&rdquo; rather than identical to the ESLint original. You need <code>--include-inspired<\/code> if you want those. I missed this on the first pass and wondered why three rules I cared about had vanished.<\/p>\n<p>The other thing worth knowing: Biome defaults to tabs. If your team has opinions about that, set <code>indentStyle<\/code> before you run <code>biome check --write<\/code> across the repo, or your first commit is going to be 40,000 lines of nothing.<\/p>\n<h2 id=\"prettier-lost-and-i-stopped-caring\">Prettier lost and I stopped caring<\/h2>\n<p>The formatter is the easy win. Biome&rsquo;s formatter aims to match Prettier closely and publishes the <a href=\"https:\/\/biomejs.dev\/formatter\/differences-with-prettier\" rel=\"nofollow noopener\" target=\"_blank\">places where it deliberately differs<\/a>. Running it over the repo produced a diff I skimmed in ten minutes: some JSX attribute wrapping, a few comment placements, one template literal it indented differently. Nothing that changed behavior.<\/p>\n<p>Speed is real, but not in the way benchmarks sell it. Formatting the whole repo went from roughly nine seconds to under one. That barely matters in CI. What matters is format-on-save in the editor, where Prettier had a perceptible lag on our larger files and Biome doesn&rsquo;t. Small thing that I notice every single day.<\/p>\n<p>The bigger win is one fewer tool in the argument. <code>eslint-config-prettier<\/code> exists purely because two tools both wanted to own whitespace. Deleting that whole category of problem felt like the same relief I got when I <a href=\"https:\/\/abrarqasim.com\/blog\/vitest-vs-jest-2026-the-day-i-stopped-fighting-my-test-runner\" rel=\"noopener\">stopped fighting my test runner<\/a>.<\/p>\n<h2 id=\"where-i-kept-eslint-anyway\">Where I kept ESLint anyway<\/h2>\n<p>Here&rsquo;s the part that gets left out of the switch-today posts.<\/p>\n<p>I could not fully drop ESLint from that Next.js repo. Three reasons.<\/p>\n<p><code>eslint-config-next<\/code> has rules that check things Biome has no equivalent for, like flagging a raw <code>&lt;img&gt;<\/code> where <code>next\/image<\/code> belongs, or catching a synchronous script in the wrong place. Biome&rsquo;s Next domain covers some overlap, but not the framework-specific stuff that only makes sense if you understand Next&rsquo;s build output.<\/p>\n<p>We had two in-house rules written as ESLint plugins, one enforcing a naming convention on our API route handlers and one banning direct imports from a legacy module. Biome supports plugins through <a href=\"https:\/\/biomejs.dev\/linter\/plugins\" rel=\"nofollow noopener\" target=\"_blank\">GritQL<\/a>, and I ported the import ban in about half an hour. The naming rule needed logic that GritQL&rsquo;s pattern matching couldn&rsquo;t express cleanly, so it stayed.<\/p>\n<p>And the strict type-aware rules: <code>no-unsafe-assignment<\/code>, <code>no-unsafe-member-access<\/code>, <code>strict-boolean-expressions<\/code>. The ones that need the full type graph. Biome has a real answer here, which I&rsquo;ll get to, but it isn&rsquo;t parity yet.<\/p>\n<p>So the honest end state on that repo: Biome formats and handles 90% of linting on every save and every commit. ESLint runs once, in CI, with a config trimmed to about fifteen rules and four plugins. That&rsquo;s still a meaningful reduction, and my editor no longer stalls. But &ldquo;I deleted ESLint&rdquo; would be a lie, and I keep seeing people write it.<\/p>\n<p>On two smaller projects with no Next.js and no custom rules, ESLint is genuinely gone. So the answer depends heavily on what you built.<\/p>\n<h2 id=\"type-inference-without-tsc-is-the-actually-new-idea\">Type inference without tsc is the actually-new idea<\/h2>\n<p>The thing that makes Biome more than a fast Prettier clone is that <a href=\"https:\/\/biomejs.dev\/blog\/biome-v2\/\" rel=\"nofollow noopener\" target=\"_blank\">v2 shipped type-aware lint rules that don&rsquo;t invoke the TypeScript compiler<\/a>. It has its own inference engine, built with sponsorship from Vercel.<\/p>\n<p>If you&rsquo;ve ever waited on <code>recommendedTypeChecked<\/code> in a large repo you know why this matters. typescript-eslint has to build a full program. That&rsquo;s the single slowest thing in most lint setups, and it&rsquo;s why so many teams turn type-aware rules off and then wonder why floating promises keep reaching production.<\/p>\n<p>Biome&rsquo;s approach trades completeness for speed. Their own numbers on <code>noFloatingPromises<\/code> put it at roughly 85% of the cases typescript-eslint catches, at a fraction of the cost. I&rsquo;ve been running it for a few months and that feels about right. It catches the obvious <code>async<\/code> call with no <code>await<\/code>. It gets confused by promises that travel through a couple of layers of generic wrappers.<\/p>\n<p>I keep going back and forth on whether 85% at 10x the speed is the better trade than 100% that people disable. For a rule like this one I think it clearly is, because the failure mode of the fast version is a missed warning and the failure mode of the slow version is nobody running it at all. For something like <code>no-unsafe-assignment<\/code>, where partial coverage gives you false confidence about untyped data crossing a boundary, I&rsquo;m less sure.<\/p>\n<h2 id=\"whats-still-missing-in-mid-2026\">What&rsquo;s still missing in mid-2026<\/h2>\n<p>Worth knowing before you plan a migration around it. Biome doesn&rsquo;t parse Markdown at all, and the <a href=\"https:\/\/biomejs.dev\/blog\/roadmap-2026\/\" rel=\"nofollow noopener\" target=\"_blank\">2026 roadmap<\/a> says that&rsquo;s blocked on finding someone to champion the work. If Prettier is formatting your docs and MDX, it stays.<\/p>\n<p>YAML formatting isn&rsquo;t shipped yet, though the parser is close. SCSS support started recently. Vue, Svelte, and Astro have what the team calls experimental full support, and they&rsquo;ve publicly acknowledged that the Svelte announcement got ahead of the actual state of things, which honestly made me trust the rest of their claims more.<\/p>\n<p>The team also flagged their own monorepo handling as a mistake. Auto-discovering nested configs sounded nice and produced memory leaks in large repos. The plan is an opt-in <code>workspaces<\/code> field instead. If you&rsquo;re running a big monorepo, read that section before you commit. I hit a related version of this with package manager behavior when I <a href=\"https:\/\/abrarqasim.com\/blog\/pnpm-vs-npm-2026-the-phantom-dependency-i-had-been-shipping\" rel=\"noopener\">tracked down a phantom dependency<\/a> last year, and nested tooling config in monorepos is reliably where the sharp edges live.<\/p>\n<p>Also: <a href=\"https:\/\/eslint.org\/docs\/latest\/use\/configure\/configuration-files\" rel=\"nofollow noopener\" target=\"_blank\">ESLint&rsquo;s flat config<\/a> is a real improvement over eslintrc, and oxlint is moving fast in the same Rust-based direction. This isn&rsquo;t a settled question. Biome has around 15 million monthly npm downloads and 500 lint rules as of v2.5, which is enough that I&rsquo;m comfortable betting on it, but I&rsquo;d revisit in a year.<\/p>\n<h2 id=\"try-this-on-a-small-repo-first\">Try this on a small repo first<\/h2>\n<p>Pick your least important TypeScript project. Run these:<\/p>\n<pre><code class=\"language-bash\">npm i -D --save-exact @biomejs\/biome\nnpx biome migrate eslint --write --include-inspired\nnpx biome migrate prettier --write\nnpx biome check --write .\n<\/code><\/pre>\n<p>Read the diff. Not the summary, the actual diff. You&rsquo;ll learn more about what your old config was doing in fifteen minutes than in a year of it silently passing.<\/p>\n<p>Then make the real decision, which isn&rsquo;t &ldquo;Biome or ESLint.&rdquo; It&rsquo;s: what&rsquo;s the smallest set of ESLint rules I can&rsquo;t get elsewhere, and is keeping ESLint around for those worth the dependency tree? On my Next.js repo the answer was yes, barely. On everything else it was no.<\/p>\n<p>If you want to see how I wire this into CI alongside the rest of a build, I keep notes on that sort of thing in my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">project write-ups<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I moved a Next.js repo from ESLint and Prettier to Biome. Formatting was a clean win, linting was not. Here&#8217;s the config I deleted and what I kept.<\/p>\n","protected":false},"author":2,"featured_media":532,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I moved a Next.js repo from ESLint and Prettier to Biome. Formatting was a clean win, linting was not. Here's the config I deleted and what I kept.","rank_math_focus_keyword":"biome vs eslint","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[165,45],"tags":[296,297,38,598,298,410,63],"class_list":["post-533","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-javascript","category-programming","tag-biome","tag-eslint","tag-frontend","tag-javascript-tooling-2","tag-linting","tag-prettier","tag-typescript"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/533","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=533"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/533\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/532"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=533"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=533"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=533"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}