Next.js 16 Removed next lint: ESLint 9 Migration
Developer Guide

Next.js 16 Removed next lint: ESLint 9 Migration

Next.js 16 removed next lint and no longer lints on build. Migrate to ESLint 9 flat config with the codemod, an eslint.config.mjs, and the CI step.

11 min read
Next.js 16 Removed next lint: ESLint 9 Migration

Photo by Artem Sapegin on Unsplash

What actually broke in Next.js 16#

Three things disappeared at once, and each one silently changes how your project behaves in CI:

  1. next lint is removed. Running npx next lint in Next.js 16 throws Unknown command: lint. If your package.json has "lint": "next lint", that script is now dead.
  2. The eslint next.config key is removed. eslint: { ignoreDuringBuilds: true } in next.config.mjs is silently ignored — and so is eslint: { dirs: ['lib'] }.
  3. next build no longer lints. Pre-16, a production build ran an ESLint pass and could fail the build on lint errors. That pass is gone. A build that used to turn red on a lint error now turns green.

This is a migration, not a regression. The Next.js team moved linting to the ESLint CLI (or Biome) because next lint was a thin wrapper around ESLint that lagged behind ESLint releases — the original incompatibility with ESLint 9 was tracked for months in vercel/next.js#64409 before being fixed, and the team decided to retire the wrapper entirely in v16.

The upgrade guide is the canonical source for the change; the flat-config default landed in PR #84874, and the migration codemod shipped in commit c57fe25.

The 30-second path: run the official codemod#

Next.js ships a codemod that does the mechanical part for you. From your project root:

bash
npx @next/codemod@canary next-lint-to-eslint-cli .

It does four things:

  1. Rewrites package.json scripts. "lint": "next lint" becomes "lint": "eslint ."; --strict maps to --max-warnings 0; --dir / --file flags map to explicit paths.
  2. Creates eslint.config.mjs with the Next.js recommended flat config if you do not have one.
  3. Updates an existing flat config to include the Next.js rules if it does not already.
  4. Installs the dependencies for you: eslint, eslint-config-next, @eslint/eslintrc (the compatibility shim you will need if any plugin in your tree still ships legacy config).

Run it, then run npm run lint once to confirm the output is the same shape as before. That is the happy path. The rest of this article is the unhappy path — the parts the codemod cannot fix automatically.

Illustration: terminal output of the @next/codemod next-lint-to-eslint-cli run, showing the four file mutations (eslint.config.mjs created, .eslintrc.json removed, package.json scripts updated, reminder that next build no longer runs linting). Verbatim text sourced from the official Next.js 16 upgrade guide and the @next/codemod README.

Writing eslint.config.mjs by hand#

If the codemod does not cover your setup (monorepo, custom rules, Prettier chain), here is the minimal flat config that reproduces what eslint-config-next used to give you in legacy .eslintrc.json:

js
// eslint.config.mjs
import coreWebVitals from 'eslint-config-next/core-web-vitals'
import typescript from 'eslint-config-next/typescript'
 
const eslintConfig = [
  ...coreWebVitals,
  ...typescript,
  {
    ignores: [
      '.next/**',
      'out/**',
      'build/**',
      'next-env.d.ts',
      'node_modules/**',
    ],
  },
]
 
export default eslintConfig
Illustration: diff from the legacy .eslintrc.json (extends next/core-web-vitals and next/typescript) to the flat eslint.config.mjs that imports eslint-config-next/core-web-vitals and eslint-config-next/typescript. Example only — text quoted from the official Next.js ESLint plugin API reference and the upgrade guide.

Two subpath exports matter, both confirmed in PR #84874:

  • eslint-config-next/core-web-vitals — the core-web-vitals ruleset (the one next lint applied by default).
  • eslint-config-next/typescript — TypeScript-specific rules; add it only if your project uses TypeScript.

The spread is intentional: each export is an array of config objects, so you concatenate them into one flat config array. Do not wrap them in defineConfig([...]) and then nest again — ESLint will warn about configFactory returning an array.

Why not defineConfig and globalIgnores#

You will see tutorials that import defineConfig and globalIgnores from eslint/config. That works, but it is sugar over the array form. The @next/eslint-plugin-next README and the PR both use the plain-array form, which is what eslint-config-next itself exports. Stick to the plain form and you will not be surprised when a plugin you add does not understand defineConfig.

package.json scripts that replace next lint#

The codemod writes these for you, but if you are setting them up manually:

json
{
  "scripts": {
    "dev": "next dev",
    "build": "npm run lint && next build",
    "start": "next start",
    "lint": "eslint .",
    "lint:fix": "eslint --fix ."
  }
}

Two details that bite people:

  • --max-warnings 0 replaces --strict. The old next lint --strict flag turned warnings into errors. The ESLint CLI equivalent is eslint . --max-warnings 0. Put it in your lint script if you want CI to fail on warnings.
  • next build no longer lints, so add lint to the build script if your CI relied on the build to catch lint errors. npm run lint && next build is the cheapest way to preserve the old behavior without a separate CI step.

Migrating from a legacy .eslintrc.json#

If you had a hand-written .eslintrc.json that extended next/core-web-vitals and added custom rules, the codemod will not perfectly translate your custom rules. The ESLint project publishes its own migration tool:

bash
npx @eslint/migrate-config .eslintrc.json

It emits a starter eslint.config.mjs that wraps your old config with FlatCompat from @eslint/eslintrc. The output looks like this:

js
// eslint.config.mjs — generated by @eslint/migrate-config
import { FlatCompat } from '@eslint/eslintrc'
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
 
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
 
