Migrating to Next.js 16: A Practical Upgrade Guide
Next.js 16 makes caching opt-in, turns params into promises, and swaps middleware for proxy.ts. Here is how to upgrade an App Router app without breaking it.
Next.js 16 is the biggest release since the App Router landed, and the upgrade
is not a one-line bump. The caching model changed shape, params and
searchParams are now promises everywhere, Turbopack runs your builds by
default, and middleware.ts is on its way out in favour of proxy.ts. None of
that is hard on its own. The trouble is that the changes touch almost every
dynamic route in a real app at once, so a rushed upgrade tends to fail in a
dozen small places rather than one obvious one.
We run this site on Next.js 16, and we have moved client projects across the same gap. The pattern that works is boring and reliable: read the codemod output, fix the async APIs first, decide your caching strategy deliberately instead of letting the old implicit behaviour leak back in, then clean up the renamed files. This guide walks through that order, with the specific gotchas that cost the most time. If you are still on Next.js 13 or 14, the same steps apply, you just have more of them to work through.
Run the codemod, then read what it could not fix
Start with the official upgrade command. It pulls the right versions of next,
react, and react-dom, and runs the codemods that handle the mechanical
rewrites for you.
npx @next/codemod@latest upgrade latestThe codemod is good, but it is not magic. It will happily wrap your params
access in await where the shape is obvious, and skip anything indirect, a
params object passed into a helper, destructured two functions deep, or read
inside a generateMetadata you wrote by hand. Treat the codemod as the first
80%, not the finish line.
Once it has run, do a clean install and a type check before you touch anything
else. With typescript.ignoreBuildErrors set, as it is on many projects, the
build will not catch these for you, so run the type checker yourself.
rm -rf node_modules .next && npm install && npx tsc --noEmitThe errors that come back are your real to-do list. Most of them will be the async API change, which is the next section.
Params and searchParams are promises now
This is the change that touches the most files. In Next.js 16, params and
searchParams are promises in pages, layouts, route handlers, and metadata. The
reason is good: it lets the framework start rendering the static shell before the
dynamic values resolve, which improves streaming. The cost is that every place
you read them needs to await.
A page that used to look like this:
export default function Post({ params }: { params: { slug: string } }) {
const post = getPost(params.slug);
return <Article post={post} />;
}becomes this:
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPost(slug);
return <Article post={post} />;
}The trap is the indirect cases. If you pass params into a generateMetadata
function, into a shared layout helper, or down through a server component that
reads it, each of those signatures has to change to Promise<...> and await as
well. Search your codebase for every params: and searchParams: type
annotation and confirm each one is a promise. This is tedious but it is the
single highest-leverage hour of the whole migration.
Client components cannot await
A client component cannot await a promise prop directly. If a 'use client'
component needs route params, read them in the server component above it, await
there, and pass the resolved string down. Or use the use() hook, which unwraps
a promise inside a client component. Do not try to await in the render body.
Caching is opt-in: decide, do not drift
The headline change in Next.js 16 is that caching is now explicit. Earlier versions cached aggressively by default and surprised people with stale data, fetches that were quietly deduped and frozen, routes that went static when you expected them dynamic. Version 16 flips the default: dynamic code in any page, layout, or route handler runs at request time unless you opt into caching.
That is healthier, but it means an app that felt fast because it was
accidentally cached can get slower after the upgrade. The fix is to opt back in
on purpose, with Cache Components and the use cache directive, on the data
that is genuinely safe to cache.
async function getPricingTiers() {
'use cache';
const tiers = await db.query('SELECT * FROM pricing');
return tiers;
}Walk your routes and sort each data dependency into one of three buckets: cache it (rarely changes, same for everyone, like marketing copy or pricing), revalidate on a timer (changes a few times a day, like a blog index), or never cache (per-user or per-request, like a dashboard). Marketing pages and blog posts should be static or time-revalidated; anything keyed to a logged-in user stays dynamic. The goal is to make every caching decision visible in the code, instead of inheriting whatever the old defaults happened to do.
Middleware becomes proxy, and Turbopack runs the build
Two smaller renames round out the upgrade. middleware.ts is being replaced by
proxy.ts. The API is largely the same, so this is mostly a file rename and an
export rename, but it signals intent: the proxy layer is for routing concerns
like redirects, rewrites, and header rules, not for heavy logic. If you were
doing real work in middleware, the upgrade is a good moment to move it into a
route handler or a server component where it belongs.
Turbopack is now the default bundler for both next dev and next build. Builds
are faster, but a few webpack-specific customizations in next.config will not
carry over. If you had custom webpack loaders or plugins, check whether Turbopack
has a native equivalent before you reach for an escape hatch. Most apps that
stuck to defaults will notice nothing except quicker builds.
Upgrade on a branch with a preview deploy
Do the whole migration on a feature branch and let your preview environment build it. The errors that matter, hydration mismatches, a route that went dynamic when it should be cached, a metadata function that lost its params, show up in a real build and a real page far more reliably than in local dev.
A sane order of operations
If you take one thing from this, take the sequence. The migrations that go sideways are the ones that try to fix everything at once.
- Run the codemod and commit the mechanical changes on their own.
- Fix every async
paramsandsearchParams, including the indirect ones, untiltsc --noEmitis clean. - Make caching explicit, route by route, so nothing relies on the old implicit behaviour.
- Rename middleware to proxy and trim any logic that does not belong there.
- Build on a preview deploy and click through the real pages before you merge.
Done in that order, a typical App Router app moves to Next.js 16 in an afternoon, not a fortnight. The payoff is a faster bundler, a caching model you can actually reason about, and a clear runway to React 19.2 features like View Transitions. If your site is also due an SEO pass while you are in there, our App Router SEO playbook pairs well with this upgrade.
If you are staring at a large or business-critical Next.js app and would rather not roll the dice on the upgrade yourself, talk to us. We migrate production App Router apps for a living, the initial consultation is free, and we reply within 24 hours.