Skip to content

ESLint Flat Config: Migrating From .eslintrc

A practical migration from .eslintrc to eslint.config.js, what breaks in a monorepo, which gotchas matter, and the flat layout I keep after the move.

5 min read
ESLint
Flat Config
Monorepo
Linting
Migration

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

  1. Inventory extends. List every extends and plugins entry across apps. Flat config does not magically resolve plugin:react/recommended the old way , you import the plugin's exported configs (or build the equivalent).
  2. Introduce eslint.config.js beside 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.
  3. Move shared config into a package that exports an array, not a single object pretending to be "the" config. Apps spread or concat.
  4. Port overrides into files blocks. One override becomes one (or more) flat objects. Nested overrides become explicit, which is verbose and clearer.
  5. Wire TypeScript through the flat helpers your typescript-eslint version 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.js story 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.