Moving from ESLint and Prettier to Oxlint and Oxfmt
On my project ComptaOpen, a full ESLint run took more than a minute locally, and above all it used a huge amount of RAM. The kind of step you end up running only in CI, where it still took 42 seconds.
I even got into the habit of linting only the changed files locally, so the verdict on the whole project only came at push time, from GitHub Actions.
I know, I'm pushing it 😒😂🤣
On the formatting side, there was no shared style: out of nearly 1,200 TypeScript files, 559 imported with double quotes, 525 with single quotes, and so on.
I fixed both in the same evening with two tools from the Oxc suite:
- Oxlint in place of ESLint,
- Oxfmt as the formatter.
A full lint now takes 2 seconds.
Let's go through it in more detail.
Oxc in a nutshell
Oxc, short for JavaScript Oxidation Compiler, is a suite of JavaScript and TypeScript tools written in Rust: parser, linter, formatter, transformer, minifier, resolver. The project is backed by VoidZero, the team behind Vite, and already powers Rolldown.
Two of these tools replace building blocks every JS project knows:
- Oxlint, the linter, stable since version 1.0. More than 870 native rules: those of ESLint, typescript-eslint, React and its hooks, jsx-a11y, import, Next.js, and more. The project claims a linter 50 to 100 times faster than ESLint.
- Oxfmt, the formatter, in beta (0.x versions). It takes Prettier's output as its reference and passes 100% of Prettier's JavaScript and TypeScript conformance tests. Tailwind class sorting and import sorting are built in, no plugin needed. The project claims about 30 times Prettier's speed.
Both install like any npm package. Nothing else in the toolchain changes: Next.js, Turbopack and Vitest stay in place.
Before migrating: probes
Whether Oxlint was faster was never the question. The question was: does it still catch what ESLint catches for me?
Before touching package.json, I had to make sure Oxlint caught every error and warning that stock ESLint reported. So I wrote three throwaway files full of deliberate violations, one per rule that matters for the project. Then I ran both linters on them, Oxlint through pnpm dlx with the config generated by the official migration tool, and compared.
| Deliberate violation | ESLint | Oxlint, migrated config |
|---|---|---|
setState called directly in an effect | caught | caught |
Date.now() during render | caught | caught |
ref.current read during render | caught | caught |
Hook inside an if | caught | caught |
| Missing effect dependency | caught | caught |
Raw <img> without alt (two rules) | caught | caught |
| Unused variable | caught | caught |
| Next.js or React dependency in a portable package (two probes) | caught | caught |
Deep import through a path alias (@/utils/cn) | caught | nothing |
Deep relative import against the dependency direction (../bj/paie/compute) | caught | nothing |
ESLint: 12 out of 12. Oxlint: 10 out of 12.
The first three rows are the rules that came from the React Compiler (set-state-in-effect, purity, refs), the newest ones in eslint-plugin-react-hooks. Oxlint runs them natively, without a JS plugin. That is what convinced me to keep going. Which left the two holes at the bottom of the table.
The trap: * does not descend into subfolders
Both holes have the same cause: a * in the no-restricted-imports patterns.
My goal here was to forbid the @/ path alias inside a folder meant to be, and to stay, portable.
{
group: ["@/*", "@@/*"],
message: "src/lib/{core,bj}/ must stay portable: no project alias.",
},-
In ESLint, the pattern applies recursively:
@/*matches@/utils, and everything under@/utilsis blocked along with it, at any depth (@/utils/cn,@/utils/format/date). -
Oxlint reads the same pattern as a glob, without recursion:
*covers a single level and stops at the first/. So it blocks@/utils, but lets@/utils/cnor@/components/ui/buttonthrough, along with every deeper import. In short, the rule blocks almost nothing anymore.
The migration tool copies the patterns as they are, without a warning. CI stays green, since the existing code already follows the rule. As long as nobody writes the forbidden import, a rule that no longer blocks anything is indistinguishable from a rule that works.
The fix: /** instead of /*, in every pattern.
{
"group": ["@/**", "@@/**"],
"message": "src/lib/{core,bj}/ must stay portable: no project alias.",
}With these patterns, the probes come back 12 out of 12.
If your ESLint config uses * in no-restricted-imports, go back over each pattern after the migration and test it with a probe: a deliberate deep import that Oxlint must block.
The migration
The official tool, @oxlint/migrate, reads a flat ESLint config and writes a .oxlintrc.json:
pnpm dlx @oxlint/migrate eslint.config.mjsOn ComptaOpen: 80 rules converted, 7 dropped, none that I needed. The tool also warns that the eslint-config-next parser is replaced by Oxlint's own, and that some ESLint settings are ignored.
The generated file works, but it is verbose. It copies the browser globals one by one, where "env": { "browser": true } is enough, and it lists every rule the original config merely turned off. I rewrote it by hand from that base, then fixed the patterns.
Then, the swap:
pnpm add -D oxlint
pnpm remove eslint eslint-config-next @eslint/eslintrc
rm eslint.config.mjs"scripts": {
"lint": "oxlint"
}The CI Lint step still calls pnpm lint, so it did not change. The lockfile loses 270 packages.
That leaves the disable comments. Oxlint understands eslint-disable, but rule names change: react-hooks/set-state-in-effect becomes react/set-state-in-effect, @next/next/no-img-element becomes nextjs/no-img-element. The migration tool's --replace-eslint-comments option takes care of it. I converted everything to oxlint-disable with Oxlint rule names, and removed the ones that no longer hid anything.
Read the documentation: https://oxc.rs/docs/guide/usage/linter
Oxfmt: one style for the whole project
Oxfmt takes Prettier's options and produces the same output, give or take a few lines, but it is not Prettier rewritten end to end. JavaScript, TypeScript, JSON, CSS, YAML, TOML, GraphQL and Markdown go through its native engine, written in Rust. HTML, Vue, Svelte, Astro and MDX are delegated to a Prettier bundled in the npm package, which needs Node.js. The standalone binary published on GitHub therefore skips those files: to format everything, you need the npm package.
On the style side, it fixes the problem from the intro: one kind of quote across the codebase, except for a string that already contains that quote, where it keeps the other one to avoid an escape. What required plugins with Prettier is built in. Tailwind class sorting uses the prettier-plugin-tailwindcss algorithm. With Tailwind v4, where the config lives in CSS, the stylesheet option points to that file so Oxfmt knows the project's custom utilities. Import sorting stays off by default. package.json sorting, on the other hand, is on out of the box: that file will show up in the diff of the first run.
Measured on the project's 1,200 files, in --check mode:
| Prettier | Oxfmt | Gap | |
|---|---|---|---|
| Without Tailwind sorting | 18.6 s | 0.5 s | about 37 times |
| With Tailwind sorting | 22.1 s (with prettier-plugin-tailwindcss) | 2.4 s | about 9 times |
VS Code: the Oxc extension
To get the same rules in the editor as in CI, you need the Oxc extension (oxc.oxc-vscode) from the VS Code marketplace. It also works in Cursor and other VS Code forks. One extension for both tools: Oxlint diagnostics in the code, and formatting by Oxfmt. It uses the project's Oxfmt, so Oxfmt has to be installed as a dev dependency, not globally.
To have the whole team prompted to install it when they open the project:
{
"recommendations": ["oxc.oxc-vscode"]
}Then it has to be set as the default formatter. The documentation suggests this:
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}The trap: VS Code gives a language-specific setting priority over a general one, even when the first comes from your user settings and the second from the workspace. Set "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } in your user settings for your other projects, and Oxc is ignored without a single message: the file is formatted by Prettier on save, and CI rejects it on push.
The workaround: one block per language in the workspace.
{
"[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[css]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[tailwindcss]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[json]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[yaml]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[dockercompose]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
"[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
}The tailwindcss, dockercompose and github-actions-workflow blocks are not overkill. Depending on the extensions installed, some files get an unexpected language identifier: tailwindcss for a .css file, dockercompose for a Docker Compose file, github-actions-workflow for a GitHub workflow. The oxfmt command formats all of these files. Without the matching block, the editor does not format them on save, and the mismatch only shows up in CI.
The outcome
| CI step | Before | After |
|---|---|---|
| Lint | 42 s | 2 s |
| Format check | 22 s | 2 s |
Beyond the time: 270 fewer packages in the lockfile, a single style across the codebase, and the same rules in the editor and in CI. A full lint runs locally again, on the whole project, without a second thought.
Things to keep in mind
Oxfmt is in beta. It does not load Prettier plugins. The most common ones have a built-in equivalent: Tailwind, Svelte, Astro, import sorting and package.json sorting. For any other plugin, check that Oxfmt covers the need before migrating.
Oxlint's JS plugins are in alpha. They let you load ESLint plugins that have no native equivalent. If your config depends on them, test them before removing ESLint.
Type-aware linting was not at stake here. eslint-config-next enables no rule that needs type information, so I lost nothing. If your config uses no-floating-promises and friends, Oxlint has a type-aware mode, built on the native TypeScript port: check where it stands before migrating.
My probes cover the rules that matter for this project, not all 80 rules one by one. That is a deliberate choice: I wanted to check the rules whose absence would cost me something.
The next bottleneck: TypeScript
Type checking is now the longest step in CI, by far.
TypeScript 7, the compiler rewritten in Go, came out in July 2026, and Next.js 16.3 already accepts it for type checking. What held me back was ESLint. eslint-config-next bundles typescript-eslint, which relies on TypeScript's programmatic API, missing from 7.0 and planned for 7.1. In the meantime, typescript-eslint 8.71 requires typescript <6.1.0.
Oxlint has its own parser and does not depend on typescript: the blocker went away with ESLint. Next step, TypeScript 7.
Sources: