Migrating to Next 16 and React 19: what broke and what I pinned
For a year I ran my site on Next 15 and pnpm. The problem was never the framework — every time I came back to the project, nvm and pnpm were fighting each other: corepack worked some days and not others, and CI would quietly pick up a different Node version. That is not technical debt, just a small stone that pinches every single day.
So when Next 16 shipped I used it as an excuse and replaced the whole environment at once: package manager, framework, React version and styling system. Let me say up front — this is not a recommendation, it is a risky move. What saved me was splitting it into three separate pull requests.
First decision: move fully to Bun
I had been using Bun only on the backend (with Hono). This time I brought it to the frontend as well, and the result was better than expected: the nvm dance is over, bun install finishes in seconds, and scripts just run with bun run dev and bun run build.
One detail matters — you have to declare the package manager explicitly in package.json, otherwise CI falls back to its old habit:
{
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build"
}
}The bun --bun prefix is not cosmetic: without it Bun simply invokes Node, and you end up using its package manager rather than its runtime.
On the Vercel side, one line was enough:
{
"bunVersion": "1.x"
}This is still Public Beta, so I wrote down my retreat path in advance: if anything goes wrong, I delete that line and the build continues on Node. In a migration, every new thing should have a one-line rollback plan sitting next to it.
Next 16: middleware died, proxy was born
The most visible change is that middleware.ts is now proxy.ts. It is not just the name but the location too: mine moved to src/proxy.ts. The logic inside barely changed — I use it to set a language header (x-locale).
The real work was elsewhere: `params` and `headers()` are now asynchronous. That touches every page and every metadata generator:
export default async function Page({
params,
}: {
params: Promise<{ lang: string; slug: string }>
}) {
const { lang, slug } = await params
// ...
}The work is mechanical but widespread, and TypeScript catches all of it — so the best strategy turned out to be running bun run build and walking top to bottom through the compiler's error list.
Builds now go through Turbopack by default. For me that was mostly a speed difference; nothing broke.
React 19: easier than I feared
Honestly, this was the step I dreaded most. In practice I had almost nothing to do. The reason is simple: the project had no class components, no legacy context API and no propTypes. Those are exactly what makes React 19 painful.
There is a practical lesson in that: how painful a React upgrade is depends less on the version and more on how much your code leans on old patterns.
What cost the most time: pinning TypeScript
This is where I lost a day.
TypeScript 7 had shipped — rewritten in Go, a noticeably faster compiler. Naturally I installed it. And lint died immediately: typescript-eslint does not accept TS 7 yet, its peer requirement is <6.1.0.
The choice was: fast compilation, or working lint. I chose lint and pinned the version hard:
{
"devDependencies": {
"typescript": "6.0.3"
}
}The missing ^ is deliberate. With a caret, the next bun install would drop me back into the same hole.
This case illustrates the most common migration trap well: it is not the framework that holds you back, it is the ecosystem. The new major is out, but the plugins, linters and type packages around it have not caught up. So when planning a migration, the question is not "has the main package shipped?" but "have the plugins I depend on shipped?".
Cleaning up along the way
A big migration is the best moment to clean out dependencies, because you are already re-testing everything. I removed three outright: daisyui, autoprefixer and @eslint/eslintrc. The first is no longer needed, the second is now handled by Tailwind 4 itself, and the third was a bridge to the old ESLint config format.
One more thing involved postcss. Next pulls in version 8.4.31, which carries a known vulnerability (GHSA-qx2v-qp2m-jg93). The fix is to force the dependency up:
{
"overrides": {
"postcss": "^8.5.10"
}
}I always leave a comment next to entries like this — in six months nobody will remember why it was added, and someone will simply delete it.
The styling system moved to Tailwind 4 in the same migration, but that is a big topic of its own: the config file disappears entirely and everything moves into CSS. I will write about it in detail in the next article.
What I would do differently
Almost nothing. The one decision that clearly paid off was not stuffing everything into a single "big migration" PR. Three separate ones: runtime and package manager, then framework and React, then styles and lint. Each deployed and verified on its own.
If it had all been one PR and the build had failed, I could not have answered "what broke it?" — because four variables would have changed at once.
If you are starting a migration, use this order: environment first (runtime, package manager), then the framework, and the presentation layer last. Build and deploy after each step. And write a one-line rollback plan next to every new thing — you may not need it, but when you do, it will save your night.