Next.js — September 6, 2026 — 9 min read
A Next.js 16 Upgrade That Created Four Separate Toolchain Failures
The linter had been silently not running for weeks. Every CI push reported a passing check on a job that never executed any lint code at all.

A single version bump — next@16 — produced four separate failures across five packages. Each failure masked the one beneath it. None of the error messages described what was actually wrong.
The linter had not been running. The CI step exited 0 every push. The code that passed was never checked.
The CLI Fallthrough That Produced a Plausible-Looking Lie
next lint does not exist in Next.js 16. It was removed, not deprecated. The upgrade notes do not call this out clearly. The CLI does not say the command is gone.
I ran Get-ChildItem node_modules/next/dist/cli -Name looking for the implementation. No next-lint.js file. The directory contains next-build.js, next-dev.js, next-start.js. Nothing for lint.
The Next.js binary uses Commander.js. Reading the actual bin file confirmed the behavior:
program.command('dev', { isDefault: true })
.argument('[directory]', ...)
Commander's isDefault: true means: when no recognized subcommand matches, route to this one. The unrecognized string becomes the first positional argument. So next lint becomes: run dev with "lint" as the project directory.
Next.js resolves <cwd>/lint, finds no such directory, and throws:
Invalid project directory provided, no such directory:
/home/runner/work/project/project/lint
No mention of lint as a command. No "command not found." A path error on a directory that has nothing to do with linting. I changed the CI invocation five times — npm run lint, npx next lint src, npx next lint --dir ., npx next lint, npx eslint . — before the source told me the command simply did not exist.
The broader point: when an error message doesn't match the action, verify that the action reached the intended code. In this case it reached nothing related to lint. Eight lines of source code answered what forty minutes of flag permutations did not.
FlatCompat Was Built for Legacy Objects, Not for Arrays
Fixing next lint meant running ESLint directly. That produced a different failure:
TypeError: Converting circular structure to JSON
--> property 'configs' -> object with constructor 'Object'
| ...
--- property 'react' closes the circle
The generated eslint.config.mjs from create-next-app uses FlatCompat:
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [
...compat.config({
extends: ["next", "next/core-web-vitals", "next/typescript"]
})
];
FlatCompat is an @eslint/eslintrc adapter. Its purpose is translating legacy .eslintrc-style configs — where extends holds resolvable string names — into flat config objects. It resolves each string via require(), then runs the result through ESLint's legacy config factory, which normalizes plugin references, applies rule inheritance, and produces flat objects.
The problem: eslint-config-next@16 no longer exports a legacy config object. I read the source:
// node_modules/eslint-config-next/dist/index.js
var config = [
{
name: 'next',
plugins: {
react: _eslintpluginreact.default,
'react-hooks': _eslintpluginreacthooks.default,
// ...
},
rules: { ... }
},
{
name: 'next/typescript',
plugins: { '@typescript-eslint': _typescripteslint.default.plugin },
// ...
}
];
module.exports = config;
This is a native flat config array. When FlatCompat resolves "next" via require(), it receives that array. Its internal legacy factory is expecting a config-shaped object — something with rules, plugins as a name-to-module map, extends as strings. Instead it gets an array whose elements happen to have plugins fields. FlatCompat partially processes them, but because the same eslint-plugin-react singleton is referenced in multiple synthesized config entries, the merged result contains circular back-references. ESLint 10's diagnostic output calls JSON.stringify on the config for error reporting. It throws.
The fix is structurally obvious once you read both sides:
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
export default [
...nextCoreWebVitals,
...nextTypescript,
{ rules: { "@typescript-eslint/no-explicit-any": "off" } }
];
No FlatCompat. No legacy adapter. The configs are native flat arrays — spread them. FlatCompat should only touch configs that are genuinely legacy. Routing a flat array through a legacy adapter because the filename extension matches is category error.
ESLint 10 Removed a Context API That react-plugin Depends On
Running npx eslint . with the corrected config produced the third failure:
TypeError: contextOrFilename.getFilename is not a function
at resolveBasedir (node_modules/eslint-plugin-react/lib/util/version.js:31)
eslint-plugin-react@7.x calls context.getFilename() to detect the file being linted. ESLint 10 removed that method from the rule context API. It had been deprecated since ESLint 9 in favor of context.filename as a property. ESLint 10 completed the removal.
The eslint-config-next package lists "eslint": ">=9.0.0" as its peer dependency range. ESLint 10 satisfies that range syntactically. In practice, eslint-plugin-react@7.37.x — a transitive dependency inside eslint-config-next — had not released a version supporting ESLint 10 at this point. The peer dependency range was an intention, not a compatibility guarantee.
Pinning to ESLint 9.39.5 resolved it:
npm install --save-dev eslint@9.39.5
Version ranges in peerDependencies express what the author tested. >=9.0.0 means the author tested on 9.x. It does not mean 10.x works. The number that matters for compatibility is not the range ceiling — it is the version of the highest-versioned transitive dependency that your package implicitly requires.
The Compiler Rules That Ship Without the Compiler
With ESLint running cleanly for the first time, nine new error-level violations appeared on code that had been shipping without complaints. These came from eslint-plugin-react-hooks@7, which is a major version bump bundled into eslint-config-next@16.
Version 7 adds the React Compiler's lint rules to configs.recommended. These rules are active by default:
react-hooks/preserve-manual-memoization
react-hooks/set-state-in-effect
react-hooks/use-memo
They fire regardless of whether the React Compiler transform is configured in next.config.ts. There is no compiler plugin in the project. No SWC transform. No Babel plugin. The compiler never runs on this code. The lint rules run anyway, because they are designed to enforce compiler-compatible patterns proactively, before projects actually enable the transform.
preserve-manual-memoization failed on this dependency array:
const filteredRows = React.useMemo(() => {
if (!data?.rows) return [];
return data.rows.filter((row) => row.name?.includes(filter));
}, [data?.rows, filter]);
The compiler's dependency inference produces data.rows — it resolves the optional chain to its non-optional terminal. The manual array specifies data?.rows. They represent the same runtime value. The compiler treats them as different dependency shapes and refuses to preserve the memoization. The rule reports it as a compile failure, not a code quality warning.
set-state-in-effect flagged setState called synchronously inside useEffect. One instance was legitimate: detecting mobile UA and OS on mount to set isIOS and isEdge state flags, which are only available after hydration. The rule does not distinguish "unavoidable browser detection" from "synchronizing derived state from a prop." Both patterns produce the same error.
The correct fix for the memoization case is removing the manual useMemo — the compiler will handle it when enabled, and the computation is cheap enough that unnecessary re-runs don't matter now. The correct fix for the browser detection case is moving the pure UA parsing into a lazy useState initializer, which runs once during client hydration:
const [{ isIOS, isEdge, anyIOS }] = useState(() => detectBrowser());
The useEffect still exists for attaching event listeners and setting timers — things that genuinely require effect timing — but it no longer calls setState synchronously. The rules that couldn't be fixed cleanly got scoped suppressions with documented reasons.
Downgrading everything to warn is not the answer. It produces a warning backlog that the next engineer inherits without context. Each violation should either be fixed or explicitly suppressed with a reason. The rule is new; the code predates it; that gap is worth documenting.
Conclusion
Four failures. One version bump. Each fix exposed the next problem. The diagnostic that worked every time was reading the actual source in node_modules rather than inferring behavior from error messages.
The toolchain had been silently not running. Code shipped untouched by linting for weeks because a CI step exited 0 without executing. That is the failure worth taking seriously — not the individual compatibility issues, but the absence of any signal that the check was broken.
Summary
Next.js 16 removed the next lint CLI command without prominent documentation; Commander.js's isDefault: true on the dev command silently routes unrecognized subcommands to it, treating "lint" as a directory argument and producing a path error with no mention of the removed command. eslint-config-next@16 now exports native flat config arrays rather than legacy .eslintrc-style objects, but the create-next-app-generated scaffold still wraps them in FlatCompat, which is designed for legacy string-resolvable configs; passing an array through FlatCompat double-wraps the same plugin singleton across multiple synthesized config entries, creating circular object references that ESLint 10 throws on during diagnostic serialization. ESLint 10 removed context.getFilename() from the rule context API; eslint-plugin-react@7.x depends on it, and the >=9.0.0 peer dependency range in eslint-config-next expresses authorial intent, not verified compatibility. eslint-plugin-react-hooks@7 bundles React Compiler lint rules as error-severity in configs.recommended, active whether or not the compiler transform is enabled, surfacing violations on code that was never written with compiler constraints in mind. The diagnostic method in all four cases was reading node_modules source directly; error messages accurately described symptoms, not causes. The meta-failure was that a CI lint step exiting 0 is indistinguishable from one that passes — the only reliable verification is confirming the check produces output.




