Upgrading a Next.js 14 portfolio to Next 16 and React 19: what actually broke
The params Promise, revalidateTag, the removed next lint, middleware becoming proxy, and 22 new lint warnings: a real upgrade, change by change.
This portfolio started on Next.js 14 and React 18. I moved it to Next.js 16 and React 19. Most of the work was small, but a few changes would have broken the site silently if I had not read the build output carefully. Here is what actually changed.
Why upgrade at all
npm audit reported 12 advisories. Most were in development tooling, but the one rated critical was in Next.js itself, and the affected range covered every release up to 16.3. There was no patch on the 14 or 15 line to move to, so the fix was the 16.4 release. After upgrading, and updating a few transitive dependencies, the audit reported zero findings.
The changes that mattered
1. params is now a Promise. In pages and generateMetadata, you have to await it:
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// ...
}
TypeScript catches this, so it fails at build time rather than in production. I had two dynamic routes to change.
2. revalidateTag needs a second argument. My admin routes called revalidateTag("blog") after saving a post. In Next 16 that signature changed, and the compiler flagged all seven call sites. The replacement for route handlers is a cache-life profile:
revalidateTag("blog", "max");
There is also updateTag, but it only works inside Server Actions and throws in a route handler, so it was not an option here.
3. next lint is gone. The lint script now runs ESLint directly. That means ESLint 9 and a flat config file:
// eslint.config.mjs
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
export default [
{ ignores: [".next/**", "node_modules/**"] },
...nextCoreWebVitals,
];
4. middleware.ts became proxy.ts. Next 16 deprecates the middleware file name. The official codemod does the rename and changes the exported function name:
npx @next/codemod@canary middleware-to-proxy .
In my case that file is the admin login gate, so I did not trust a green build. I started the app and sent requests: no cookie, a tampered signature, a garbage token, a token signed with the wrong secret and an expired token all returned 401, while a correctly signed token reached the handler. The rename kept the behaviour exactly.
5. A removed experiment flag. My config had experimental.missingSuspenseWithCSRBailout, which no longer exists. Deleting it was the whole change.
The new lint rules
eslint-config-next 16 includes the React Compiler rules, and they flagged 22 places in my code. Most were the same pattern: setting state inside an effect to copy something from the browser. For example, my theme provider read the data-theme attribute in an effect and then called setState. The cleaner version reads the attribute as an external store:
const theme = useSyncExternalStore(subscribe, readTheme, () => "dark");
That also removed a side effect I had hidden inside a state updater. I fixed the 22 warnings in the code rather than turning the rules off. While testing in a browser afterwards, React also logged a missing key on a fragment, which I fixed too.
React 19
Next 16 still runs on React 18 but prints a deprecation warning, and Next 17 will drop it. For this project, moving to React 19 needed no code changes, so I did it in the same pass.
What I would do again
- Upgrade on a branch and run a production build early. The compiler pointed at almost everything.
- Treat the security-sensitive file (the proxy) as something to test with real requests, not just a build.
- Fix the new lint warnings instead of silencing them. Several pointed at real design problems.
If you have an older Next.js site that needs a careful upgrade, see my web development services or contact me.