Web Development Bug Report

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.

Hannah Okafor Hannah Okafor @hannahokafor · May 12, 2025 · 7 min read · 38 Views
Banner: ESLint 9 couldn't find an eslint.config after upgrading, migrating to flat config with TypeScript

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 --version printed 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 ignores object 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: true lets typescript-eslint find the right tsconfig per file, replacing the old project path and its performance problems in monorepos.
  • eslint-config-prettier comes 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=0 and 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 .eslintrc and .eslintignore; migrate to eslint.config.js.
  • Import configs and plugins as objects and keep eslint-config-prettier last.
  • Use projectService for typed linting.
  • Check effective config with --print-config.
  • Add a canary that proves lint actually runs.

References

Add a label

#typescript #eslint #prettier #flat-config #linting
Written by
Hannah Okafor @hannahokafor

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.