A glossy 3D-rendered knife block holding a chef's knife and a bread knife under red-orange and cyan neon rim lighting on a dark reflective floor, with the post title in bold condensed white capitals.

A Hybrid Biome + ESLint/Prettier Setup

Introduction

I wanted to delete ESLint and Prettier from this website’s repo and run one tool: Biome. One binary, one config file, one thing to keep updated. I couldn’t do it—not fully—and the reason turned out to be interesting enough to write down.

The short version, if you want to stop after this paragraph: Biome owns my TypeScript, JavaScript, JSON, and CSS. ESLint and Prettier own my .astro files, because their Astro plugins are mature and Biome’s framework-file support is still marked experimental. Prettier alone owns Markdown, because Biome doesn’t touch Markdown at all. And a composite pnpm lint script hides the arrangement so I don’t have to remember which tool runs on which file, and neither will future me.

The rest of the post is the reasoning, the config boundaries, and the two places I tripped.

Why Biome at All

Biome’s pitch is simple: one tool replaces both ESLint (linting—catching bugs and bad patterns) and Prettier (formatting—making the code look consistent) for the file types it supports, with near-zero configuration and a really absurd speed difference. The Biome project’s benchmark, for example, formats about 171,000 lines across 2,100 files roughly 35 times faster than Prettier. At that scale, the difference is a Prettier run long enough to pick up your coffee, take a sip, and put it back down. Biome finishes before your hand leaves the keyboard.

And switching isn’t a compatibility gamble. In late 2023, Prettier co-creator Christopher Chedeau put up a $10,000 bounty for any Rust-based formatter that could pass more than 95% of Prettier’s test suite; Guillermo Rauch matched it, napi.rs chipped in another $2,500, and the pot came to $22,500. Biome won it at over 96% compatibility. So switching your formatting to Biome doesn’t mean relearning what your code looks like. On all but a handful of edge cases you get the same output, delivered about 35 times faster.

Biome is also my default across my projects. I have a shared GitLab CI component that runs it, my editor setup assumes it, and each new repo starts with a biome.json. So when I rebuilt this website, “just use Biome” was the plan, not a question.

To keep it fair: Biome is younger than the tools it replaces. Its plugin ecosystem is thinner than ESLint’s decade of accumulated rules, and—the part this post is about—its support for framework files lags behind. Full support today covers JS/TS/JSX/TSX, JSON, CSS, and GraphQL. Markdown isn’t supported at all. Framework files like .astro and .svelte sit in a middle category we’ll get to now.

The Wall: Framework Files

Astro and Svelte files aren’t plain JavaScript. They’re bespoke formats—a .astro file, for example, has a frontmatter fence of TypeScript up top, then a templating syntax that looks like JSX but isn’t quite, and maybe a <style> block below that. Formatting one correctly means parsing three languages stitched together in one file, and getting the stitching right. That stitching problem is why the community built dedicated tooling: eslint-plugin-astro and the official prettier-plugin-astro, each of which understands the format natively.

Now, a correction to my thesis, because the ground moved while I was standing on it. When I first sketched this post, the argument was simple: Biome couldn’t parse these files, full stop—which is no longer true. Biome v2.3 (October 2025) shipped Vue, Svelte, and Astro support—script, style, and markup. That support is officially experimental, however, hidden behind an html.experimentalFullSupportEnabled flag, with documented gaps—Svelte’s control-flow syntax and Astro’s JSX-like templating, for example—plus a batch of lint rules (useConst, noUnusedVariables, useImportType, and friends) that throw false positives on framework files unless you switch them off.

So the question stopped being “can Biome parse my Astro files?” and became “do I trust it to?” And I don’t—yet. That distrust isn’t theoretical caution. In my design-system repo, I let Biome’s partial Svelte support format .svelte files once, and it mangled the component markup—not a style disagreement, but broken output. That experience bought the experimental flag a long probation period.

The picture I keep coming back to is a knife block. Biome is a chef’s knife: it handles ninety percent of what happens in the kitchen, fast, and you barely think about it. But a chef’s knife crushes a crusty loaf of bread. For that you want the bread knife—slower, specialized, and right for the one job. No cook thinks the existence of the bread knife is a failure of the chef’s knife. You just keep both in the block.

The plugins are the bread knife. They’re solid, they’re maintained, and they work on my files today. I wasn’t going to leave .astro files unlinted while I waited for the experimental flag to graduate.

The Hybrid Split

So the division of labor in this repo is a three-way split, not two:

  • Biome.ts, .js, .json, .css (lint and format)
  • ESLint + Prettier.astro (via eslint-plugin-astro and prettier-plugin-astro)
  • Prettier alone.md (Biome has no Markdown support, so Prettier keeps a third lane all to itself)

That Markdown lane is pretty easy to forget, and it’s half the reason Prettier can’t leave. Even the day Biome’s Astro support graduates, something will still have to format this blog post.

The part that makes a hybrid livable is the boundary. Two formatters with overlapping jurisdiction will fight wherever their opinions differ—one rewrites a file, the other rewrites it back, and your git diff becomes a coin flip. So each tool gets an explicit territory, declared in config.

On the Biome side, we use files.includes in biome.json with negated globs to fence off what Biome doesn’t own:

// biome.json (the boundary-relevant parts)
{
"vcs": { "useIgnoreFile": true },
"files": {
"includes": [
"**",
"!**/*.astro",
"!**/*.svelte",
"!apps/website/src/content/post"
]
}
}

The .svelte exclusion is belt-and-suspenders—this repo doesn’t contain any Svelte components (those live in my design-system repo, which formats them with Prettier), but the fence costs one line and I’d rather have it. The content/post exclusion is more interesting: my blog posts live in a separate git submodule, and I don’t want a website-repo formatting pass rewriting files that belong to another repository. Formatting someone else’s checkout from your repo is a good way to confuse two git histories at once.

