Skip to content

Evaluate Oxc toolchain (oxfmt + oxlint) as Biome replacement #114

@arzafran

Description

@arzafran

Context

Oxfmt entered beta on 2026-02-24 — a Rust-powered, Prettier-compatible formatter. Combined with oxlint (695 rules, 50-100× faster than ESLint), the Oxc toolchain is now a credible full replacement for Biome.

This issue evaluates migrating from Biome to oxfmt + oxlint.

Current Biome surface in Satus

Capability What Biome does today
Formatting JS/TS/JSX/TSX, CSS/CSS Modules, JSON/JSONC
Linting 60+ explicit rules + domain presets (next, react, project)
Import sorting organizeImports action
Tailwind sorting useSortedClasses nursery rule (error)
Custom plugins 3 GritQL: no-anchor-element, no-relative-parent-imports, no-unnecessary-forwardref
Overrides 5 file-type blocks (CSS, TSX, TS, App Router, CSS Modules)
Inline suppressions 29 biome-ignore comments across codebase
Unified command biome check = format + lint + imports in one pass

Oxc toolchain coverage

oxfmt (formatter)

Capability oxfmt Biome
JS/TS/JSX/TSX ✅ 100% Prettier-compatible ✅ ~97%
CSS/SCSS/Less
JSON/JSONC/JSON5
HTML/Vue/Angular
Markdown/MDX
YAML/TOML/GraphQL
Import sorting ✅ built-in (customizable groups)
Tailwind class sorting ✅ built-in (no plugin) ✅ nursery rule
package.json sorting
CSS-in-JS formatting
--migrate biome N/A
Speed 30× Prettier, 3× Biome Baseline

oxlint (linter)

Capability oxlint Biome
Total rules 695 (108 default, 245 with fixes) ~200
ESLint core ✅ 100+ rules ✅ partial
TypeScript ✅ 100+ rules (via tsgo/TS7) ✅ custom inference
React ✅ full plugin ✅ domain preset
Next.js ✅ plugin (no-img-element, no-async-client-component, etc.) ✅ domain preset
jsx-a11y ✅ 20+ rules ✅ 12 explicit rules
Import rules ✅ full plugin (incl. no-cycle via module graph) ✅ partial
Unicorn ✅ 80+ rules
Type-aware linting ✅ native via tsgo
Auto-fix --fix (245 rules) --write
File overrides ✅ glob-based overrides[]
Config format TOML or TS (defineConfig) JSON
Inline suppression // oxlint-disable-line // biome-ignore
Custom plugins ✅ JS plugins (experimental) ✅ GritQL plugins
Speed 50-100× ESLint ~30× ESLint

Custom GritQL plugin migration — all 3 have native oxlint paths

Our 3 custom Biome GritQL plugins all map to built-in oxlint rules — no custom plugins needed:

1. no-anchor-elementreact/forbid-elements (built-in)

Oxlint's react/forbid-elements rule does exactly what our GritQL plugin does — forbids specific JSX elements with a custom error message suggesting the replacement:

{
  "react/forbid-elements": ["error", {
    "forbid": [{
      "element": "a",
      "message": "Use <Link> from '@/components/ui/link' instead"
    }]
  }]
}

Our GritQL plugin has special allowances for <a> inside the Link component itself (checks for isExternal/isExternalSSR conditionals). With oxlint, this is handled by a file-level override disabling the rule for the Link component file specifically — simpler and more maintainable.

2. no-relative-parent-importsimport/no-relative-parent-imports (built-in)

Direct equivalent. Oxlint's import plugin has this rule built-in:

{
  "import/no-relative-parent-imports": "error"
}

Forbids all ../ imports, requiring @/ path aliases. Our GritQL plugin only flags ../../ (two levels deep) while allowing single ../ for colocated files. The oxlint rule is stricter (all parent imports). We'd need to evaluate if the stricter behavior is acceptable, or use a file override for specific cases.

3. no-unnecessary-forwardrefreact/forward-ref-uses-ref (built-in, inverted)

