Skip to content

Web Development · Developer Tooling

ESLint 9 Flat Config: The Migration Guide for Teams Who Have Put It Off

ESLint 9 made flat config the default and deprecated the old .eslintrc format. If your project is still on the legacy config, here's what changed, why it's better, and how to migrate without breaking your setup.

Anurag Verma

Anurag Verma

6 min read

ESLint 9 Flat Config: The Migration Guide for Teams Who Have Put It Off

Sponsored

Share

ESLint 9 shipped in April 2024 with flat config as the default. The old .eslintrc system was officially deprecated. If you’re still running .eslintrc.json or .eslintrc.js, you’re on borrowed time. If you’ve been putting off the migration, this guide is for you.

The migration is not small. The config format changed substantially, many plugins needed updates to support the new format, and the documentation is spread across multiple upgrade guides and issue trackers. But flat config is genuinely better: simpler to understand, easier to extend, and much clearer about where rules come from.

What Changed and Why

The old config system had two interacting complexity sources that made it hard to debug.

Cascading file-based config. ESLint would look for .eslintrc files in the directory of each linted file, then walk up the directory tree looking for more config files, merging them all. A file deep in src/api/handlers/ might be affected by .eslintrc files it couldn’t see, creating behavior that was difficult to explain.

Plugin and parser naming by string. You’d write "@typescript-eslint/parser" as a string, and ESLint would resolve it from node_modules. If the resolution failed or you had multiple versions, the error messages were unhelpful.

Flat config fixes both problems.

Single config file. You have one eslint.config.js (or .mjs, .cjs) at the project root. Everything is defined explicitly. No hidden inheritance from parent directories.

JavaScript objects, not JSON strings. Plugins and parsers are imported directly as JavaScript objects:

import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';

If the import fails, you get a clear module resolution error. No magic string lookup.

Explicit file targeting. Rules are scoped to files using a files property with glob patterns. You can see exactly which files each rule applies to.

The New Config Format

Flat config exports an array of config objects from eslint.config.js:

// eslint.config.js
import js from '@eslint/js';

export default [
  js.configs.recommended,
  {
    rules: {
      'no-unused-vars': 'warn',
      'no-console': 'warn',
    },
  },
];

Each object in the array is applied to matched files. Later objects override earlier ones for the same rule. The js.configs.recommended is an object from @eslint/js, the package that contains ESLint’s built-in rules.

A more complete TypeScript config:

import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import globals from 'globals';

export default [
  // Global ignores
  {
    ignores: ['dist/**', 'node_modules/**', '*.min.js'],
  },

  // Base JS rules
  js.configs.recommended,

  // TypeScript files
  {
    files: ['**/*.ts', '**/*.tsx'],
    languageOptions: {
      parser: tsParser,
      parserOptions: {
        project: './tsconfig.json',
      },
      globals: {
        ...globals.browser,
        ...globals.node,
      },
    },
    plugins: {
      '@typescript-eslint': tsPlugin,
    },
    rules: {
      ...tsPlugin.configs.recommended.rules,
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/explicit-function-return-type': 'off',
    },
  },

  // Test files — relax some rules
  {
    files: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**/*.ts'],
    rules: {
      '@typescript-eslint/no-explicit-any': 'off',
    },
  },
];

The structure is explicit: you can read this file top-to-bottom and understand exactly what applies where.

Package Changes

The @eslint/js package is now where ESLint’s built-in rule sets live:

npm install --save-dev eslint @eslint/js

globals provides environment globals (browser, node, etc.) that used to come from env: { browser: true }:

npm install --save-dev globals

Old syntax:

{
  "env": {
    "browser": true,
    "node": true
  }
}

New syntax:

{
  languageOptions: {
    globals: {
      ...globals.browser,
      ...globals.node,
    }
  }
}

Migrating an Existing .eslintrc Config

The most mechanical way to migrate: use the migration tool ESLint provides.

npx @eslint/migrate-config .eslintrc.json

This reads your existing .eslintrc.json (or .eslintrc.js, .eslintrc.yml) and generates a eslint.config.mjs equivalent. The output isn’t always perfect (you’ll need to review it), but it handles the mechanical translation and flags anything that needs manual attention.

Common issues the migration tool flags:

Plugins that don’t support flat config yet. The tool will note which plugins you use that haven’t published a flat-config-compatible version. As of mid-2026, most major plugins have updated, but some less-maintained ones still haven’t. Check each plugin’s GitHub issues.

extends arrays that need expansion. In .eslintrc, you could extend shared configs with extends: ['airbnb']. In flat config, you import and spread the config objects:

// Old: extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended']

// New:
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';

export default [
  js.configs.recommended,
  {
    plugins: { '@typescript-eslint': tsPlugin },
    rules: tsPlugin.configs.recommended.rules,
  }
];

Or use the new tsPlugin.configs.recommended flat config object if the plugin provides one:

import tseslint from 'typescript-eslint';

export default tseslint.config(
  ...tseslint.configs.recommended,
);

.eslintignore is gone. Ignored files now use the ignores property in the config:

export default [
  {
    ignores: ['dist/**', 'coverage/**', 'node_modules/**'],
  },
  // ... rest of config
];

Common Migration Blockers

eslint-config-airbnb. The Airbnb config hasn’t published an official flat-config version as of this writing. The community-maintained eslint-config-airbnb-flat package fills the gap for most setups:

npm install --save-dev eslint-config-airbnb-flat

Prettier integration. eslint-config-prettier has supported flat config since version 9. If you’re on an older version, update it.

npm install --save-dev eslint-config-prettier@latest

In flat config:

import prettierConfig from 'eslint-config-prettier';

export default [
  // ... other configs
  prettierConfig, // must be last
];

eslint-plugin-react. The React plugin added flat config support in v7.33.0. Update to the latest version and use the new flat config exports:

import reactPlugin from 'eslint-plugin-react';

export default [
  reactPlugin.configs.flat.recommended,
  // ...
];

Running Both Configs During Transition

If you need to migrate incrementally (say, a monorepo with packages on different timelines), you can temporarily tell ESLint which config system to use with an environment variable:

# Force legacy config (for gradual migration)
ESLINT_USE_FLAT_CONFIG=false npx eslint .

# Force flat config (after migration)
ESLINT_USE_FLAT_CONFIG=true npx eslint .

In ESLint 9, flat config is the default when eslint.config.js exists. Legacy config is used when only .eslintrc.* exists. The environment variable overrides this detection.

Verifying Your Migration

After migrating, confirm your rules are still being applied correctly:

# List the rules active for a specific file
npx eslint --print-config src/app.ts

Compare the output with what you got from the old config. Pay attention to:

  • TypeScript-specific rules are active for .ts files
  • Test files have the relaxed rules you configured
  • Ignored paths are still ignored

Run your full lint check and fix any new issues before calling the migration complete:

npx eslint . --fix

What You Gain

Flat config is more verbose for simple setups than .eslintrc.json was. A three-rule eslintrc.json is shorter than the equivalent eslint.config.js. That’s the trade-off.

The gain is transparency at scale. When you have a monorepo with multiple packages, different rules for tests and source files, and multiple plugin configurations, flat config stays readable. The cascading .eslintrc system in a complex monorepo becomes a debugging exercise.

And practically: ESLint’s future development targets flat config. The legacy system will eventually be removed. Projects that haven’t migrated by the time that happens will need to do it under pressure. Doing it now, on your timeline, is much better.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored