DEV Community

Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

Next.js 16 Removed next lint: ESLint 9 Migration

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:

npx @next/codemod@canary next-lint-to-eslint-cli .
Enter fullscreen mode Exit fullscreen mode

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.
Illustration — not a live capture. Verbatim text sourced from the official Next.js 16 upgrade guide and the @next/codemod README. The orange banner inside the image is the renderer-level "not a live capture" badge.

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:

// 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
Enter fullscreen mode Exit fullscreen mode

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.


Illustration — not a live capture. Example diff; the wording is quoted from the official Next.js ESLint plugin API reference and the upgrade guide. The orange banner inside the image is the renderer-level "not a live capture" badge.

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:

{
  "scripts": {
    "dev": "next dev",
    "build": "npm run lint && next build",
    "start": "next start",
    "lint": "eslint .",
    "lint:fix": "eslint --fix ."
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

npx @eslint/migrate-config .eslintrc.json
Enter fullscreen mode Exit fullscreen mode

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

// 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
Enter fullscreen mode Exit fullscreen mode

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:

- run: npm run build
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

const nextConfig = {
  eslint: {
    ignoreDuringBuilds: true,
  },
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  ignores: ['scripts/**', 'docs/**'],
}
Enter fullscreen mode Exit fullscreen mode

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.

FAQ

Should I migrate to ESLint 9 or wait for ESLint 10?

Migrate now. ESLint 10 will drop legacy config support entirely; v16 already
defaults to flat config and will not maintain backwards compatibility. The
longer you stay on .eslintrc.*, the more painful the eventual move.

Can I use Biome instead of ESLint in Next.js 16?

Yes. The upgrade guide explicitly mentions Biome as an alternative. Biome
does not consume eslint-config-next rules, so you lose the Next-specific
checks (the no-html-link-for-pages, no-img-element, core-web-vitals
rules). If those matter to you, stay on ESLint.

What about the eslint-config-next package — is it deprecated?

No. eslint-config-next is the supported way to get Next.js lint rules in
flat config. Only the next lint wrapper command is removed. The package
is alive and is what you import from in eslint.config.mjs.

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)