Oxlint has react/forward-ref-uses-ref which flags forwardRef calls where the ref parameter isn't used. Our GritQL plugin flags ALL forwardRef usage since React 19 makes it unnecessary. Two options:

  • Option A: Use react/forbid-elements approach — not applicable since forwardRef isn't a JSX element
  • Option B: Use import/no-restricted-imports to ban importing forwardRef entirely:
    {
      "import/no-restricted-imports": ["error", {
        "paths": [{ "name": "react", "importNames": ["forwardRef"], "message": "forwardRef is unnecessary in React 19. ref is a regular prop." }]
      }]
    }
  • Option C: Write a lightweight JS plugin (ESLint-compatible API, ~15 lines) if the import restriction approach isn't sufficient

Bottom line: Zero GritQL plugins to maintain. All three become native oxlint config.

Rule-by-rule migration feasibility

Biome rules with direct oxlint equivalents

Biome rule oxlint equivalent Status
noExplicitAny typescript/no-explicit-any
noUnusedImports import/no-unused-imports
noUnusedVariables eslint/no-unused-vars
useExhaustiveDependencies react/exhaustive-deps
noImgElement nextjs/no-img-element
useImportType typescript/consistent-type-imports
useAltText jsx-a11y/alt-text
useValidAriaProps jsx-a11y/aria-props
useValidAriaRole jsx-a11y/aria-role
noDoubleEquals eslint/eqeqeq
noDebugger eslint/no-debugger
noGlobalEval eslint/no-eval
useButtonType react/button-has-type
noParameterAssign eslint/no-param-reassign
useFilenamingConvention unicorn/filename-case
noForEach unicorn/no-array-for-each
useFlatMap unicorn/prefer-array-flat-map

Features handled by oxfmt (no longer lint rules)

Biome feature oxfmt equivalent
useSortedClasses (Tailwind) Built-in Tailwind class sorting
organizeImports Built-in import sorting with custom groups

Operational considerations

Pre-commit (lefthook)

Current (single tool):

biome:
  run: bun biome check --write --unsafe {staged_files}

Proposed (two tools, parallel via lefthook):

format:
  run: bunx oxfmt --write {staged_files}
lint:
  run: bunx oxlint --fix {staged_files}

Both run in parallel via lefthook — no real complexity increase since lefthook already runs biome + typecheck in parallel.

CI (bun run check)

Current: biome check && tsgo --noEmit && bun test
Proposed: oxfmt --check && oxlint && tsgo --noEmit && bun test

Config files

Current: 1 file (biome.json, 266 lines) + 3 GritQL plugins
Proposed: 2 files (oxfmt.toml + oxlint.config.ts), zero plugins

Recommendation

This is worth a spike. The Oxc toolchain brings real advantages:

  1. 695 rules vs ~200 — significantly broader coverage including unicorn, full import plugin, type-aware linting via tsgo
  2. Type-aware linting — Biome uses custom type inference; oxlint uses actual tsgo. Enables rules like no-floating-promises that Biome can't do
  3. Multi-file analysis — Module graph enables import/no-cycle without performance cliffs
  4. All 3 custom GritQL plugins become native config — zero custom plugin maintenance
  5. Future-proof — Oxc ecosystem is actively converging (same parser, same resolver, same type checker across tools)
  6. Speed — 3× faster formatting + 50-100× faster linting (vs ESLint baseline)

Key risk: oxlint JS plugins are still "technical preview" — but since all 3 GritQL use cases map to built-in rules, this risk is mitigated. The main remaining risk is oxfmt beta stability.

Tasks

  • Run oxfmt --migrate biome and diff output against current formatting
  • Benchmark oxfmt vs biome format on full codebase
  • Map all 60+ Biome rules to oxlint equivalents (start from table above)
  • Test Tailwind class sorting parity in oxfmt (cn()/clsx() function support)
  • Test import sorting groups match current order (React → third-party → @/ → relative → CSS)
  • Prototype oxlint.config.ts covering all current Biome lint rules + overrides
  • Validate react/forbid-elements blocks <a> with Link suggestion
  • Validate import/no-relative-parent-imports — evaluate stricter behavior (all ../ vs just ../../)
  • Validate forwardRef ban via restricted imports or equivalent
  • Prototype lefthook config with dual-tool setup
  • Migrate 29 biome-ignore comments to oxlint-disable-line
  • Run bun run build with full Oxc toolchain, verify no regressions
  • Verify CSS Modules linting/formatting compatibility

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions