Our frontend monorepo had grown to 6,670 files and 206K lines of TypeScript/React code across 8 NX projects. Linting and formatting worked, but the machinery behind it had become a maintenance burden. We decided to rip out ESLint + Prettier and replace them with Oxlint + Oxfmt — two Rust-based tools from the OXC project.
Here's how it went.
The Problem: Config Sprawl
Over time, our ESLint setup had sprawled into 10 separate .eslintrc.js files — one at the root, plus one per app and package. Each extended a shared base but layered on project-specific overrides for TypeScript paths, parser options, and rule tweaks.
The dependency list told the story:
@graphql-eslint/eslint-plugin
@nx/eslint
@nx/eslint-plugin
@typescript-eslint/eslint-plugin
@typescript-eslint/parser
eslint
eslint-config-prettier
eslint-import-resolver-typescript
eslint-plugin-graphql
eslint-plugin-import
eslint-plugin-jsx-a11y
eslint-plugin-prettier
eslint-plugin-react
eslint-plugin-react-hooks
eslint-plugin-storybook
eslint-plugin-unused-imports
prettier
prettier-eslint18 npm packages just to lint and format. Each came with its own version constraints, peer dependency requirements, and config surface area.
Our lint-staged config was 45 lines of JavaScript that routed files to the correct ESLint config based on their path:
// Before: lint-staged.config.js (45 lines)
module.exports = {
'*.{js,jsx,ts,tsx}': files => {
const eslintBatches = {};
const prettierCommands = [];
files.forEach(file => {
const eslintConfig = (() => {
if (file.includes('apps/pay-platform/')) {
return 'apps/pay-platform/.eslintrc.js';
} else if (file.includes('packages/fe-core/')) {
return 'packages/fe-core/.eslintrc.js';
} else if (file.includes('packages/fe-utils/')) {
return 'packages/fe-utils/.eslintrc.js';
} else if (file.includes('packages/sk-ui/')) {
return 'packages/sk-ui/.eslintrc.js';
} else if (file.includes('packages/ui-components/')) {
return 'packages/ui-components/.eslintrc.js';
} else if (file.includes('packages/ui-assets/')) {
return 'packages/ui-assets/.eslintrc.js';
}
return '.eslintrc.js';
})();
if (!eslintBatches[eslintConfig]) {
eslintBatches[eslintConfig] = [];
}
eslintBatches[eslintConfig].push(`"${file}"`);
});
const eslintCommands = Object.entries(eslintBatches).map(
([config, files]) =>
`eslint --config ${config} --fix ${files.join(' ')}`,
);
const filesStr = files.map(file => `"${file}"`).join(' ');
prettierCommands.push(`prettier --write ${filesStr}`);
return [...eslintCommands, ...new Set(prettierCommands)];
},
'*.{css,scss,html,json,md}': files => {
const filesStr = files.map(file => `"${file}"`).join(' ');
return `prettier --write ${filesStr}`;
},
};Every time someone added a new package to the monorepo, they had to remember to update this routing table. Nobody ever did on the first try.
Why Oxlint + Oxfmt
OXC (Oxidation Compiler) is a collection of Rust-based JavaScript/TypeScript tools. Two things made it compelling for us:
-
ESLint-compatible rule names. Oxlint uses the same rule identifiers (
@typescript-eslint/no-unused-vars,react-hooks/rules-of-hooks, etc.), so migrating rules is a direct mapping exercise, not a translation. -
Single binary, zero plugins. TypeScript, React, React Hooks, JSX-a11y, and import rules are all built in. No plugin packages, no peer dependency resolution, no version matrix to maintain.
-
Speed. Oxlint is written in Rust and lints files in parallel across all CPU cores. Oxfmt similarly parallelizes formatting.
-
Import sorting built into the formatter. Oxfmt handles import organization as part of formatting — something that previously required
eslint-plugin-importplus Prettier, often conflicting with each other.
The Migration
Step 1: Consolidate Lint Rules into .oxlintrc.json
We mapped our ESLint rules to a single root config:
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "react-hooks", "jsx-a11y", "import"],
"env": { "browser": true, "node": true },
"rules": {
"eqeqeq": "error",
"no-var": "error",
"no-console": ["error", { "allow": ["warn", "error"] }],
"array-callback-return": "error",
"no-template-curly-in-string": "error",
"no-sequences": "error",
"no-useless-concat": "error",
"no-redeclare": "error",
"no-lone-blocks": "error",
"no-extra-boolean-cast": "error",
"prefer-spread": "error",
"prefer-rest-params": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"varsIgnorePattern": "^_",
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/ban-ts-comment": "warn",
"react/jsx-uses-vars": "error",
"react/display-name": "warn",
"react/jsx-no-useless-fragment": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"import/no-duplicates": "error"
},
"ignorePatterns": [
"dist/**",
"coverage/**",
".nx/cache/**",
"**/*.generated.ts",
"**/*.generated.tsx",
"**/*.spec.ts",
"node_modules/**",
"**/*.graphql"
]
}50 lines. One file. No extends chain, no per-project overrides needed — Oxlint doesn't use tsconfig.json for project-scoped resolution, so every project gets the same rules automatically.
Step 2: Replace Prettier with .oxfmtrc.json
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "avoid",
"printWidth": 80,
"sortImports": {
"groups": [
"builtin",
"external",
"skuad-packages",
"pay-core",
"pay-features",
"pay-modules",
"idwebclient",
["parent", "sibling", "index"],
"side_effect",
"side_effect_style"
],
"customGroups": [
{
"groupName": "skuad-packages",
"elementNamePattern": ["@skuad/**"]
},
{
"groupName": "pay-core",
"elementNamePattern": [
"@pay/apollo-client", "@pay/apollo-client/**",
"@pay/i18n-client", "@pay/i18n-client/**",
"@pay/firebase-client", "@pay/firebase-client/**",
"@pay/router", "@pay/router/**",
"@pay/theme", "@pay/theme/**"
]
},
{
"groupName": "pay-features",
"elementNamePattern": [
"@pay/pages/**", "@pay/components/**",
"@pay/contexts/**", "@pay/hooks/**",
"@pay/hoc/**", "@pay/services/**",
"@pay/assets/**", "@pay/utils/**",
"@pay/constants/**"
]
},
{
"groupName": "pay-modules",
"elementNamePattern": [
"@pay/modules/**", "@pay/old/**", "@pay/types/**"
]
},
{
"groupName": "idwebclient",
"elementNamePattern": ["@idwebclient/**"]
}
],
"newlinesBetween": true,
"order": "asc",
"ignoreCase": true
}
}The sortImports configuration was a big win. We had been using eslint-plugin-import for import ordering, which required a separate config and frequently fought with Prettier. Oxfmt handles import sorting natively — groups, custom patterns for internal packages, newlines between groups — all in one place.
Step 3: Update NX Project Configs
Each project's project.json had a lint target using NX's ESLint executor:
// Before
{
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
}
}
// After
{
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "oxlint apps/pay-platform"
}
}
}Same pattern for the prettify target:
// Before
"command": "prettier --write \"apps/pay-platform/**/*.{ts,tsx,js,jsx,json,html,css,scss,md}\""
// After
"command": "oxfmt --write apps/pay-platform"We replaced the @nx/eslint:lint executor with nx:run-commands across all 8 projects. This also meant we could drop @nx/eslint and @nx/eslint-plugin from our dependencies.
Step 4: Simplify lint-staged
The 45-line routing nightmare became 7 lines:
// After: lint-staged.config.js (7 lines)
module.exports = {
'*.{js,jsx,ts,tsx}': files => [
`oxlint --fix ${files.join(' ')}`,
`oxfmt --write ${files.join(' ')}`,
],
'*.{css,scss,html,json,md}': files =>
`oxfmt --write ${files.join(' ')}`,
};No more per-project routing. Oxlint reads the root .oxlintrc.json regardless of where the file lives, so every staged file gets the same treatment.
Step 5: Update the Dangerfile
Our CI danger check used ESLint's async Node API. The migration replaced it with a synchronous execFileSync call to oxlint with JSON output:
// Before: async ESLint API
const { ESLint } = require('eslint');
const lintFiles = async () => {
const eslint = new ESLint();
const results = await eslint.lintFiles(allModifiedFiles);
for (const result of results) {
for (const msg of result.messages) {
// process each message...
}
}
};
// After: sync execFileSync with JSON output
const { execFileSync } = require('child_process');
const lintFiles = () => {
try {
const result = execFileSync(
'npx',
['oxlint', '--format', 'json', ...allModifiedFiles],
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
);
processDiagnostics(JSON.parse(result).diagnostics);
} catch (e) {
const stdout = e.stdout ?? '';
try {
processDiagnostics(JSON.parse(stdout).diagnostics);
} catch {
fail(`Oxlint failed to run: ${e.message}`);
}
}
};One quirk: oxlint exits with a non-zero code when it finds lint errors, so you need to catch the error and parse stdout from the exception.
Step 6: VS Code Settings
Swapped two extensions for two (but from the same project):
// Before
"recommendations": [
"esbenp.prettier-vscode"
]
// After
"recommendations": [
"oxc.oxc-vscode"
]And updated settings.json to use the new formatter and linter:
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.oxlint": "explicit"
},
"oxlint.enable": trueThis replaced the per-language formatter overrides and the eslint.validate array.
The Numbers
Performance
Benchmarked on the full codebase (M3 MacBook Pro, median of 3 runs):
| Tool | Time | Notes |
|---|---|---|
| ESLint | ~2m 27s | Single-threaded, type-aware rules |
| Oxlint | ~1.3s | 5,360 files, 134 rules, 11 threads |
| Prettier | ~13.9s | 6,111 files |
| Oxfmt | ~2.1s | 6,111 files, 11 threads |
Oxlint is roughly 113x faster than ESLint. Oxfmt is roughly 6.5x faster than Prettier.
Config Reduction
| Metric | Before | After | Reduction |
|---|---|---|---|
| Config files | 16 | 2 | 87.5% |
| Config lines | 1,066 | 131 | 87.7% |
| npm lint deps | 18 | 2 | 88.9% |
| lint-staged lines | 45 | 7 | 84.4% |
88.9% fewer dependencies. The entire lint/format stack went from 18 npm packages to 2.
Gotchas
A few things to be aware of if you're considering the same migration:
Rule coverage gaps. Oxlint doesn't support every ESLint rule. We lost @typescript-eslint/naming-convention (our custom naming convention enforcer), no-restricted-imports with custom messages, and a few Storybook-specific rules. For us, these were nice-to-haves, not blockers.
No plugin ecosystem. If you depend on specialized ESLint plugins (GraphQL schema validation, i18n key checking, etc.), those don't exist in the Oxlint world. We dropped @graphql-eslint/eslint-plugin and eslint-plugin-graphql — our GraphQL files are now excluded from linting entirely.
Formatting differences. Oxfmt is Prettier-compatible but not identical. We saw minor whitespace differences in some edge cases (long ternary expressions, certain JSX formatting). The initial oxfmt --write . commit touched thousands of files, so we did it as a standalone commit to keep the diff reviewable.
Import sorting migration. Moving from eslint-plugin-import's sort config to Oxfmt's sortImports required rethinking the group definitions. The syntax is different, but more expressive — custom groups with glob patterns made our internal package grouping cleaner than before.
Conclusion
The migration took about a day of focused work, most of it spent on the sortImports configuration and verifying the formatting output matched our expectations. The result:
- Faster feedback loops — linting in ~1s instead of ~50s
- Less config to maintain — 2 files instead of 16
- Fewer dependencies — 2 packages instead of 18
- Simpler mental model — one linter, one formatter, no plugin wiring
If your ESLint config has grown into a multi-file, multi-plugin sprawl, and you don't depend heavily on niche ESLint plugins, Oxlint + Oxfmt is worth evaluating. The migration path is straightforward since the rule names map directly, and the performance difference is hard to ignore.
Disclaimer: Claude/AI was used to help write this blog post.