Linting and Formatting: Biome, ESLint, Prettier, and Pre-Commit Hooks
Linting and Formatting: Biome, ESLint, Prettier, and Pre-Commit Hooks
Code formatting debates waste time. Automate the formatting, enforce it in CI, and never argue about semicolons again. This guide covers the current state of JavaScript/TypeScript linting and formatting tooling, from choosing tools to enforcing them.
Biome vs ESLint + Prettier
The main choice in 2026 is between Biome (one tool for both linting and formatting) and ESLint + Prettier (two tools working together).
| Feature | Biome | ESLint + Prettier |
|---|---|---|
| Speed | Very fast (Rust) | Slow (JavaScript) |
| Config files | 1 (biome.json) | 2-3 (eslint.config.js, .prettierrc, .prettierignore) |
| Formatting | Built-in | Prettier |
| Lint rules | ~300 rules | 1000+ with plugins |
| Plugin ecosystem | Limited | Massive |
| TypeScript | Native | Via typescript-eslint |
| React/JSX | Yes | Via eslint-plugin-react |
| CSS/JSON/etc. | Yes | Prettier handles, ESLint via plugins |
Choose Biome When:
- You want one tool instead of two
- Speed matters (large codebases, CI pipelines)
- You don't need niche ESLint plugins
- You're starting a new project
Choose ESLint + Prettier When:
- You need specific ESLint plugins (accessibility, security, framework-specific rules)
- Your project already uses them and migration isn't worth the effort
- You need fine-grained rule customization
Biome Setup
bun add --dev @biomejs/biome
bunx biome init
This creates biome.json:
{
"$schema": "https://biomejs.dev/schemas/2.0.x/schema.json",
"organizeImports": {
"enabled": true
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
# Format
bunx biome format --write .
# Lint
bunx biome lint .
# Both at once
bunx biome check --write .
Biome's check command runs formatting, linting, and import sorting in a single pass. It's faster than running ESLint and Prettier separately.
ESLint + Prettier Setup (Flat Config)
ESLint moved to "flat config" (eslint.config.js) in v9. If you're still using .eslintrc, migrate -- the old config format is deprecated.
bun add --dev eslint @eslint/js typescript-eslint prettier eslint-config-prettier
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettierConfig from "eslint-config-prettier";
export default [
js.configs.recommended,
...tseslint.configs.recommended,
prettierConfig, // Must be last to override conflicting rules
{
rules: {
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
}],
},
},
];
// .prettierrc
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100
}
eslint-config-prettier disables all ESLint rules that conflict with Prettier. Always include it last in your config array.
Pre-Commit Hooks
Pre-commit hooks run linting and formatting on staged files before each commit. This catches issues before they reach CI.
Husky + lint-staged
The most popular approach. Husky manages Git hooks, lint-staged runs commands only on staged files.
bun add --dev husky lint-staged
bunx husky init
This creates .husky/pre-commit. Edit it:
#!/bin/sh
bunx lint-staged
Configure lint-staged in package.json:
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"biome check --write --no-errors-on-unmatched"
],
"*.{json,md,yaml}": [
"biome format --write --no-errors-on-unmatched"
]
}
}
Or with ESLint + Prettier:
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css,yaml}": [
"prettier --write"
]
}
}
Lefthook
Lefthook is a faster, configuration-file-based alternative to Husky. It's written in Go and doesn't require npm post-install scripts.
# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.{ts,tsx,js,jsx}"
run: bunx biome check --write --no-errors-on-unmatched {staged_files}
stage_fixed: true
format:
glob: "*.{json,md,yaml}"
run: bunx biome format --write --no-errors-on-unmatched {staged_files}
stage_fixed: true
bun add --dev lefthook
bunx lefthook install
Lefthook runs commands in parallel by default and has better performance than Husky + lint-staged for large teams. The stage_fixed: true option automatically re-stages files after auto-fixing.
Trade-offs of Pre-Commit Hooks
Pre-commit hooks have a downside: they add time to every commit. For large repos or slow linters, this friction adds up. Some teams skip pre-commit hooks entirely and rely on CI enforcement, using git commit --no-verify only in emergencies.
A good middle ground: run formatting in pre-commit hooks (fast, auto-fixable) and run linting in CI only (slower, may need manual fixes).
Editor Integration
VS Code
Biome has an official VS Code extension that provides format-on-save and inline diagnostics:
// .vscode/settings.json
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}
For ESLint + Prettier:
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
Commit your .vscode/settings.json to the repository so all team members get the same editor behavior. Use .vscode/extensions.json to recommend the required extensions.
Other Editors
JetBrains IDEs (WebStorm, IntelliJ) have built-in ESLint and Prettier support. Biome support is available via plugin. Neovim users can configure Biome or ESLint through nvim-lspconfig or none-ls.
CI Enforcement
Pre-commit hooks are a convenience, not a guarantee. Developers can skip them with --no-verify. CI is where you actually enforce code quality.
# GitHub Actions
- name: Check formatting
run: bunx biome format --check .
- name: Lint
run: bunx biome lint .
Or with ESLint + Prettier:
- name: Lint
run: bunx eslint .
- name: Check formatting
run: bunx prettier --check .
Note the difference: in CI, use --check (report errors without fixing). In local development and pre-commit hooks, use --write (auto-fix). CI should never modify code -- it should only verify.
Run formatting and linting checks early in your CI pipeline, ideally in parallel with type checking and tests. They're fast enough that there's no reason to gate them behind slower steps.
Keeping Configs Minimal
The biggest mistake with linting configuration is adding too many rules. Every custom rule is a decision your team has to maintain and debate. Start with the recommended preset and add rules only when you encounter real problems.
// Good: minimal biome.json
{
"linter": {
"rules": {
"recommended": true
}
}
}
// Bad: dozens of individually configured rules
// (every rule is a potential debate and maintenance burden)
The same applies to ESLint. Use recommended presets and override sparingly. If you find yourself disabling rules in dozens of files with inline comments, the rule probably doesn't fit your codebase.
Recommendations
- New projects: Use Biome. One tool, one config file, fast execution.
- Existing ESLint + Prettier projects: Keep them unless you're actively frustrated. Migrate to Biome when you'd do a major config overhaul anyway.
- Pre-commit hooks: Use Lefthook or Husky + lint-staged. Keep hooks fast (formatting only, not full linting).
- CI: Always enforce formatting and linting in CI. Never trust pre-commit hooks alone.
- Editor settings: Commit
.vscode/settings.jsonwith format-on-save enabled. Reduce friction so developers don't have to think about formatting. - Config philosophy: Start minimal, add rules only when they prevent real bugs. Delete rules that generate more noise than signal.