Skip to content
Chapter 28Lesson 5

Linting and formatting with Biome

Keeping a JavaScript codebase clean conventionally takes two tools: ESLint to catch bug-shaped code and Prettier to settle formatting, plus the plugins and config that stop them fighting over the same lines. This project installs Biome instead: a single Rust binary, configured by a single biome.json, that lints, formats, and sorts imports in one pass, far faster than the pair it replaces, switching rule sets on automatically based on what is in your package.json. It is the default choice for a new project on this stack.

One thing flips that choice. If your team needs an ESLint plugin that Biome has no equivalent for, ESLint earns its place back. Nothing this course ships depends on such a plugin, so here Biome wins cleanly and you never run the two side by side.

This is the last of the four toolchain walkthroughs, and the last piece of the project’s foundation. Once you can read every field of the provided config and know which daily script to reach for, 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. Next.js 16 removed it. The framework no longer ships or runs a linter, so linting is now the project’s own decision, wired here as Biome pnpm scripts.

If you inherit a Next.js 15 codebase that still leans on next lint, the @next/codemod package ships a next-lint-to-eslint-cli migration; on a fresh project there is nothing to migrate.

Biome lives in the project as a single exact-pinned devDependency:

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

Note the version: 2.4.16, with no caret, while every line around it carries a ^. Biome is pinned to one exact version, just as you pinned Node and pnpm with --save-exact in the lockfile lesson. A formatter may change its output between minor versions, so an unpinned one can reformat the whole codebase the day a teammate runs pnpm install, producing noisy diffs out of nowhere. Pin it, and everyone on the team and in CI formats identically until you bump it on purpose.

The starter’s biome.json came from running pnpm biome init, which drops a minimal config at the repo root. What you are about to read is the curated version of that default, trimmed and adjusted for this stack, so it reflects real decisions rather than a template.

Here is the whole file, forty-three lines you can read top to bottom in one sitting. 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 it autocompletes the config and red-flags any option that does not exist. The 2.4.16 in the URL matches 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 ignores it never lints or formats. That leaves node_modules and .next alone without your 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, where ! entries are negated globs: everything (**) except these. It backs up the ignore rules, keeping the generated next-env.d.ts and the build output out even if a .gitignore rule ever drifts. ignoreUnknown skips file types Biome does not understand instead of warning.

{
"$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. These values match the .editorconfig the project carries from earlier, so Biome and your editor 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 override: single quotes. Line width, trailing commas, and semicolons all take Biome’s defaults. That is the philosophy of this file: state the few things you care about, inherit the rest.

{
"$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 rule set, the floor every project should clear. Then one override: noImgElement is off because this project deliberately renders a raw <img> in its theme-aware image component rather than Next.js’s optimized <Image>, which arrives in the App Router chapters. Override a recommended rule only with a reason you can name.

{
"$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 reads 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 makes import sorting a save-time action, no separate plugin the way ESLint needed eslint-plugin-import. Your editor fires it through the source.organizeImports.biome code action you wired up earlier, so imports reorder themselves the moment you save.

1 / 1

Eight field groups, one rule override, defaults everywhere else. If this config grows much past thirty lines, treat it as a warning sign: someone is usually configuring rules they cannot yet justify.

The config above sets recommended: true and nothing framework-specific, yet Biome still catches Next.js and React mistakes. Domains are why.

A domain is a set of rules for one ecosystem — there is a next domain, a react domain, a test domain, and more — and each one auto-enables when its matching dependency appears in your package.json. This project depends on next and react, so both domains switch on with no line in the config. That is why biome.json stays short: the framework-aware rules key off what you have installed and arrive on their own.

You can pin a domain explicitly when you want a level other than the auto default:

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

The provided config skips this, since auto-enable already gives it what it needs.

You drive Biome through four pnpm scripts from the project’s package.json. Each maps to a moment when you reach for it:

ScriptWhat it doesWhen you run it
pnpm formatFormats only, writing changes in place.Rarely on its own — check does this and more.
pnpm lintLints only, reports problems, writes nothing.When you want to see lint findings without touching files.
pnpm checkFormat and lint and sort imports and apply safe fixes, in one pass.Locally, before you commit. This is the workhorse.
pnpm verifyThe full shippability gate.Before you ship, and in CI.

format (biome format --write .) and lint (biome lint .) are the narrow, single-purpose commands; you reach for them occasionally. check (biome check --write .) is the one you live in, because it formats, lints, sorts imports, and applies safe fixes in a single sweep. Run it before every commit and the diff you push is already clean.

verify is the gate, worth seeing in full because it chains three 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 pass — it is either clean or it stops. Then tsc --noEmit runs the type-check gate from the previous lesson, and next build does a real production build. The && makes each stage wait for the one before to succeed. This is the project’s answer to “is this shippable?”: lint clean, types clean, build green, in that order.

Format-on-save and the import-sorting code action were wired into the editor earlier, but they had no config to obey until now. With biome.json in place, the wiring fires.

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

  2. Break it on purpose: swap single quotes for double, misalign the indentation, add an unused import, and shuffle the import order.

  3. Save. Biome reformats instantly: quotes snap back to single, indentation lands on two spaces, and imports re-sort — all from the formatter and assist fields you just read.

  4. Check the unused import. Saving leaves it (a fix Biome treats as needing your say-so), but a lint diagnostic appears on that line, shown inline by the Error Lens extension instead of buried in a panel.

The formatting half of that save:

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.

Type freely, save, get clean code: formatting stops being something anyone thinks about.

Safe fixes versus unsafe ones. Every --write you have seen applies only Biome’s safe fixes — changes that cannot alter what your program does, like sorting imports or snapping quotes. A second tier, --unsafe, is opt-in and never runs on save, because those fixes can change behavior: rewriting == to === changes the result when the operands differ in type. Reach for --unsafe deliberately on a file you are watching, knowing why it never fires automatically.

Biome runs in CI too. biome ci proves on every pull request that the code meets the floor, so no branch merges past a skipped formatter or an ignored lint error. Folding it into verify catches that locally first, making CI a confirmation rather than a surprise.

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