Fix: ESLint Flat Config Not Working — eslint.config.js, ignores, Plugins, and Migration
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix ESLint flat config errors — eslint.config.js not found, .eslintrc.json ignored after upgrade, ignores replacing .eslintignore, plugin object form, typescript-eslint integration, monorepo configs, and ESLINT_USE_FLAT_CONFIG.
The Error
You upgrade to ESLint 9 and npx eslint . finds no files:
$ npx eslint .
# Ran without errors but linted 0 files.Or it errors out about a missing config:
Could not find config file.Or your .eslintignore is silently ignored:
$ cat .eslintignore
node_modules/
dist/
$ npx eslint .
# Lints dist/ anyway.Or a plugin’s rules don’t run:
// eslint.config.js
import react from "eslint-plugin-react";
export default [
{ rules: { "react/jsx-key": "error" } },
];
// "react/jsx-key" doesn't trigger.Why This Happens
ESLint 9 made flat config (eslint.config.js) the default, replacing .eslintrc.*. The two formats have fundamental differences:
- Single config file. Flat config is a JavaScript file that exports an array. No more YAML/JSON, no automatic merging with parent directory configs.
- No
extends, no shared config strings. Plugins are imported as JS objects and spread into the array. Shareable configs are arrays you spread, not strings to resolve. ignoresreplaces.eslintignore. The separate ignore file isn’t read anymore. You declare ignores in the config object.- Globals are explicit. Instead of
env: browser, you spreadglobals.browser(from theglobalspackage) intolanguageOptions.globals.
The “lints 0 files” issue is usually a missing or wrong-shaped config. ESLint 9 doesn’t auto-find your old .eslintrc.json.
There’s a second source of confusion: most teams went through a long transition window where ESLint 8 supported both formats. If you set up the project during that window, you may have a half-migrated state — a flat config alongside leftover .eslintrc.json, eslint-config-* packages that still expect the legacy resolver, and editor extensions pinned to the old behavior. ESLint 9 doesn’t read the old files at all, so anything you relied on through the legacy resolver silently stops working the moment you upgrade.
The third pitfall is plugin compatibility. Plugins published before 2024 often exported themselves only in the legacy “string name” shape. They work in flat config only if you wrap them or upgrade to a flat-aware version. typescript-eslint, eslint-plugin-react, eslint-plugin-import, and eslint-plugin-jsx-a11y all published flat-config-ready majors during 2024 — older majors will throw Cannot read properties of undefined or load with missing rules.
Version History: How Flat Config Landed
Knowing which ESLint version did what makes the migration much less mysterious:
- ESLint 8.21 (August 2022) shipped flat config as an opt-in preview. You enabled it with
ESLINT_USE_FLAT_CONFIG=trueand a singleeslint.config.jsfile. The API was already close to its final shape, but plugin support was sparse. - ESLint 8.x through 2023 kept flat config behind the flag while the team iterated. Shareable configs were rewritten for flat output (
@eslint/js,eslint-config-prettier@9, typescript-eslint 7 withtseslint.config()). - ESLint 9.0 (April 2024) flipped the default.
eslint.config.jsis now found first; legacy.eslintrc.*files requireESLINT_USE_FLAT_CONFIG=false. Node 18.18+ is required. - ESLint 9.1–9.10 (2024) added quality-of-life features: the
defineConfighelper, theextendsfield inside flat config objects,--inspect-configfor debugging, and improvements to global ignores. - typescript-eslint v8 (July 2024) is the first major that’s flat-config-first. The
tseslint.config()helper produces correctly-typed config arrays and handles plugin/parser wiring. If you upgraded ESLint to 9 but left typescript-eslint on v7, type-aware rules can silently stop working. - ESLint 9.11+ and the 9.x line through 2025 continued tightening flat config defaults and dropping legacy resolver fallbacks. Future majors will remove
ESLINT_USE_FLAT_CONFIG=falseentirely, so the escape hatch is temporary.
In parallel, the ecosystem moved. Biome 1.0 (August 2023) and Oxlint 0.x (2024) positioned themselves as faster, zero-config alternatives. Many teams now run ESLint only for the rules Biome/Oxlint don’t cover and run Biome for the rest. If you find flat config more friction than value, those are valid escape paths — but flat config itself is the long-term ESLint story.
Fix 1: Create eslint.config.js
The minimal flat config:
// eslint.config.js
export default [
{
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
rules: {
"no-unused-vars": "warn",
"no-undef": "error",
},
},
];For CommonJS projects, use eslint.config.cjs:
// eslint.config.cjs
module.exports = [
{
files: ["**/*.js"],
rules: { "no-console": "warn" },
},
];Run:
npx eslint .If ESLint reports 0 files linted, check:
- The
filesglob actually matches your sources. - You’re running from the project root (where
eslint.config.jslives). - The file extension matches (
.js,.cjs,.mjs, or.tsif you use TS config).
Pro Tip: Use ESLint’s defineConfig helper (added in 9.x) for autocompletion:
import { defineConfig } from "eslint/config";
export default defineConfig([
{
files: ["**/*.ts"],
rules: { ... },
},
]);Fix 2: Migrate Ignores
.eslintignore is no longer read. Move ignores to flat config:
export default [
{
ignores: [
"dist/**",
"build/**",
"coverage/**",
"node_modules/**",
"**/*.generated.*",
],
},
{
files: ["**/*.ts"],
rules: { ... },
},
];A config object with only ignores and no files applies the ignores globally. Other config objects then add rules for the remaining files.
Common Mistake: Putting ignores in the same object as files and rules — that only ignores within those files. Use a dedicated ignores-only object for global excludes:
// Global ignores (no `files`):
{ ignores: ["dist/**"] }
// Rules for sources (no `ignores`):
{ files: ["src/**/*.ts"], rules: { ... } }For temporary overrides:
# CLI form, ignores in addition to config:
npx eslint . --ignore-pattern "tmp/**"Fix 3: Plugins Are Objects, Not Strings
Old:
{
"plugins": ["react"],
"rules": { "react/jsx-key": "error" }
}New:
import react from "eslint-plugin-react";
export default [
{
files: ["**/*.{jsx,tsx}"],
plugins: { react },
rules: { "react/jsx-key": "error" },
},
];The plugins key is now an object: { <prefix>: <pluginModule> }. The prefix becomes the rule namespace (react/jsx-key).
For typescript-eslint (the dominant TS plugin):
import tseslint from "typescript-eslint";
export default tseslint.config(
{
files: ["**/*.ts", "**/*.tsx"],
extends: [...tseslint.configs.recommended],
rules: {
"@typescript-eslint/no-unused-vars": "warn",
},
},
);tseslint.config(...) is a helper that wraps tseslint.configs.* shareable configs. It handles the plugin wiring for you. On typescript-eslint v8, the helper also returns precisely-typed config arrays — older v6/v7 versions worked with flat config but typed it as any.
For multiple plugins:
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
{
files: ["**/*.{jsx,tsx}"],
plugins: { react, "react-hooks": reactHooks, "jsx-a11y": jsxA11y },
rules: {
"react/jsx-key": "error",
"react-hooks/rules-of-hooks": "error",
"jsx-a11y/alt-text": "warn",
},
},
];Notice the keys use the prefix you want — "react-hooks" (with the dash) is the plugin namespace, regardless of the imported variable name.
Fix 4: Set Up Globals With the globals Package
env: { browser: true, node: true } from old configs is gone. Use the globals package:
npm install -D globalsimport globals from "globals";
export default [
{
files: ["**/*.ts"],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.es2024,
},
},
rules: { ... },
},
];globals exports objects keyed by environment. Spread them into languageOptions.globals. For Jest:
import globals from "globals";
export default [
{
files: ["**/*.test.ts"],
languageOptions: {
globals: { ...globals.jest },
},
},
];Common Mistake: Adding a global by hand: globals: { foo: "readonly" }. This works for one or two — for “all browser globals,” use the globals package instead of listing 100+ names.
Fix 5: Configure the TS Parser
For TypeScript, you need both the plugin and parser from typescript-eslint:
import tseslint from "typescript-eslint";
export default tseslint.config(
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: "./tsconfig.json", // For type-aware rules
tsconfigRootDir: import.meta.dirname,
},
},
plugins: { "@typescript-eslint": tseslint.plugin },
rules: { ... },
},
);For type-aware rules (@typescript-eslint/no-floating-promises, etc.), parserOptions.project must point at a tsconfig. Without it, only AST-based rules run.
tsconfigRootDir: import.meta.dirname resolves relative tsconfig paths from the config file’s location — essential in monorepos. import.meta.dirname requires Node 20.11+; on older Node, use path.dirname(fileURLToPath(import.meta.url)).
For projects with multiple tsconfigs:
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: import.meta.dirname,
}Pro Tip: Type-aware rules are slow. Enable them for src/** only; skip type-checking for test files via a separate config block. In typescript-eslint v8, you can also opt into the new projectService: true mode, which is faster than listing tsconfigs explicitly because it discovers them lazily.
Fix 6: Monorepo: Per-Workspace Configs
For monorepos, each package can have its own eslint.config.js, or you can centralize in the root:
// Root eslint.config.js
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig([
// Global ignores
{ ignores: ["**/dist/**", "**/build/**", "**/node_modules/**"] },
// Apply to all TS files
{
files: ["**/*.{ts,tsx}"],
extends: [...tseslint.configs.recommended],
},
// Frontend-specific
{
files: ["packages/frontend/**/*.{ts,tsx}"],
rules: { "react/jsx-key": "error" },
},
// Backend-specific
{
files: ["packages/backend/**/*.ts"],
rules: { "no-console": "off" }, // Allow console in backend
},
]);For per-package overrides, place an eslint.config.js inside the package — but ESLint 9 doesn’t auto-cascade like the old format. You’d run ESLint from each package’s directory if you want package-local config.
Common Mistake: Expecting child eslint.config.js files to inherit from the root. They don’t. Either centralize in root or import shared config from each child:
// packages/frontend/eslint.config.js
import baseConfig from "../../eslint.config.js";
export default [
...baseConfig,
// Frontend-specific additions
];Fix 7: Migrate Legacy Configs
ESLint provides a migration utility:
npx @eslint/migrate-config .eslintrc.jsonIt generates eslint.config.mjs from your old config, mapping extends, plugins, and env to the flat shape.
The tool isn’t perfect — it handles common cases but leaves comments where it can’t auto-translate. Review the diff carefully.
For projects that aren’t ready to switch and use ESLint 9, set the env var to opt into the legacy resolver:
ESLINT_USE_FLAT_CONFIG=false npx eslint .This is a temporary escape hatch — ESLint will drop the legacy resolver in a future major.
For projects still on ESLint 8 that want to test flat config:
ESLINT_USE_FLAT_CONFIG=true npx eslint .ESLint 8.21+ supports flat config opt-in via the env var.
Fix 8: Editor Integration
VS Code’s ESLint extension auto-detects flat config in recent versions. To force:
// .vscode/settings.json
{
"eslint.useFlatConfig": true,
"eslint.experimental.useFlatConfig": true, // Older extension versions
"eslint.options": {
"overrideConfigFile": "eslint.config.js"
}
}For JetBrains IDEs, set ESLint configuration in Settings → Languages & Frameworks → JavaScript → Code Quality Tools → ESLint, and choose “Manual ESLint configuration” pointing at your eslint.config.js.
For Neovim with nvim-lspconfig:
require("lspconfig").eslint.setup({
settings = {
experimental = { useFlatConfig = true },
},
})Pro Tip: After installing the ESLint extension, restart your editor — extensions sometimes cache the old config resolution.
Still Not Working?
A few less-obvious failures:
Cannot find module 'eslint-plugin-X'. Install it as a devDependency. Flat config requires actual imports; ESLint can’t resolve plugins fromnode_modulesby string anymore.- Linting works locally, fails in CI. Lockfile out of sync. Pin
eslintand all plugins to exact versions, and runnpm ciin CI. extendstypo. In flat config, there’s noextendsfield at the top level — you spread arrays. Old patterns likeextends: ["eslint:recommended"]don’t work. Useimport js from "@eslint/js"; export default [js.configs.recommended].overridesfrom old config. Replaced by adding more config objects with differentfilespatterns.- Cache stuck across versions. Delete
.eslintcache(or wherever your cache lives) after major upgrades. parserandpluginson the same object. Both must be in the same config object that has the rules — splitting them produces “plugin not found” errors.- Pre-commit hook runs ESLint 8 but local is ESLint 9. Husky/Lefthook usually invoke
npx eslint, which uses the project’s installed version. Make sure both your global and project versions are aligned, or pin in CONTRIBUTING. - Disabling a rule per-line. Old
// eslint-disable-line rulestill works. Configure rules in flat config; disable in code the same as before. - typescript-eslint v7 paired with ESLint 9. Officially supported but emits deprecation warnings about
RuleTester. Upgrade to typescript-eslint v8 to clear them — and to get the typedtseslint.config()helper. @eslint/compatfor legacy plugins. Some plugins haven’t published a flat-config-ready major. Wrap them withfixupPluginRules(plugin)from@eslint/compatto keep them working while you wait for an update.--inspect-configshows unexpected merging. ESLint 9.4+ shipsnpx eslint --inspect-config, which opens a UI that prints the final merged config for a given file. Use it before assuming a rule is “not running” — often the rule is set, just to"off"from a later object.
For related linting, TypeScript, and tooling issues, see ESLint config not working, ESLint parsing error unexpected token, Oxlint not working, and Biome not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Oxlint Not Working — .oxlintrc.json Config, Rule Mapping, TypeScript, and ESLint Coexistence
How to fix Oxlint errors — .oxlintrc.json not loaded, rules not matching ESLint output, TypeScript files not linted, plugin-react/typescript wiring, IDE extension setup, and running alongside ESLint.
Fix: ESLint import/no-unresolved Error (Module Exists but ESLint Can't Find It)
How to fix ESLint's import/no-unresolved errors when modules actually resolve correctly — configure eslint-import-resolver-typescript, fix path alias settings, and handle node_modules that ESLint cannot find.
Fix: Clack Not Working — Prompts Not Displaying, Spinners Stuck, or Cancel Not Handled
How to fix @clack/prompts issues — interactive CLI prompts, spinners, multi-select, confirm dialogs, grouped tasks, cancellation handling, and building CLI tools with beautiful output.
Fix: Sharp Not Working — Installation Failing, Image Not Processing, or Build Errors on Deploy
How to fix Sharp image processing issues — native binary installation, resize and convert operations, Next.js image optimization, Docker setup, serverless deployment, and common platform errors.