Skip to main content
Juliano Alves
Back to blog

Migrating ESLint to flat config (eslint.config.js)

5 min read
By Juliano Alves

ESLint’s flat config (ESLint 9+) replaces the legacy .eslintrc.* cascade with a single explicit array of configuration objects. Order matters: first matching config wins for many rule merges, unlike the old extends resolution order that was hard to predict in large repos.

This post covers a production-shaped setup: TypeScript, React, import hygiene, and how I run it in CI.

Minimal baseline#

// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';

export default tseslint.config(
  { ignores: ['**/node_modules/**', '**/.next/**', '**/dist/**'] },
  js.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    plugins: {
      react,
      'react-hooks': reactHooks,
    },
    settings: {
      react: { version: 'detect' },
    },
    rules: {
      'react/react-in-jsx-scope': 'off',
      'react-hooks/rules-of-hooks': 'error',
      'react-hooks/exhaustive-deps': 'warn',
    },
  },
);

Type-aware rules#

recommendedTypeChecked enables rules that use the TypeScript program (no-floating-promises, no-misused-promises, etc.). They catch real async bugs but require projectService or project: true and a valid tsconfig.json.

In monorepos, set tsconfigRootDir so ESLint resolves the correct project for each package.

Imports and boundaries#

Flat config plays well with eslint-plugin-import-x or eslint-plugin-n for restricted imports (e.g. forbid src/features/billing importing src/features/auth/internal). I define one config object per package root with files: ['packages/app/**/*.{ts,tsx}'] and rules specific to that subtree.

Example: package-scoped overrides#

export default [
  // ...shared base...
  {
    files: ['packages/admin/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': [
        'error',
        { patterns: ['**/packages/checkout-core/**'] },
      ],
    },
  },
  {
    files: ['**/*.test.{ts,tsx}'],
    rules: {
      '@typescript-eslint/no-explicit-any': 'off',
    },
  },
];

Tests often need relaxed rules; admin surfaces often need stricter import boundaries than marketing sites.

Performance of type-aware linting#

recommendedTypeChecked is slower than typeless lint because it loads TS projects. Mitigations:

  • Scope files globs tightly (do not lint dist/ by accident).
  • Use parserOptions.projectService (TypeScript 5.4+) instead of listing every tsconfig manually when possible.
  • Split very large repos into multiple ESLint invocations per package in CI, parallelized by matrix.

Stylistic rules: separate tool or ESLint?#

Formatting is largely Prettier’s job. I avoid stylistic ESLint rules that fight Prettier (indent, quotes). For import sorting, eslint-plugin-simple-import-sort stays stable across flat config.

Migration path from .eslintrc#

  1. Run npx @eslint/migrate-config .eslintrc.json (produces a starting eslint.config.mjs).
  2. Collapse duplicate files globs; merge ignores to the top.
  3. Replace extends chains with explicit imports (import js from '@eslint/js').
  4. Run eslint . --max-warnings=0 locally, then wire the same command in CI.

CI gotchas#

  • Ensure NODE_OPTIONS or workspace hoisting does not load two ESLint versions.
  • Cache node_modules and a fingerprint of lockfile + config for speed.
  • Fail on warnings in CI if the team agreed max-warnings=0.

Editor integration#

VS Code’s ESLint extension reads eslint.config.js automatically in recent versions. For multi-root workspaces, set eslint.workingDirectories per package so projectService resolves.

When to stay on legacy config temporarily#

Some enterprise plugins still assume .eslintrc. If you are blocked, pin ESLint 8 only until the plugin ships flat support—do not fork long-term.

Summary#

Flat config trades implicit extends magic for explicit, ordered configuration. Pair it with typescript-eslint’s project service, React plugins, and a clear CI command, and upgrades become boring—which is what you want for linting.

© 2026 Juliano Alves. All rights reserved.