On the Prettier side, .prettierignore mirrors the same split from the opposite direction—it excludes the files Biome owns, and its header comment literally spells out which tool owns which extensions, so the next person to open it doesn’t have to reverse-engineer the treaty.

That file also handed me my favorite bug of the setup. I wanted to exclude .mdx files (my case studies, which embed JSX that Prettier’s Markdown mode doesn’t love), and I wrote the glob **/*.mdx?. Looks harmless. But in glob syntax, ? means “one optional character”—so that pattern matches .mdx and .md. One stray question mark, and Prettier skipped the Markdown files in the repo while reporting success. No error, no warning, just unformatted prose for who knows how long. The fix was deleting one character. Finding it was not proportional to the fix.

Tying It Together: One Command

The rule that makes a hybrid setup bearable instead of miserable is that the scripts remember which tool owns which file so that I don’t have to—and neither does CI, or a contributor six months from now.

The composite scripts in the root package.json are the single mail slot in an office building’s front door. Behind the slot sits a sorter who routes each envelope to the right desk. The people mailing things don’t need to learn the floor plan—they just use the slot. Mine looks like this:

// package.json (the actual scripts from this repo)
{
"scripts": {
"lint": "biome check && pnpm --filter @kdd/website lint:astro && pnpm format:md:check",
"lint:fix": "biome check --write && pnpm --filter @kdd/website lint:astro --fix && pnpm format:md",
"format": "biome format --write && pnpm --filter @kdd/website format:astro --write && pnpm format:md",
"format:md": "prettier --write \"**/*.md\"",
"format:md:check": "prettier --check \"**/*.md\""
}
}

The sub-scripts down in apps/website/package.json are one-liners—lint:astro runs eslint "**/*.astro" and format:astro runs Prettier over the same glob. The important detail is that the fix-mode flags flow all the way through: lint:fix passes --fix to the Astro ESLint run and --write to Biome, so a full fix is really one command, not one command plus two you have to remember exist.

The same routing happens at commit time via lint-staged (a tool that runs commands against only the files staged for commit): .astro files get eslint --fix plus prettier --write, TypeScript/JavaScript/JSON/CSS get biome check --write --no-errors-on-unmatched, and Markdown gets Prettier—three lanes, one git commit.

One rule keeps all of this honest: the lint has to stay green and CI-blocking. Zero errors and zero warnings, enforced in the pipeline, before any task is done. A lint setup that’s allowed to stay yellow trains people to ignore it, and a lint setup people ignore is worse than none—it’s noise wearing the costume of a safety net.

Editor + CI Wiring

In VS Code, the split maps onto per-language default formatters: the Biome extension formats the files Biome owns, and Prettier handles .astro and .md. Set it once in .vscode/settings.json and commit it, and each clone of the repo will get the same behavior.

One trap here deserves a paragraph to itself, because it cost me a confused evening in another project. VS Code’s editor.codeActionsOnSave setting merges across scopes—user settings, workspace settings, per-language blocks all stack rather than override. I had a global source.organizeImports action from an older setup, and once Biome arrived with its import organizer, each save triggered both: one sorted the imports, the other sorted them differently, and the file flickered between two orderings on alternating saves. The diagnosis that finally cracked it: two passes of the same tool with the same config can’t oscillate—if your file won’t settle, there are two different tools in the ring. The fix is explicitly setting "source.organizeImports": "never" inside each per-language block, so a single organizer holds the pen.

For CI, this repo’s .gitlab-ci.yml is a thin include of my shared static-site template, which pins the same lint-biome component I use across my repos (more on those in DRY GitLab Pipelines with Shared CI/CD Components). The pipeline runs the same composite checks the pre-commit hook runs. That symmetry is the point: “works on my machine, fails in CI” usually means two environments running two different definitions of clean, so this repo keeps one definition and runs it in two places.

Would I Consolidate Later?

Happily, yes. The exit plan is written into the setup: the day Biome’s Astro support drops the experimental label and stops needing rule exemptions to avoid false positives, the setup collapses back toward one tool—I delete the ESLint config, thin out .prettierignore, and simplify the composite scripts. Biome’s 2026 roadmap has framework-file support squarely in view, and v2.4 already improved the parsers, so I think consolidation is a “when,” not an “if.” Prettier keeps its Markdown lane until Biome learns Markdown, but that’s one small script, not a parallel toolchain.

I’ll be checking the release notes against one specific memory: the mangled Svelte markup. Trust that gets broken by a formatter rewriting your components gets rebuilt on someone else’s files first.

The principle underneath: adopt the better tool everywhere it’s ready, and don’t force it anywhere it isn’t. Tool loyalty is not an engineering value.

Conclusion

The setup, one more time: Biome for TypeScript, JavaScript, JSON, and CSS. ESLint plus Prettier for .astro, where the dedicated plugins are still the trustworthy option. Prettier solo for Markdown, which Biome doesn’t speak. Explicit fences in biome.json and .prettierignore so no two tools touch the same file, and composite lint, lint:fix, and format scripts so you don’t have to know the fences exist.

The bigger lesson is that tooling decisions are rarely all-or-nothing, even when the marketing wants them to be. “One tool for everything” is a great destination and a bad requirement. A clean hybrid—each tool on the files it’s really good at, hidden behind one command—beats a dogmatic monoculture that mangles your markup. Keep the chef’s knife and the bread knife in the same block, and drop the mail through the one slot.

That last sentence mixed two analogies. The lint script would never have let that through.