Skip to content

Fix: ESLint Flat Config Not Working — eslint.config.js, ignores, Plugins, and Migration

FixDevs · (Updated: )

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.
  • ignores replaces .eslintignore. The separate ignore file isn’t read anymore. You declare ignores in the config object.
  • Globals are explicit. Instead of env: browser, you spread globals.browser (from the globals package) into languageOptions.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=true and a single eslint.config.js file. 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 with tseslint.config()).
  • ESLint 9.0 (April 2024) flipped the default. eslint.config.js is now found first; legacy .eslintrc.* files require ESLINT_USE_FLAT_CONFIG=false. Node 18.18+ is required.
  • ESLint 9.1–9.10 (2024) added quality-of-life features: the defineConfig helper, the extends field inside flat config objects, --inspect-config for 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=false entirely, 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 files glob actually matches your sources.
  • You’re running from the project root (where eslint.config.js lives).
  • The file extension matches (.js, .cjs, .mjs, or .ts if 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 globals
import 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.json

It 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 from node_modules by string anymore.
  • Linting works locally, fails in CI. Lockfile out of sync. Pin eslint and all plugins to exact versions, and run npm ci in CI.
  • extends typo. In flat config, there’s no extends field at the top level — you spread arrays. Old patterns like extends: ["eslint:recommended"] don’t work. Use import js from "@eslint/js"; export default [js.configs.recommended].
  • overrides from old config. Replaced by adding more config objects with different files patterns.
  • Cache stuck across versions. Delete .eslintcache (or wherever your cache lives) after major upgrades.
  • parser and plugins on 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 rule still 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 typed tseslint.config() helper.
  • @eslint/compat for legacy plugins. Some plugins haven’t published a flat-config-ready major. Wrap them with fixupPluginRules(plugin) from @eslint/compat to keep them working while you wait for an update.
  • --inspect-config shows unexpected merging. ESLint 9.4+ ships npx 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles