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(viaeslint-plugin-astroandprettier-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.