ESLint Couldn't Find an eslint.config.js File: Migrate to Flat Config

Linting · Intermediate · 7 min read · published

This article was written by Claude (Anthropic) and published automatically.

What this solves: ESLint 9 ignores your .eslintrc and .eslintignore and crashes lint in CI. Here's how to migrate to flat config without silently linting dist/ or losing rules.

What Changed

You upgrade to ESLint 9 and every lint run dies with ESLint couldn't find an eslint.config.(js|mjs|cjs) file. The reason is that ESLint v9.0.0 made "flat config" the default and stopped looking for .eslintrc.* files. Your existing config is still on disk, but ESLint no longer reads it.

The full message looks like this:

Oops! Something went wrong! :(

ESLint: 9.x.x

ESLint couldn't find an eslint.config.(js|mjs|cjs) file.

From ESLint v9.0.0, the default configuration file is now eslint.config.js.
If you are using a .eslintrc.* file, please follow the migration guide
to update your configuration file to the new format

Several things changed together in v9:

The Old Way vs The New Way

Before, with .eslintrc.json and .eslintignore:

{
  "root": true,
  "env": { "browser": true, "node": true },
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint", "react-hooks"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "rules": { "react-hooks/rules-of-hooks": "error" },
  "overrides": [
    { "files": ["*.test.ts"], "rules": { "no-console": "off" } }
  ]
}
# .eslintignore
dist
coverage

After, with eslint.config.js:

import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import { defineConfig, globalIgnores } from 'eslint/config';

export default defineConfig([
  globalIgnores(['dist/', 'coverage/']),
  js.configs.recommended,
  tseslint.configs.recommended,
  {
    files: ['**/*.{ts,tsx}'],
    plugins: { 'react-hooks': reactHooks },
    languageOptions: { globals: { ...globals.browser, ...globals.node } },
    rules: { 'react-hooks/rules-of-hooks': 'error' },
  },
  {
    files: ['**/*.test.ts'],
    rules: { 'no-console': 'off' },
  },
]);

In the new version, nothing is resolved by string name, there is no cascade, and overrides is just another object in the array.

Why It Was Added

The eslintrc system had problems that caused real bugs:

Flat config replaces all of that with one rule: an ordered array, matched by globs, where later objects win. Plugins are ordinary imports, so Node's module resolution, not ESLint's, decides which version you get.

How It Works Underneath

When you run eslint .:

  1. ESLint looks for eslint.config.{js,mjs,cjs} starting in the current working directory and walking upward. TypeScript config files are supported in newer 9.x releases, with an extra loader installed.
  2. It stops at the first config file it finds. There is no cascade. Package-level configs below the cwd are not consulted by default. An experimental flag (unstable_config_lookup_from_file) changes this so lookup starts from each linted file instead.
  3. For each file, it builds the final config by walking the array:
    • An object with only an ignores key is a global ignore.
    • Any other object applies if the file matches its files (or the object has no files) and does not match its own ignores.
    • Matching objects are merged in order, and later keys override earlier ones.
  4. Default ignores are only **/node_modules/ and .git/. Everything else, including dist/, is fair game unless you ignore it.
flowchart TD
  A[eslint . run in cwd] --> B{eslint.config.* in cwd or ancestor?}
  B -- no --> E[Error: couldn't find eslint.config file]
  B -- yes --> C[Load module, get config array]
  C --> D[For each file on disk]
  D --> G{Matches a global ignores-only object?}
  G -- yes --> S[Skip file entirely]
  G -- no --> H[Walk array in order]
  H --> I{Object files glob matches AND its own ignores do not?}
  I -- yes --> J[Merge object into file config, later wins]
  I -- no --> K[Skip this object only]
  J --> L{More objects?}
  K --> L
  L -- yes --> H
  L -- no --> M{Any object with files matched?}
  M -- no --> N[File not linted]
  M -- yes --> O[Run rules with merged config]

The most common migration bug lives in the "skip this object only" branch. Writing ignores next to rules does not ignore the file. It only stops that one object from applying.

To see what a given file actually gets, run npx eslint --print-config path/to/file.ts. For a visual breakdown, use npx eslint --inspect-config.

Should You Adopt It Yet

Yes. Flat config is the only supported path forward, and the ecosystem has caught up:

What migration actually costs you:

Who should wait briefly: teams pinned to a plugin with no flat export and no maintained fork. Set ESLINT_USE_FLAT_CONFIG=false in CI to buy time, and track that plugin. The escape hatch disappears in the next major release, so treat it as weeks, not quarters.

Migration Notes

1. Generate a first draft:

npx @eslint/migrate-config .eslintrc.json

It writes an eslint.config.mjs and converts .eslintignore entries. Read the output: it often leans on FlatCompat more than you need, and it may put ignores in the wrong place.

2. Grep for the things that silently break:

grep -rn "ESLINT_USE_FLAT_CONFIG" .github/ .gitlab-ci.yml package.json ~/.zshrc
grep -rn -- "--ext\|--ignore-path\|--rulesdir" package.json .github/
find . -name ".eslintignore" -o -name ".eslintrc*" -not -path "*/node_modules/*"
grep -rn "eslint-disable.*plugin/" src/  # rule names may have changed prefix

3. Map the old concepts:

eslintrc flat config
env: { browser: true } languageOptions.globals: globals.browser
parser: '...' languageOptions.parser: importedParser
plugins: ['x'] plugins: { x: importedPlugin }
overrides another object with files
.eslintignore globalIgnores([...]) or { ignores: [...] } alone
--ext .ts files: ['**/*.ts']

4. Verify parity before deleting the old config. Lint once with the old setup and once with the new one, then compare the results:

ESLINT_USE_FLAT_CONFIG=false npx eslint . -f json > old.json
npx eslint . -f json > new.json

Diff the rule IDs per file. Large drops usually mean a files glob that doesn't match, often because .tsx or .mts is missing. Large jumps usually mean ignores that were placed inside a rules object.

5. Update scripts. Change "lint": "eslint --ext .ts,.tsx src" to "lint": "eslint src". With --ext removed, the files globs in your config now decide which file types get linted.

Key takeaway: In flat config, an `ignores` array only skips files globally when it sits alone in its own object. Put it next to `rules` and it filters only that one block.

Real-world challenge

Your team upgraded to ESLint 9 and ran the migration tool. Lint passes locally for some developers. In CI, lint now takes four minutes instead of twenty seconds and fails with hundreds of errors in `dist/`, `coverage/`, and generated `src/__generated__/*.ts` files. The old setup had a `.eslintignore` that listed all three paths, and that file still exists in the repo. The new eslint.config.js has `ignores: ['dist/**', 'coverage/**']` inside the same object that sets `languageOptions` and `rules`.

Diagnosis

  1. ESLint 9 in flat config mode does not read .eslintignore. That file is now dead weight. It even triggers a warning, which CI logs often hide.
  2. The ignores that was added sits next to rules, so it only stops that object from applying to those files. Every other spread config, such as js.configs.recommended and tseslint.configs.recommended, still matches dist/**/*.js, so the files are linted anyway.
  3. src/__generated__ was never carried over at all.
  4. The "passes locally" reports come from developers who had ESLINT_USE_FLAT_CONFIG=false exported in their shell from an earlier experiment.

You can confirm the cause with npx eslint --debug dist/bundle.js 2>&1 | grep -i ignore. You can also run npx eslint --inspect-config and check which config objects match a dist file.

Fix

Put the ignores in their own object at the top, and delete .eslintignore:

// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';

export default defineConfig([
  globalIgnores(['dist/', 'coverage/', 'src/__generated__/']),
  js.configs.recommended,
  tseslint.configs.recommended,
  { files: ['**/*.{js,ts}'], rules: { 'no-console': 'error' } },
]);

If your ESLint 9 version predates eslint/config, use { ignores: [...] } with no other keys instead.

Finally, remove ESLINT_USE_FLAT_CONFIG from shell profiles and CI env. That way everyone runs the same mode, and the four-minute lint drops back to seconds.