Skip to content

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

FixDevs ·

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.

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.

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.

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.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.

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