const compat = new FlatCompat({ baseDirectory: __dirname })
 
const eslintConfig = [
  ...compat.extends('next/core-web-vitals', 'next/typescript'),
  {
    rules: {
      // your old custom rules land here
      'no-console': 'warn',
    },
  },
]
 
export default eslintConfig

FlatCompat is the bridge: it lets a flat config consume plugins and shareable configs that still ship the legacy .eslintrc-style export. Use it for any third-party plugin that has not migrated yet — for example, many eslint-plugin-* packages on npm still export a legacy config object. Once the plugin ships a flat config, you can drop the compat layer and import it directly.

The .eslintrc plugins that have not migrated#

This is the part the upgrade guide skips. If your .eslintrc.json extended plugin:prettier/recommended or any plugin that has not published a flat config export, ESLint 9 will throw Failed to load config "prettier" at startup. Two fixes, in order of preference:

  1. Use the plugin's flat config export if it has one. Prettier, for instance, ships eslint-config-prettier/flatconfig since v9.1.0. Import it directly and spread it last in your array.
  2. Wrap with FlatCompat if it does not. compat.extends('plugin:prettier/recommended') still works because @eslint/eslintrc knows how to translate the legacy format.

The codemod installs @eslint/eslintrc for exactly this reason. Keep it in your devDependencies until every plugin in your tree has migrated; then you can remove it.

CI/CD: where the silent break lives#

This is the highest-impact section. Pre-16, a typical GitHub Actions job looked like:

yaml
- run: npm run build

next build ran ESLint, and a lint error failed the job. Post-16, the same job is green on the same codebase even if it is full of lint errors, because next build no longer lints. Your lint coverage silently drops to zero the day you upgrade.

Fix it by adding an explicit lint step before the build:

yaml
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run lint
      - run: npm run build

If you have a separate CI workflow that already ran next lint, change the script call from next lint to eslint .. The Next.js + Supabase CI/CD production guide covers the broader workflow, but the one-line change for the lint step is npx next lintnpx eslint ..

The ignoreDuringBuilds trap#

A lot of projects had this in next.config.mjs:

js
const nextConfig = {
  eslint: {
    ignoreDuringBuilds: true,
  },
}

In Next.js 16, the eslint key is removed. The config still loads without error — Next.js ignores unknown keys — so the file does not break. But the behavior changes: the key no longer does anything because the build no longer lints at all. You can leave the block in for a version or two as a no-op, but the clean move is to delete it and move the ignore list into eslint.config.mjs:

js
{
  ignores: ['scripts/**', 'docs/**'],
}

That puts all lint configuration in one place — the ESLint flat config — instead of split between next.config.mjs and .eslintrc.

Common pitfalls, ranked by how often they bite#

1. Unknown command: lint in CI. Your CI script still calls next lint. Change it to eslint . (or run the codemod, which rewrites the lint script). This is the single most common upgrade break.

2. Failed to load config "next/core-web-vitals". You copied an old .eslintrc.json that extends next/core-web-vitals into a Next.js 16 project. The legacy export path is gone. Replace the file with eslint.config.mjs that imports from eslint-config-next/core-web-vitals.

3. next build is green, lint is broken. You removed the lint step during the upgrade and never added it back. Add npm run lint to CI before next build — otherwise lint regressions ship to production undetected. This is the silent-coverage bug above.

4. TypeScript rules missing. You added eslint-config-next/core-web-vitals but forgot eslint-config-next/typescript. Your @typescript-eslint rules disappear and no-unused-vars no longer catches unused TS variables. Add the TypeScript export to the array.

5. Parsing error: Cannot find module 'next/babel'. This is a different, older issue — a leftover .babelrc referencing next/babel while the project uses SWC. It is covered separately in the next/babel parsing error fix; do not conflate it with the flat-config migration.

A note on Turbopack#

next build --turbopack (the Turbopack bundler) is unrelated to this change. Turbopack replaces Webpack as the bundler; it does not change how linting works. If you hit a build error after upgrading and it mentions Turbopack, it is a bundler issue, not a lint issue — the Turbopack stuck fix covers that separately. Do not try to "fix" a Turbopack error by editing your ESLint config.

Verifying the migration worked#

Three checks, in order:

  1. npm run lint runs without ERR_MODULE_NOT_FOUND and without Failed to load config. If either appears, a plugin in your tree still ships legacy config — wrap it with FlatCompat.
  2. npm run build succeeds and the build log does not contain a lint pass. Pre-16, the build log had a "Linting and checking validity of types" step; post-16, that step is absent. That absence is correct.
  3. CI fails on a deliberately introduced lint error. Add const _x = 1 to a file, push, and confirm the CI job fails on the npm run lint step. If it passes, your lint script is still pointing at next lint.

Production recommendations#

  • Pin eslint and eslint-config-next to the same minor version as Next.js. A eslint-config-next major bump can change rule severity without warning; lock the version in package.json.
  • Run lint:fix in pre-commit, not in CI. CI should run eslint . (read-only) and fail on errors. Auto-fixing in CI hides the signal.
  • Add eslint . --max-warnings 0 to CI only after you reach zero warnings. Turning warnings into errors before the codebase is clean makes CI red for weeks.
  • Keep @eslint/eslintrc in devDependencies until every plugin in your tree has a flat-config export. Then remove it — the compat layer is dead weight once you do not need it.
  • Move all ignore patterns into eslint.config.mjs, not next.config.mjs. One source of truth for lint config is the whole point of the migration.

Frequently Asked Questions

|

Have more questions? Contact us

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.