Skip to content
Chapter 28Lesson 5

Biome, the single-binary linter and formatter

The conventional way to keep a JavaScript codebase clean is two tools wired together: ESLint to catch bug-shaped code, Prettier to settle formatting, plus a pile of plugins and a config to stop the two from fighting over the same lines. This project installs one tool instead. Biome is a single Rust binary, configured by a single biome.json, that lints, formats, and sorts imports — 10 to 25 times faster than the ESLint-plus-Prettier pair it replaces, with rule sets that switch themselves on based on the dependencies already in your package.json. For new SaaS on this stack in 2026, it is the default.

There is exactly one thing that flips that choice. If your team genuinely needs an ESLint plugin that Biome has no equivalent rule or domain for — a niche framework’s custom lint pack, say — then ESLint earns its place back, plugin and all. For everything this course ships, no such plugin is load-bearing, so Biome wins cleanly and you never run the two side by side.

This is the fourth and last of the toolchain walkthroughs. You pinned the package manager and the lockfile, wrote the contributor briefing in AGENTS.md, and read tsconfig.json end to end. Biome is the last piece of the floor. Once you can read every field of the provided config and know which of the four daily scripts to reach for, the foundation is complete and the rest of the chapter is code.

Older Next.js projects had linting handled for them. next lint ran ESLint with a Next-flavored config, and an eslint block in next.config let the build fail on lint errors. Next.js 16 removed both. The framework no longer ships or runs a linter — it expects the project to wire one itself. That removal is precisely why this lesson exists: linting is now your decision to make, not a thing the framework does behind your back, and this project makes it by running Biome directly through pnpm scripts.

If you ever inherit a Next.js 15 codebase that still leans on the old next lint, the @next/codemod package ships a next-lint-to-eslint-cli migration that rewrites the setup for you. Name it if you need it; on a fresh 2026 project there is nothing to migrate.

Biome lives in the project the same way the rest of the toolchain does — as a single exact-pinned devDependency:

package.json
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",

Look at the version: 2.4.16, no caret. Every line around it carries a ^^4.3.0, ^25.9.1 — which means “this minor or any newer compatible one.” Biome carries none. It is pinned to one exact version, the same way you pinned the runtime to Node 24 and the package manager to pnpm 11.3.0 back in the lockfile lesson, using the --save-exact pattern from that same lesson. The reason is identical: a formatter is allowed to change its output between minor versions, and a formatter that reformats your whole codebase the day a teammate happens to run pnpm install is a formatter that produces noisy, mystifying diffs. Pin it, and everyone on the team — and CI — formats with the byte-for-byte same Biome until you bump it on purpose.

One note on where this config came from. The starter was generated by running pnpm biome init, which drops a minimal biome.json at the repo root. What you are about to read is not that raw default, though — it is the curated result, trimmed and adjusted for this stack. The course ships the curated version so you read a config that reflects real decisions, not a template.

Here is the whole thing. It is short on purpose — forty-three lines — and short is the point: a config you can read top to bottom in one sitting is a config the team can agree on without a meeting. Step through it field by field.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

$schema points your editor at the JSON Schema for this exact Biome version, so the editor autocompletes the config file itself and red-flags an option that does not exist. Notice the 2.4.16 in the URL is the same version as the pinned binary — the schema you author against and the binary that runs are locked together.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

vcs tells Biome to read your version control’s ignore rules. With clientKind: "git" and useIgnoreFile: true, Biome honors .gitignore — so anything Git already ignores, Biome never lints or formats. That is why it leaves node_modules and .next alone for free, without you listing them.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

files.includes is the explicit file set, and the ! entries are negated globs — “everything (**), but not these.” It belts-and-suspenders the ignores: next-env.d.ts (the generated, never-edit file from the previous lesson) and the build output stay out even if a .gitignore rule ever drifts. ignoreUnknown tells Biome to silently skip file types it does not understand rather than warn.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

formatter is the Prettier replacement. Two spaces, indent with spaces not tabs — and these two values are chosen to match the .editorconfig the project carries forward from earlier in the course. That alignment is deliberate: when Biome and your editor’s own indentation agree by construction, the two never fight over a file on save.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

javascript.formatter.quoteStyle is the one JS formatting override: single quotes. Everything else about how JS and TS get formatted — line width, trailing commas, semicolons — takes Biome’s defaults. That is the whole philosophy of this file: state the few things you care about, inherit the rest, and stop.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

linter is the ESLint replacement. recommended: true switches on Biome’s curated recommended rule set — the floor every project should clear, no hand-picking. Then exactly one override: noImgElement is turned off because this project deliberately renders a raw <img> in its theme-aware image component, and Next.js’s optimized <Image> is a topic for the App Router chapters that come next, not for this project. That is the discipline to copy — you override a recommended rule only with a reason you can name, and here the reason is written into the lesson that uses it.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

css.parser.tailwindDirectives teaches Biome to understand Tailwind’s @theme and @apply at-rules in globals.css. Without it, Biome would read those non-standard directives as CSS errors; with it, the stylesheet lints clean.

