Upgrading to Next.js 16: field notes from this site
I upgraded cweihan.com from Next.js 15.5 to 16.2.9 in June, split across two pull requests. The first PR did the actual framework jump: next 15.5 to 16.2.9, eslint-config-next 15 to 16, @types/node 22 to 25, and our CI runner from Node 20 to 22. A follow-up PR a bit later took typescript 5 to 6 and zod 3 to 4. Nothing else went into either PR, on purpose.
This site is a marketing site plus blog, App Router, TypeScript, Tailwind, deployed on Vercel, with a small Vitest suite and CI gating lint and tests. Small surface area, but enough of it broke in specific, googleable ways that I want the notes down before I forget them. If you are planning the same move, here is what actually happened, roughly in the order I hit it.
Do this before you touch the version number: migrate off next lint
On Next 15.5, next lint was already printing deprecation banners. In Next 16 it is removed outright. If your CI or your package.json scripts still call next lint, the framework bump will simply break your lint step, so do this first, before you touch the Next version: switch to the plain ESLint CLI, eslint ..
One surprise here. next lint quietly excluded the .next build directory for you. The ESLint CLI does not know to do that on its own. Run eslint . without adjusting anything and you will start linting generated code sitting inside .next, which is slow and throws errors on files you never wrote a line of. Add .next to your ESLint ignores before you run it for real, or the first run will look like the upgrade broke everything when it is just linting build output.
eslint-config-next 16 ships flat config natively
eslint-config-next 16 gives you native flat-config arrays instead of the old shareable-config shape. eslint-config-next/core-web-vitals and eslint-config-next/typescript are both arrays now, and the right move is to import them and spread them straight into your flat config.
Do not keep a FlatCompat wrapper around them out of habit from the Next 15 setup. I tried it against config 16 and it crashed immediately with Converting circular structure to JSON. FlatCompat exists to translate old-style configs into flat config, and these configs are already flat, so wrapping them a second time just breaks the serializer. Drop the compat layer for anything eslint-config-next now exports natively.
The big trap: ESLint 10 is not compatible yet
This is the one that will burn the most people, and if you landed here from a search engine, this is probably why. eslint-config-next 16 declares a peer dependency of eslint >=9.0.0. That range is technically true and practically misleading: it happily lets npm install ESLint 10 alongside it, reports no conflicts, and then lint crashes at runtime with context.getFilename is not a function. ESLint 10 removed that API. The version of eslint-plugin-react bundled inside eslint-config-next 16 still calls it.
The fix is simple once you know it: pin ESLint to 9 and stay there for now. Dependabot will keep opening a PR offering the ESLint 10 bump, and the right call is to keep declining it until the plugin catches up. I did not just trust the peer range. I validated it directly: one preview branch on the ESLint 10 bump, one on ESLint 9. The ESLint 10 branch's preview build errored on exactly this. The ESLint 9 branch built clean. If your lint is failing with that exact message mid-upgrade, check your installed ESLint major version first.
react-hooks v6 has a new refs rule, and it has a blind spot
eslint-config-next 16 pulls in react-hooks v6, which adds a new rule around refs. It is a good rule most of the time: it catches code that reads ref.current during render, which really is unsafe. But it has a false positive on polymorphic components, the pattern where a component accepts an as prop and forwards a ref through createElement(as, { ref }). That code is attaching a ref, not reading one. The rule cannot tell the difference and flags it as "Cannot access refs during render" anyway.
I scoped the rule off rather than disabling it project-wide. It is right the overwhelming majority of the time, so turning it off everywhere would have thrown away real protection to fix one component. Disable that specific rule only in the ESLint config scope covering the affected directory, and leave it enabled everywhere else.
tsconfig.json gets auto-rewritten on the first build
The first next build you run on 16 rewrites tsconfig.json for you. jsx flips from preserve to react-jsx, and a .next/dev/types entry gets added to your include paths. This is expected, documented behavior, not a bug and not something to revert by hand. Run the build, look at the diff it produces, and commit it as part of the upgrade.
The TypeScript 6 gotcha a green Vercel deploy will hide from you
This one came out of the follow-up dependency PR rather than the framework PR itself, but it belongs next to the ESLint trap because it is the same shape of problem: one tool silently tolerates something another tool rejects. TypeScript 6 deprecates baseUrl in tsconfig.json. Run tsc --noEmit against a config that still sets it, and you get error TS5101.
Here is the part that actually matters. next build does not fail on this. It ignores the deprecation entirely and builds anyway. Vercel runs next build for your deploy, so the deploy goes green while a standalone typecheck would be red. A passing deploy is no longer proof your types are clean. You have to run tsc --noEmit yourself, locally or as its own CI step. The documented, zero-behavior-change silencer is adding "ignoreDeprecations": "6.0" to compilerOptions, which quiets the warning without changing how anything resolves, buying time to migrate off baseUrl later.
Diff the route table, even when you expect nothing to change
Once the build succeeds, next buildprints a route table showing which routes are static and which are dynamic. Major version bumps can change that classification for routes that lean on behavior the framework quietly altered. Ours came out identical between 15.5 and 16.2.9: not one route moved from static to dynamic or back. That is a five minute check against the previous build's output, and it is exactly the kind of regression a visual smoke test would never catch, so I would not skip it on a larger app just because it was clean here.
What the upgrade did not fix
Worth naming so nobody goes chasing it later: npm audit reports two moderate advisories on postcss, and they live inside Next's own bundled node_modules, not in anything this project depends on directly. They are build time only, not reachable at runtime, and they are still present on 16.2.9. That needs a future Next patch release, not action on our side, and going after it now would just be noise.
The bottom line
Every real problem in this upgrade had the same shape: a peer dependency range, or a build tool, being more permissive than the thing it wraps, so the failure surfaces later and in a different tool than the one that actually caused it. ESLint 10 installs cleanly and only fails at lint runtime. baseUrl passes next build and fails tsc. None of it is exotic once you have hit it, which is the whole point of writing it down. If you are mid-upgrade on a similar stack and want a second pair of eyes on your CI setup before you ship it, send me a brief and I will look at what you have got.