ESLint flat config stopped being "the new thing" and became the default path.
.eslintrc* still works in many codebases, but new docs, plugins, and examples
assume eslint.config.js. Staying on the classic format is not immoral, it is
increasingly a tax on every tooling upgrade.
I migrated shared monorepo lint packages after flat config had been stable long
enough that plugin authors stopped treating it as experimental. The goal was not
fashion. It was fewer mysterious extends resolution bugs and one config shape
that matches how ESLint actually thinks about files.
What actually changed
Classic config is a cascade of named configs (extends, overrides,
ignorePatterns) that ESLint merges with its own rules. Flat config is an
ordered array of config objects. Each object can target files with files /
ignores, and later objects win on conflicts.
That mental model matters more than the file rename:
js// eslint.config.js, shape, not a full production config import js from '@eslint/js'; import prettier from 'eslint-config-prettier'; export default [ { ignores: ['**/dist/**', '**/.next/**', '**/node_modules/**'] }, js.configs.recommended, { files: ['**/*.{js,mjs,cjs,ts,tsx}'], rules: { 'no-console': ['warn', { allow: ['warn', 'error'] }], }, }, prettier, ];
ignores as a lone object is global. Putting ignores next to files only
narrows that block. Mixing those up is the first migration bug I hit, half the
repo suddenly linted dist/ because I "moved ignorePatterns" into the wrong
object.
Migration steps that did not waste a weekend
- Inventory extends. List every
extendsandpluginsentry across apps. Flat config does not magically resolveplugin:react/recommendedthe old way , you import the plugin's exported configs (or build the equivalent). - Introduce
eslint.config.jsbeside the old file, keep classic config temporarily if you need a rollback, then delete.eslintrc*once CI is green. Two sources of truth for more than a day is how rules drift. - Move shared config into a package that exports an array, not a single object pretending to be "the" config. Apps spread or concat.
- Port
overridesintofilesblocks. One override becomes one (or more) flat objects. Nested overrides become explicit, which is verbose and clearer. - Wire TypeScript through the flat helpers your
typescript-eslintversion documents (tseslint.config(...)or equivalent). Do not cargo-cult a blog post from a different major.
Shared package sketch:
js// packages/config-eslint/index.js import js from '@eslint/js'; import prettier from 'eslint-config-prettier'; /** @type {import('eslint').Linter.Config[]} */ export const base = [ js.configs.recommended, { files: ['**/*.{js,mjs,cjs}'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', }, rules: { 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], }, }, prettier, ]; export default base;
js// apps/web/eslint.config.js import { base } from '@acme/config-eslint'; import nextPlugin from '@next/eslint-plugin-next'; export default [ { ignores: ['.next/**', 'node_modules/**'] }, ...base, { files: ['**/*.{js,jsx,ts,tsx}'], plugins: { '@next/next': nextPlugin }, rules: { ...nextPlugin.configs.recommended.rules, ...nextPlugin.configs['core-web-vitals'].rules, }, }, ];
Exact Next plugin wiring varies by version, read the package you install. The pattern is what matters: framework rules live in the app, base rules live in the shared package, Prettier disablement stays last.
Gotchas that actually bit
root: true disappeared as a concept. Flat config does not walk parent
directories the same way. In a monorepo, that is usually good, each package can
own a config, or the root can own one with files globs. Pick one strategy. I
prefer root-owned flat config with package-aware files when the repo is small,
and per-package configs once teams need divergent framework stacks.
Plugin configs.recommended is not always a drop-in array element. Some
plugins export a flat-ready array; others export classic objects. If ESLint
complains about unexpected keys, you are feeding classic shape into flat. Fix
the adapter, do not silence the error.
import/no-extraneous-dependencies needs new context. __dirname in an ESM
config file is not automatic. Use import.meta.url + fileURLToPath, or pass
explicit packageDir paths. Wrong packageDir recreates the false-positive
festival from the classic monorepo setup.
Ignore semantics differ. A forgotten global ignore for generated clients turns CI into a 20-minute lint of GraphQL output. Add generated paths first, then tighten rules.
Running ESLint 9+ without flat config is a future you do not want. Even if
you are still on a version that tolerates .eslintrc, migrate while you control
the schedule, not during an emergency major bump.
What I deliberately did not do
I did not rewrite every stylistic rule "while we are in there." Migrations that
bundle rule philosophy fights never finish. Move the format. Keep the rule set
stable. Open a second PR if you want stricter no-floating-promises.
I also did not chase zero-config Biome in the same change. Evaluating a different toolchain is a different decision (later in this arc). Flat config is about staying competent with ESLint, not about escaping it.
Done looks like
- One
eslint.config.jsstory per app (or one root config), no leftover.eslintrc*. - Shared package exports composable arrays.
eslint-config-prettier(or equivalent) still last so format wars stay dead.- CI runs the same command locally and remotely:
eslint . --max-warnings=0.
Flat config is bureaucratic until the day a plugin drops classic support. Then it is just the cost of having migrated on purpose.
Next: the pre-commit layer, husky, lint-staged, and nano-staged, and which combination I actually keep on a laptop that should not feel like a build farm.