Upgrading to Next.js 16: What Fails Loudly and What Changes Quietly
Most of the breaking changes in 16 stop the build, which makes them safe. The ones worth planning for are the handful that change behaviour without telling you.
Next.js 16 has a long list of breaking changes and most of them are the good kind: they stop the build. You run it, it fails, you fix it, you move on.
The ones worth reading carefully are the few that change behaviour without failing anything. Those ship.
This is written from a codebase running 16.3 - the site you are reading it on.
The one architectural decision: middleware became proxy
middleware.ts is deprecated and renamed to proxy.ts, with the exported
function renamed from middleware to proxy. The codemod does the rename.
What the codemod cannot do is the decision underneath it, and it is a real one:
The
edgeruntime is not supported inproxy. The proxy runtime isnodejs, and it cannot be configured.
If your middleware ran on the edge - and by default it did - renaming the file moves that work from a hundred edge locations to your Node server. For a rewrite or a locale redirect that runs on every request, that is a change in where the latency comes from, not a rename.
The documented path if you need the edge runtime is to keep using
middleware for now. So the upgrade has a fork in it:
- Light routing work - rewrites, redirects, a cookie read - move to
proxyand accept the Node runtime. - Anything latency-sensitive on the edge - stay on
middlewareuntil the follow-up guidance lands, and know you are on a deprecated convention.
Either way this is the moment to check whether the matcher was ever right. Middleware runs on every request, including the ones you forgot - and now every one of those requests is a trip to your origin.
Config flags renamed with it: skipMiddlewareUrlNormalize is
skipProxyUrlNormalize. The codemod handles those.
What fails loudly, so you cannot ship it
A webpack config with next build. Turbopack is now the default for
next build, not just next dev, and a project with a custom webpack config
fails the build deliberately rather than silently ignoring it. Three ways out:
build with --turbopack and ignore the config, port the config to Turbopack
options, or opt out with --webpack. Worth knowing: a plugin may be adding a
webpack option you did not write, which is the confusing version of this
error.
Parallel routes without default.js. Every slot now requires an explicit
default.js and the build fails without it. A file that calls notFound() or
returns null restores the previous behaviour.
Node 18. The minimum is now Node 20.9. TypeScript's minimum is 5.1. Browser targets moved to Chrome, Edge and Firefox 111+ and Safari 16.4+.
next lint, AMP, and runtime config. All removed. serverRuntimeConfig
and publicRuntimeConfig are gone - use environment variables.
next/legacy/image is deprecated, and so is images.domains, which
remotePatterns replaced.
What changes quietly, which is the part to plan for
Image caching went from 60 seconds to 4 hours. images.minimumCacheTTL
now defaults to 14400. Nothing errors. If you were relying on the old
default to refresh images that change during the day, they now do not - and
the symptom is a stale image nobody can reproduce because their browser cache
disagrees with the optimiser's. Set it back explicitly if you need it:
// next.config.ts
images: { minimumCacheTTL: 60 },next build no longer runs lint. If your CI relied on the build to catch
lint errors, it has quietly stopped. @next/eslint-plugin-next also defaults
to flat config now. Run ESLint or Biome as its own step - and check that the
step exists before you assume it is passing.
Image srcset defaults moved. 16 was dropped from the default
imageSizes array and the qualities default changed. Neither errors; both
change what is generated and therefore what is cached and served.
Local images with a query string now need config. /photo?v=1 requires an
images.localPatterns entry with a matching search. This one does error -
but only on the routes that use the pattern, which may not be the ones in your
smoke test.
The order that works
- Read the guide before the codemod. It is in
node_modules/next/dist/docs/once you are on 16.2 or later, matched to the version you actually installed rather than whatever the internet remembers. - Run the upgrade codemod, then the async request API codemod separately
if you still have synchronous
params,searchParams,cookies(),headers()ordraftMode()from the 15 compatibility period. The upgrade codemod does not run every migration. - Decide the proxy question deliberately, because nothing will prompt you.
- Diff the build output against the old one. Which routes are
○and which areƒshould not have changed, and if it has, that is worth understanding before you ship. - Check the quiet list above against your own configuration.
If you are several versions behind
The 15-to-16 step is manageable. Arriving from 13 or 14 with a Pages Router still in the tree is a different project, and the mistake is treating it as one upgrade. Migrating without a freeze is the order that keeps the application shippable while it happens, and migration and rescue is that work done for you.