{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!next-env.d.ts", "!.next", "!node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noImgElement": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

assist.actions.source.organizeImports turns import sorting into a save-time action — no separate plugin, the way ESLint needed eslint-plugin-import. This is the action your editor fires through the source.organizeImports.biome code action you wired up earlier in the course; it is why imports reorder themselves the moment you hit save.

1 / 1

That is the entire file. Eight field groups, one rule override, and a defaults-first stance throughout. If this config ever grows past thirty-odd lines, treat it as a warning sign — it usually means someone is configuring rules they cannot yet justify. The recommended preset and the domain defaults you are about to meet should carry almost everything.

The config above lints with recommended: true and nothing framework-specific, yet Biome still catches Next.js and React mistakes. That is because of domains, the headline feature of Biome 2.

A domain is a grouped set of rules for a specific ecosystem — there is a next domain, a react domain, a test domain, and more. The trick is that a domain auto-enables when the matching dependency is in your package.json. Because this project depends on next and react, the next and react domains switch themselves on with no line in the config. That auto-detection is exactly why biome.json can stay as short as it is: the framework-aware rules arrive on their own, keyed off what you have installed.

You can still pin a domain explicitly when you want to control its level rather than take the auto default — for instance, to run the next domain at its recommended level on purpose:

biome.json
"linter": {
"domains": {
"next": "recommended"
}
}

The provided config does not do this, because the auto-enable already gives it what it needs. Reach for the explicit form only when you want a level the default does not give you.

You drive Biome through four pnpm scripts, lifted straight from the project’s package.json. The trick to remembering them is to think about when you reach for each one:

| Script | What it does | When you run it | | --- | --- | --- | | pnpm format | Formats only, writing changes in place. | Rarely on its own — check does this and more. | | pnpm lint | Lints only, reports problems, writes nothing. | When you want to see lint findings without touching files. | | pnpm check | Format and lint and sort imports and apply safe fixes, in one pass. | Locally, before you commit. This is the workhorse. | | pnpm verify | The full shippability gate. | Before you ship, and in CI. |

The first two are the narrow, single-purpose commands. format runs biome format --write . and only touches whitespace; lint runs biome lint . and only reports. You will reach for them occasionally, but checkbiome check --write . — is the one you actually live in, because it does formatting, linting, import sorting, and the safe auto-fixes together in a single sweep. Run it once before every commit and the diff you push is already clean.

verify is the gate, and it is worth seeing in full because it chains three separate tools:

package.json
"verify": "biome ci . && tsc --noEmit && next build"

Read it left to right. biome ci . runs Biome in CI mode — it writes nothing and fails on the first diagnostic, so it cannot quietly fix a problem to make itself pass; it either is clean or it stops. Then tsc --noEmit runs the type-check gate from the previous lesson as its own process. Then next build does a real production build. The && means each stage must succeed before the next runs. This single command is the project’s answer to “is this shippable?” — formatting and lint clean, types clean, build green, in that order.

Here is the payoff. The format-on-save behavior and the import-sorting code action were wired into the editor earlier in the course — but until this lesson they had nothing to act on, because there was no biome.json for them to obey. Now there is, so the wiring finally fires. Watch one round of it.

  1. Open any .tsx file in the project — a component under src/components/ is fine.

  2. Make a mess on purpose: switch some single quotes to double quotes, knock the indentation out of alignment, add an import you do not use, and re-order the imports so they are out of order.

  3. Save the file. The moment you do, Biome reformats it: double quotes snap back to single, indentation lands on two spaces, and the imports re-sort into Biome’s order — all from the formatter and assist fields you just read.

  4. Look at the unused import. Saving does not delete it (that is a fix Biome treats as needing your say-so), but a lint diagnostic appears on that exact line — the Error Lens extension you set up earlier puts the message inline, right where the problem is, instead of hiding it in a panel.

The formatting half of that save looks like this:

import { useState } from "react"
export function Counter() {
const [count, setCount] = useState(0)
return <Button onClick={() => setCount(count + 1)}>{count}</Button>
}

What you typed. Double quotes, four-space indent, no semicolons — all things the config has an opinion about.

That loop — type freely, save, get clean code — is the whole reason the config is worth getting right once. Formatting stops being something anyone thinks about.

Two things an experienced engineer keeps in mind about this setup.

Safe fixes versus unsafe ones. Every --write you have seen — in format, in check — applies only Biome’s safe fixes: changes that cannot alter what your program does. Sorting imports and snapping quotes are safe; they are pure cosmetics. There is a second tier, --unsafe, that is opt-in and never runs by default, because those fixes can change behavior. The textbook case is rewriting == to ===: when the two operands differ in type, that rewrite changes the result. You want to know --unsafe exists so you can reach for it deliberately on a file you are watching — and you want to understand exactly why it is not the thing that fires every time you save.

Biome runs in CI, not just on your machine. The --frozen-lockfile and only-allow pnpm discipline from the lockfile lesson has a direct cousin here: biome ci is the command that proves, on every pull request, that the code meets the floor — no one can merge a branch that skipped the formatter or ignored a lint error. Folding it into verify is how you catch that locally before you push, so CI is a confirmation rather than a surprise. The standalone CI job that runs it on the server comes later in the course; the local gate is in your hands now.

With this, the floor is complete. Node and pnpm pin the runtime and the package manager, AGENTS.md briefs the next contributor, tsconfig.json sets the strictness, and Biome settles formatting and lint — every one of them a decision made once and enforced mechanically from here on. The rest of the chapter is the fun part: the next lessons build the marketing surface — header, hero, feature grid, pricing, footer, theme toggle, and a mobile drawer — on top of this exact foundation.

The lesson read the provided config field by field; these official references are where you go to change it on purpose.