ESLint 9 "couldn't find an eslint.config" after upgrading: migrating to flat config with TypeScript
A routine dependency update moved us to ESLint 9, and lint failed in CI and every editor: ESLint couldn't find an eslint.config file. ESLint 9 uses flat config by default and ignores .eslintrc. How we migrated a TypeScript and React project, kept Prettier out of the way, and avoided the common traps.
Renovate opened a pull request bumping eslint from 8.57 to 9.x alongside a few other dev dependencies.
The tests passed, a reviewer approved it, and it merged. The next lint run in CI failed immediately:
$ npx eslint .
Oops! Something went wrong! :(
ESLint: 9.26.0
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:
https://eslint.org/docs/latest/use/configure/migration-guide
Editors with the ESLint extension stopped showing any lint errors, which is worse: developers did not notice anything was wrong.
The existing configuration
// .eslintrc.json
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": "./tsconfig.json" },
"plugins": ["@typescript-eslint", "react", "react-hooks"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"settings": { "react": { "version": "detect" } },
"rules": { "@typescript-eslint/no-explicit-any": "warn" },
"ignorePatterns": ["dist/", "coverage/"]
}
Plus an .eslintignore file and an override for test files in a nested .eslintrc under src/test.
What we ruled out
- A broken install.
npx eslint --versionprinted 9.26.0; the binary was fine. - Running from the wrong directory. The same error appeared locally from the repository root.
- Setting
ESLINT_USE_FLAT_CONFIG=false. This temporary escape hatch still worked in ESLint 9, but it is deprecated and removed in the next major, so it was not a fix.
Root cause
ESLint 9 made the flat configuration format the default. It looks for eslint.config.js,
eslint.config.mjs or eslint.config.cjs and no longer reads .eslintrc.* or .eslintignore files
unless the legacy mode is explicitly enabled. Our project had only legacy configuration, so ESLint found
no configuration at all.
What changed with flat config
Legacy .eslintrc |
Flat eslint.config.js |
|---|---|
| Cascading files per directory | One file at the root, an array of config objects |
extends with string names |
Import configs as JavaScript objects and spread them |
plugins: ["react"] resolved by name |
plugins: { react: reactPlugin } with imported objects |
parser and parserOptions at top level |
languageOptions: { parser, parserOptions, globals } |
overrides with files |
Separate config objects with files patterns |
.eslintignore and ignorePatterns |
ignores in a config object (global when alone) |
env: { browser: true } |
globals from the globals package |
The flat format is explicit: what you import is what runs, with no name resolution magic.
Who is affected
Every project upgrading from ESLint 8 to 9 with .eslintrc.* files, including monorepos with nested
configs per package, projects whose plugins were never updated for flat config, and editor setups that
pinned the old configuration mode. Tools that wrap ESLint, such as framework-specific lint commands, may
fail with their own error messages that hide the underlying cause.
Environment
| Component | Version / value |
|---|---|
| ESLint | 8.57 to 9.26 |
| TypeScript | 5.8, typescript-eslint 8.x |
| React | 19, eslint-plugin-react, eslint-plugin-react-hooks 5.x |
| Formatter | Prettier 3, eslint-config-prettier 10 |
| Editors | VS Code ESLint extension 3.x |
Step 1: generate a starting point
ESLint provides a migration tool that converts an .eslintrc file:
npx @eslint/migrate-config .eslintrc.json
It produces an eslint.config.mjs using FlatCompat for plugins that might not support flat config. We
used it as a reference but wrote the final file by hand, because all our plugins already had native flat
configs and the compat layer adds indirection.
Step 2: the flat config
// eslint.config.mjs
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist/', 'coverage/', 'node_modules/'] },
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
react.configs.flat.recommended,
react.configs.flat['jsx-runtime'],
reactHooks.configs['recommended-latest'],
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
globals: { ...globals.browser },
},
settings: { react: { version: 'detect' } },
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['src/test/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'],
languageOptions: { globals: { ...globals.node } },
rules: { '@typescript-eslint/no-non-null-assertion': 'off' },
},
{
files: ['*.config.{js,mjs,cjs}'],
...tseslint.configs.disableTypeChecked,
languageOptions: { globals: { ...globals.node } },
},
prettier,
);
Notes:
- The
ignoresobject stands alone at the top, which makes it a global ignore, replacing.eslintignore. tseslint.config()is a helper that gives type checking of the config and handles spreading.projectService: truelets typescript-eslint find the righttsconfigper file, replacing the oldprojectpath and its performance problems in monorepos.eslint-config-prettiercomes last, so it turns off formatting rules that would conflict with Prettier, regardless of what earlier configs enabled.- Config files themselves are linted without type information, which avoids "file not included in
project" errors for
vite.config.mjs.
Step 3: remove legacy files
git rm .eslintrc.json .eslintignore src/test/.eslintrc.json
Leaving them around confuses people, and some editor extensions show warnings for them.
Step 4: update scripts and editors
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}
The --ext flag is gone in flat config; file patterns in the config decide what is linted. The VS Code
extension detects flat config automatically in recent versions; we removed an old
eslint.experimental.useFlatConfig setting from the shared workspace settings.
Monorepos
In a monorepo, flat config still means one root file, with files patterns per package instead of nested
config files. Packages with different settings get their own config objects:
{ files: ['packages/api/**/*.ts'], languageOptions: { globals: { ...globals.node } } },
{ files: ['packages/web/**/*.{ts,tsx}'], languageOptions: { globals: { ...globals.browser } } },
ESLint 9 also supports a config file per package if you run it from that package's directory, but one root file keeps editor behaviour predictable.
Alternatives and trade-offs
- Pin ESLint 8 until later. ESLint 8 reached end of life in October 2024, so this only delays the work and leaves the toolchain without fixes.
- Use the compat layer permanently. Works for unmaintained plugins, at the cost of a less readable config.
How to test the fix
npx eslint --print-config src/orders/OrderTable.tsx | jq '.rules["react-hooks/rules-of-hooks"]'
# [2]
npx eslint . --max-warnings=0
--print-config shows the effective configuration for a single file, the quickest way to check that a
files pattern applies where you expect.
We also introduced a deliberate violation in a branch (a hook called conditionally) to confirm that CI fails, because "no errors" can also mean "no config".
Confirming in production
CI lint passed with the same set of warnings as before the upgrade, plus 14 new findings from
recommendedTypeChecked, which we fixed or downgraded. The editor showed lint errors again for everyone
after restarting the extension.
Regression guard
- CI runs
eslint . --max-warnings=0and a canary check that a known-bad fixture file produces an error, so a config that silently lints nothing fails the build. - Renovate groups ESLint and its plugins into one pull request with a note that major updates need manual review.
Security implications
None directly. Lint rules catch some security-relevant mistakes, such as unsafe eval usage and
floating promises in authorization code; a silently disabled linter removes that safety net, which is why
the canary check matters.
Prevention checklist
- Expect ESLint 9 to ignore
.eslintrcand.eslintignore; migrate toeslint.config.js. - Import configs and plugins as objects and keep
eslint-config-prettierlast. - Use
projectServicefor typed linting. - Check effective config with
--print-config. - Add a canary that proves lint actually runs.
References
- ESLint, Configuration migration guide: https://eslint.org/docs/latest/use/configure/migration-guide
- ESLint, Configuration files (flat config): https://eslint.org/docs/latest/use/configure/configuration-files
- typescript-eslint, Getting started with flat config: https://typescript-eslint.io/getting-started/
- eslint-config-prettier: https://github.com/prettier/eslint-config-prettier
Add a label
Embed this post
<iframe src="https://www.hibug.me/eslint-9-flat-config-couldnt-find-config" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.