Migrating ESLint to flat config (eslint.config.js)
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
filesglobs tightly (do not lintdist/by accident). - Use
parserOptions.projectService(TypeScript 5.4+) instead of listing everytsconfigmanually 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
- Run
npx @eslint/migrate-config .eslintrc.json(produces a startingeslint.config.mjs). - Collapse duplicate
filesglobs; mergeignoresto the top. - Replace
extendschains with explicit imports (import js from '@eslint/js'). - Run
eslint . --max-warnings=0locally, then wire the same command in CI.
CI gotchas
- Ensure
NODE_OPTIONSor 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.