Using Biome as an All-in-One Linter and Formatter in CI

Using Biome as an All-in-One Linter and Formatter in CI

Biome is a high-performance JavaScript and TypeScript toolchain that replaces ESLint, Prettier, and several other tools with a single binary. It is written in Rust, has near-zero configuration overhead, and runs lint checks 10–100× faster than the Node.js equivalents. This guide covers everything you need to integrate Biome into a CI pipeline: rule configuration, unsafe auto-fixes, and migrating an existing ESLint project.

Why Biome in CI?

The classic JavaScript toolchain for code quality involves at least three tools: ESLint for linting, Prettier for formatting, and often a pre-commit hook tool like lint-staged. Each requires separate configuration files, plugins, and careful version pinning. Biome collapses this into one binary with one config file.

Key advantages for CI:

  • Single binary, no plugin ecosystem to maintain
  • Structured JSON/JSONC config (biome.json) — no plugin resolution
  • Sub-second lint and format checks on large codebases
  • First-class TypeScript support without @typescript-eslint
  • Deterministic output across platforms (no env-specific Prettier quirks)

Installation

# npm
npm install --save-dev --save-exact @biomejs/biome

<span class="hljs-comment"># pnpm
pnpm add --save-dev --save-exact @biomejs/biome

<span class="hljs-comment"># bun
bun add --dev --exact @biomejs/biome

Use --save-exact to pin the version — Biome's rule set evolves between minor versions and you want reproducible CI runs.

Generate the default config:

npx @biomejs/biome init

This creates biome.json at the project root.

Configuration Overview

A production-ready biome.json for a TypeScript React project:

{
  "$schema": "https://biomejs.dev/schemas/1.8.0/schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": {
        "noUnusedVariables": "error",
        "noUnusedImports": "error"
      },
      "suspicious": {
        "noExplicitAny": "warn",
        "noConsoleLog": "warn"
      },
      "style": {
        "useConst": "error",
        "useTemplate": "error",
        "noNonNullAssertion": "warn"
      },
      "performance": {
        "noAccumulatingSpread": "error"
      },
      "a11y": {
        "useAltText": "error",
        "noAutofocus": "warn"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100,
    "lineEnding": "lf"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "trailingCommas": "es5",
      "semicolons": "always"
    }
  },
  "files": {
    "ignore": [
      "node_modules",
      "dist",
      ".next",
      "coverage",
      "*.min.js"
    ]
  }
}

Rule Severity Levels

Each rule accepts "off", "warn", or "error". Use "error" for anything that should block a CI merge. Use "warn" for rules you want visible but not blocking during a migration period.

Per-File Overrides

Override rules for specific file patterns:

{
  "overrides": [
    {
      "include": ["**/*.test.ts", "**/*.spec.ts"],
      "linter": {
        "rules": {
          "suspicious": {
            "noExplicitAny": "off"
          }
        }
      }
    },
    {
      "include": ["scripts/**/*.js"],
      "linter": {
        "rules": {
          "suspicious": {
            "noConsoleLog": "off"
          }
        }
      }
    }
  ]
}

CI Integration

GitHub Actions

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  biome:
    name: Lint & Format
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run Biome check
        run: npx @biomejs/biome ci --reporter=github .

The ci command combines lint + format checks and exits with a non-zero code on any violation. The --reporter=github flag annotates pull request diffs directly in GitHub's review UI.

Separate Lint and Format Jobs

For more granular CI feedback:

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Lint
        run: npx @biomejs/biome lint --reporter=github .

  format:
    name: Format
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Check formatting
        run: npx @biomejs/biome format --check .

GitLab CI

biome:
  image: node:20-alpine
  stage: lint
  cache:
    paths:
      - node_modules/
  script:
    - npm ci
    - npx @biomejs/biome ci .

Auto-Fix in CI

Biome can write fixes to disk. In CI you typically want to apply only safe fixes automatically:

# Apply safe fixes and stage the changes
npx @biomejs/biome check --apply .
git diff --exit-code  <span class="hljs-comment"># fail if there are uncommitted changes

This pattern is useful in a "commit bot" workflow where CI applies fixes and opens a PR. If you want CI to fail hard instead of auto-fixing, use --apply locally in pre-commit hooks and use biome ci (check only) in the pipeline.

Unsafe Fixes

Biome classifies auto-fixes into two categories:

  • Safe fixes — applied by --apply. They preserve semantics (e.g., removing unused imports, adding missing const).
  • Unsafe fixes — applied by --apply-unsafe. They may change runtime behaviour (e.g., collapsing optional chains, reordering class members).

