उदय
  • Blogs
  • Resume
उदय

Senior Software Engineer with 5+ years of experience owning platform-level systems — from migrating legacy codebases to shipping payment platforms at scale. Specializing in TypeScript, React, Next.js, and Node.js.

Pages

BlogsResume

© 2022 - 2026 Uday Nayak. All rights reserved.

Blog
  • Previous
  • Next

Migrating from ESLint + Prettier to Oxlint + Oxfmt in an NX Monorepo

Uday Nayak

March 4, 2026

4 min read

How we replaced 18 ESLint dependencies with 2 Rust-based tools, cut config from 1,066 lines to 131, and made linting dramatically faster across 6,670 files.

  • tooling
  • dx
  • eslint
  • oxlint
  • monorepo

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-eslint

18 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:

  1. 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.

  2. 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.

  3. Speed. Oxlint is written in Rust and lints files in parallel across all CPU cores. Oxfmt similarly parallelizes formatting.

  4. Import sorting built into the formatter. Oxfmt handles import organization as part of formatting — something that previously required eslint-plugin-import plus 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": true

This 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):

ToolTimeNotes
ESLint~2m 27sSingle-threaded, type-aware rules
Oxlint~1.3s5,360 files, 134 rules, 11 threads
Prettier~13.9s6,111 files
Oxfmt~2.1s6,111 files, 11 threads

Oxlint is roughly 113x faster than ESLint. Oxfmt is roughly 6.5x faster than Prettier.

Config Reduction

MetricBeforeAfterReduction
Config files16287.5%
Config lines1,06613187.7%
npm lint deps18288.9%
lint-staged lines45784.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.

On this page
The Problem: Config Sprawl
Why Oxlint + Oxfmt
The Migration
Step 1: Consolidate Lint Rules into .oxlintrc.json
Step 2: Replace Prettier with .oxfmtrc.json
Step 3: Update NX Project Configs
Step 4: Simplify lint-staged
Step 5: Update the Dangerfile
Step 6: VS Code Settings
The Numbers
Performance
Config Reduction
Gotchas
Conclusion
Blog
  • Previous
  • Next