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:
- Config file:
eslint.config.js(or.mjs/.cjs) replaces.eslintrc.*. It is a JavaScript module that exports an array of config objects. .eslintignoreis no longer read. Ignores now live in the config.- Removed CLI flags:
--ext,--rulesdir,--ignore-pathand--resolve-plugins-relative-tono longer exist. File types now come fromfilesglobs. - Plugins are imported objects, not strings that ESLint resolves by package name.
envis gone. UselanguageOptions.globals, usually with theglobalspackage.- Temporary escape hatch:
ESLINT_USE_FLAT_CONFIG=falsestill works in v9. The whole eslintrc system is deprecated and goes away in the next major version. - Newer helpers: later 9.x releases added
defineConfig()andglobalIgnores()fromeslint/config.defineConfig()also brings back anextendskey inside individual objects.
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:
- Cascading configs. ESLint merged every
.eslintrcfrom the file's directory up to the root, unless something saidroot: true. A stray config in a parent folder, even in your home directory, could change lint results. Monorepos ended up with packages linting differently depending on where you ran the command. - String-based plugin resolution.
"plugins": ["react"]meant "find a package calledeslint-plugin-react" relative to the config. This broke under pnpm's strictnode_moduleslayout and in shared configs. It caused the infamousESLint couldn't determine the plugin uniquelyerrors and forced shared configs to make plugins peer dependencies. - Opaque merging.
extends,overrides,envandparserOptionsall merged by different rules. Nobody could predict the final config without--print-config.
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 .:
- 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. - 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. - For each file, it builds the final config by walking the array:
- An object with only an
ignoreskey is a global ignore. - Any other object applies if the file matches its
files(or the object has nofiles) and does not match its ownignores. - Matching objects are merged in order, and later keys override earlier ones.
- An object with only an
- Default ignores are only
**/node_modules/and.git/. Everything else, includingdist/, 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:
typescript-eslint,eslint-plugin-react-hooks,eslint-plugin-import(oreslint-plugin-import-x), the Vue and Svelte plugins and Next.js's config all ship flat-config exports.
What migration actually costs you:
- Legacy shareable configs. Old configs that only exist as eslintrc packages need
FlatCompatfrom@eslint/eslintrcas a shim. It works, but it is a sign that the dependency is unmaintained. - Monorepos. Per-package configs need rethinking. Either use one root config with
filesscoped topackages/a/**, or run ESLint from each package directory. - Editor integrations. Older VS Code ESLint extension versions need
eslint.useFlatConfigor an update.
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
- 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. - The
ignoresthat was added sits next torules, so it only stops that object from applying to those files. Every other spread config, such asjs.configs.recommendedandtseslint.configs.recommended, still matchesdist/**/*.js, so the files are linted anyway. src/__generated__was never carried over at all.- The "passes locally" reports come from developers who had
ESLINT_USE_FLAT_CONFIG=falseexported 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.