Example of a rule that requires unsafe fix — noParameterAssign:

// Before
function greet(name: string) {
  name = name.trim()  // noParameterAssign violation
  return `Hello, ${name}`
}

// After unsafe fix
function greet(name: string) {
  const trimmedName = name.trim()
  return `Hello, ${trimmedName}`
}

Use --apply-unsafe selectively. The recommended CI workflow:

  1. Apply safe fixes in pre-commit (local developer machines).
  2. Run biome ci in CI — fail on any remaining violation.
  3. Reserve --apply-unsafe for a dedicated clean-up branch when migrating legacy codebases.
# Local pre-commit hook (via simple-git-hooks or husky)
npx @biomejs/biome check --apply $(git diff --name-only --cached)

Migrating from ESLint

Biome ships a migration command that reads your ESLint config and generates an equivalent biome.json:

npx @biomejs/biome migrate eslint --write

This command:

  1. Reads .eslintrc.* or eslint.config.* (flat config)
  2. Maps supported ESLint rules to Biome equivalents
  3. Prints a report of rules it could not migrate (unsupported plugins, custom rules)

What Gets Migrated Automatically

ESLint Rule Biome Equivalent
no-unused-vars correctness/noUnusedVariables
eqeqeq suspicious/noDoubleEquals
no-console suspicious/noConsoleLog
prefer-const style/useConst
no-var style/noVar
@typescript-eslint/no-explicit-any suspicious/noExplicitAny

What Doesn't Migrate

  • Custom ESLint plugins without a Biome equivalent (e.g., eslint-plugin-react-hooks rules like exhaustive-deps)
  • Rules implemented in ESLint's plugin ecosystem that Biome hasn't yet replicated

For plugins without a Biome equivalent, you have two options:

  1. Accept the gap and rely on TypeScript's type-checker to catch those categories of bugs.
  2. Run ESLint in parallel for just those rules while using Biome for everything else.

Parallel Migration Strategy

// biome.json — handles formatting + most linting
{
  "linter": { "enabled": true, "rules": { "recommended": true } },
  "formatter": { "enabled": true }
}
// .eslintrc.json — handles only unmigrated rules
{
  "extends": [],
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}
# CI runs both tools
- name: Biome check
  run: npx @biomejs/biome ci .

- name: ESLint (react-hooks only)
  run: npx eslint --no-eslintrc -c .eslintrc.json --ext .ts,.tsx src/

This strategy lets you remove Prettier and most ESLint rules immediately while keeping the hooks plugin until Biome ships its own implementation.

Migrating from Prettier

npx @biomejs/biome migrate prettier --write

This reads your Prettier config (prettierrc.*, .prettierignore) and maps settings to biome.json. After migration, remove the prettier package and any prettier-eslint integrations from package.json.

Editor Integration

Biome has a VS Code extension (biomejs.biome) and LSP support for Neovim via biome lsp-proxy. With the extension installed, format-on-save uses Biome instead of Prettier automatically for projects that have a biome.json.

// .vscode/settings.json
{
  "editor.defaultFormatter": "biomejs.biome",
  "editor.formatOnSave": true,
  "[javascript]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[typescript]": {
    "editor.defaultFormatter": "biomejs.biome"
  }
}

Performance Comparison

On a representative TypeScript monorepo (400 files, ~50k lines):

Tool Task Time
ESLint + Prettier Full lint + format check ~12s
Biome Full lint + format check ~0.4s
Biome Changed files only (pre-commit) ~50ms

The speed difference matters most in pre-commit hooks where developer feedback latency is felt directly, and in CI where parallel jobs have startup overhead that dominates short checks.

Practical Recommendations

Start with recommended: true and suppress specific rules you can't fix immediately using // biome-ignore lint/suspicious/noExplicitAny: legacy code. Track suppressions in a follow-up ticket.

Run biome ci in PR checks, not biome check --apply. Auto-applying fixes in CI creates noisy commits and obscures the real state of the codebase.

Use exact version pinning (--save-exact). Biome's rule set evolves frequently; unexpected CI failures after a ^ upgrade are frustrating.

Integrate Biome diagnostics with your test runner. If you use HelpMeTest for end-to-end quality gates, you can add a Biome check health monitor that alerts your team when lint violations accumulate in a branch — keeping code quality visible without blocking local development.

Biome is production-ready for most TypeScript and JavaScript projects today. The migration from ESLint and Prettier is straightforward for projects using standard configurations and pays off immediately in faster CI feedback loops.

Read more