packages/eslint/src/migrations/update-23-1-0/convert-to-flat-config.md
These instructions guide you through finishing the migration of an Nx workspace to ESLint v9.
ESLint v9 makes flat config (eslint.config.{mjs,cjs,js}) the default config format. The legacy eslintrc format (.eslintrc.*) still works at runtime, but only when ESLINT_USE_FLAT_CONFIG=false is set, so Nx converts workspaces to flat config instead of relying on that escape hatch.
The migration runs in two halves:
@nx/eslint:convert-to-flat-config generator) that already converted the JSON and YAML eslintrc configs.Work systematically through each section below.
<pre_pass_summary note="a deterministic pre-pass already applied these edits; verify the new shape is in place rather than redoing them">
The pre-pass handled, mechanically:
eslint.config.mjs:
eslint:recommended to js.configs.recommended@nx/* presets to their flat-config equivalentsenv to languageOptions.globalsparser / parserOptions to languageOptionsplugins to the flat plugins objectignorePatterns and .eslintignore to ignores.eslintrc/.eslintignore references in nx.json and project.json inputs@eslint/js and @eslint/eslintrc to package.json when the converted config needs them.The pre-pass does NOT:
.eslintrc.js, .eslintrc.cjs). It cannot evaluate them safely.FlatCompat shim should become flat-native config.Everything the pre-pass could not finish is forwarded to you in <advisory_context>.
How to read the wrapper sections above this file:
<files_changed> lists files the pre-pass wrote. Verify the new shape is in place; do not re-apply the same edit. It is absent when the pre-pass made no changes (for example a workspace that was already on flat config).<advisory_context> lists detections the pre-pass forwarded because it could not safely complete them. Every entry is pending work. Address each one in the relevant section below.</pre_pass_summary>
<handoff_guidance>
In your handoff summary (1 to 3 sentences per the system prompt), name the sections you applied and explicitly call out any you skipped because they did not apply (for example "no JavaScript-based configs and no removed formatters in this workspace").
</handoff_guidance>
Confirm the ESLint version is v9:
npx eslint --version
Locate all ESLint config files:
eslint.config.{mjs,cjs,js} at the root and in each project..eslintrc, .eslintrc.json, .eslintrc.yaml, .eslintrc.yml, .eslintrc.js, .eslintrc.cjs..eslintignore.Identify all lint targets:
nx show projects --with-target lint
Check project.json files for the @nx/eslint:lint executor or eslint run-commands. Workspaces using the inferred plugin (@nx/eslint/plugin) get lint targets from the presence of eslint.config.*; inspect them with nx show project <name> --json.
Identify local ESLint rules or plugins authored inside the workspace. These use the rule API that changed in v9 (see section 6).
ESLINT_USE_FLAT_CONFIG=false is set. Nx converts the workspace to flat config so that no environment variable is required.eslint.config.mjs that each project imports, for example import baseConfig from '../../eslint.config.mjs'. Convert and verify the base config first, then the per-project configs.@nx/eslint/plugin infers the lint target from the presence of eslint.config.*. Renaming or moving the config invalidates inference. After config edits, run nx reset && nx show project <name> on a sample project to confirm the target is still present.extends or a complex override natively, it emitted a FlatCompat shim (from the @eslint/eslintrc package). That config works as-is, but section 3 covers replacing it with flat-native config where low-risk.If the workspace already uses eslint.config.* at the root and in every project, with no remaining .eslintrc.* files, do NOT restructure it. The only required work is the passing-state check in section 4: a workspace on ESLint v9 can newly fail because v9 and typescript-eslint v8 changed which rules their recommended sets enable, even when the config was already flat.
Search pattern: .eslintrc.js and .eslintrc.cjs files (forwarded in <advisory_context>).
What changed: the pre-pass only converts JSON and YAML eslintrc files. JavaScript-based configs run arbitrary code, so they need manual conversion.
// BEFORE (.eslintrc.js)
module.exports = {
extends: ['../../.eslintrc.json'],
overrides: [
{
files: ['*.ts'],
rules: { '@typescript-eslint/no-explicit-any': 'error' },
},
],
};
// AFTER (eslint.config.mjs)
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
{
files: ['**/*.ts'],
rules: { '@typescript-eslint/no-explicit-any': 'error' },
},
];
Action items:
eslint.config.mjs, mirroring the structure the pre-pass produced for the JSON/YAML configs..eslintrc.js / .eslintrc.cjs once the flat config replaces it.project.json / nx.json inputs that referenced the old file name.Search pattern: FlatCompat, @eslint/eslintrc, compat.extends(, compat.config( in the generated eslint.config.* files (listed in <files_changed>).
What changed: FlatCompat is a runtime shim that adapts eslintrc-style extends into flat config. Many plugins now ship native flat presets, which are clearer and avoid the shim.
Decision rule: convert a FlatCompat usage to flat-native config when it is low-risk, otherwise keep the shim.
eslint-plugin-react, eslint-plugin-import).// BEFORE (FlatCompat shim, eslint.config.mjs)
import js from '@eslint/js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { FlatCompat } from '@eslint/eslintrc';
const compat = new FlatCompat({
baseDirectory: dirname(fileURLToPath(import.meta.url)),
recommendedConfig: js.configs.recommended,
});
export default [...compat.extends('plugin:@typescript-eslint/recommended')];
// AFTER (flat-native, eslint.config.mjs)
import tseslint from 'typescript-eslint';
export default [...tseslint.configs.recommended];
Action items:
FlatCompat usage, decide flat-native vs keep-the-shim using the rule above.@eslint/eslintrc import if no shim remains in that file.This is the core requirement of the migration: the workspace must lint cleanly when you are done.
ESLint v9 and typescript-eslint v8 changed which rules their recommended sets enable. A rule the user never configured may now report errors. Disable those rules; do not edit source files to satisfy them.
The set of rules the user explicitly configured before the migration is in <advisory_context> (the entry that starts with "Passing-state requirement").
Procedure:
Run lint across the workspace:
nx run-many -t lint
Tell a rule violation apart from a plugin crash. If a project fails with a thrown error instead of rule findings - a TypeError such as context.getAncestors is not a function, a Could not find "<rule>" in plugin "<name>" / couldn't find the config "<name>" to extend from, or a plugin that fails to load - the plugin predates ESLint v9; this is not a changed preset default. Do NOT disable the rule (that silently drops its coverage); update the plugin instead (section 6), then re-run lint and continue.
For each rule that now reports errors (findings, not a thrown error):
// Disable a rule that a changed preset default newly enabled (eslint.config.mjs).
export default [
...baseConfig,
{
files: ['**/*.ts'],
rules: {
// Newly enabled by the ESLint v9 recommended set; was not enforced before the upgrade.
'no-unused-expressions': 'off',
},
},
];
Action items:
<fail_if note="if you cannot reach a passing state without editing source or disabling a user-configured rule, stop and report"> You cannot make lint pass without either editing source files or disabling a rule the user explicitly configured. Write status: failed and explain which rule and project in your summary. Do not guess. </fail_if>
Search pattern: the format option on lint targets (forwarded in <advisory_context>).
What changed: ESLint v9 removed several built-in output formatters. The built-ins that remain are stylish, html, json, and json-with-metadata. Removed: compact, codeframe, unix, visualstudio, table, checkstyle, jslint-xml, junit, tap.
Fix: switch the target to a built-in that remains, or install the matching community package and reference it by its package name.
# Example: keep junit output by installing the community formatter package.
npm install --save-dev eslint-formatter-junit
// project.json (reference the community formatter by package name)
"lint": {
"executor": "@nx/eslint:lint",
"options": { "format": "eslint-formatter-junit" }
}
Action items:
devDependencies.Search pattern: lint executor options, run-commands invoking eslint, and local rule/plugin source.
--rulesdir, --ext, and --resolve-plugins-relative-to were removed. The matching @nx/eslint:lint options (rulesdir, resolvePluginsRelativeTo, ignorePath) are not supported for flat config. Move file targeting into the config via the files and ignores keys..eslintrc.* files found up the tree. Every setting must live in eslint.config.*.context APIs below, or it only ships an eslintrc config that no longer loads. This surfaces as a thrown error (see section 4), not a new rule violation. List the installed plugins from package.json (dependencies/devDependencies matching eslint-plugin-* or @<scope>/eslint-plugin-*), and for each confirm its version supports ESLint v9 (its changelog, or that peerDependencies.eslint allows >=9). Update any that do not, and prefer the plugin's flat entry point where it ships one (for example eslint-plugin-cypress/flat). Update the plugin rather than disabling its rules.SourceCode (only relevant if the workspace authors its own rules):
context.getScope() to sourceCode.getScope(node)context.getAncestors() to sourceCode.getAncestors(node)context.getDeclaredVariables() to sourceCode.getDeclaredVariables(node)context.markVariableAsUsed(name) to sourceCode.markVariableAsUsed(name, node)context.getSource() to sourceCode.getText()context.parserServices to sourceCode.parserServicesmeta.schema in v9.Action items:
files / ignores.SourceCode API and add meta.schema where required.Clear the inference cache so renamed configs are re-detected:
nx reset
Confirm lint passes across the workspace:
nx run-many -t lint
Spot-check that a converted project resolves its config:
npx eslint --print-config <a-file-in-the-project>
Confirm no .eslintrc.* files remain unless one was intentionally kept.