Fix: ESLint Flat Config Not Working — eslint.config.js, ignores, Plugins, and Migration
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.
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.
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.
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.
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.
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.