Upgrading Next.js
Move a Next.js 14 app to 16: read release notes, run real codemods, fix async request APIs, rename middleware to proxy, and upgrade safely.
- 01Read what changedFind a version's release post, upgrade guide and deprecation warnings, and tell a breaking change from a new option.
- 02Let codemods do the routine workRun
@next/codemodon a clean branch, read its diff, and fix by hand what it marks with@next-codemod-error. - 03Move across majors safelyHandle async request APIs,
proxy.ts, Turbopack, removed options and Cache Components, and check each step with a build and tests.
A tea shop written for Next.js 14
The tea shop was built in 2024, with Next.js 14, and nobody has touched its framework since. Upgrading means moving it to a newer version of Next.js, and changing the code the new version treats differently. The first step looks easy: install 16.4.3 and the React it comes with, then build. Here is that build, a real one. Open the Terminal tab.
app/teas/[id]/page.tsximport { notFound } from "next/navigation";import { getCurrency } from "../../currency";import { teas } from "../../teas"; export function generateMetadata({ params }: { params: { id: string } }) { const tea = teas.find((item) => item.id === params.id); return { title: tea ? tea.name : "Tea shop" };} export default function TeaPage({ params }: { params: { id: string } }) { const tea = teas.find((item) => item.id === params.id); if (!tea) notFound(); return ( <main> <h1>{tea.name}</h1> <p> {tea.price} {getCurrency()} </p> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack) ⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. To migrate automatically, run: npx @next/codemod@canary middleware-to-proxy . Learn more: https://nextjs.org/docs/messages/middleware-to-proxy Creating an optimized production build ...✓ Compiled successfully Running TypeScript ...app/currency.ts(4,20): error TS2339: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'.Failed to type check.Two things in the output. First a warning: The "middleware" file convention is deprecated. Please use "proxy" instead. Deprecated means it still works, but it's planned to be removed, and the warning names the command that changes it. Then TypeScript stops the build at line 4 of app/currency.ts: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'. In Next.js 14, cookies() returned the cookies. Since 15 it returns a promise of them. The tea's page reads params the old way too, on lines 5 and 10 of app/teas/[id]/page.tsx.
When a city renames streets, it publishes the list first. Old signs stay up for a while, with the new name below. The post office runs every typed letter through a machine that fixes the address. The letters it can't read go to a clerk, who works them out by hand.
- In real life: The city's list of renamed streets
- In Next.js: Release notes and the upgrade guide
- In real life: Old signs stay up for a while, under the new names
- In Next.js: Deprecated APIs keep working, with a warning
- In real life: A machine reprints the typed addresses on letters
- In Next.js: A codemod rewrites the code it understands
- In real life: Handwritten letters go to a clerk
- In Next.js:
@next-codemod-error: code you fix by hand
Upgrading is reading what changed, letting codemods make the routine changes, fixing the rest by hand, and proving the result with a build and tests. Next.js says what changes in release posts, upgrade guides and warnings, and @next/codemod rewrites the code it can. Do it on a branch, one step at a time.
Where changes are announced
A Next.js version has three numbers, like 16.4.3: the major (16), the minor (4) and the patch (3). This is called semantic versioning. A patch fixes bugs. A minor adds features, and can mark old ones as deprecated. A major can remove and change APIs, so code that worked can break. Next.js follows this with one exception, written in its support policy: fixes for a major in Maintenance LTS come out as minor releases, even when they break something. The policy also says what each major gets:
- Next.js 14, released October 26, 2023: No longer supported.
- Next.js 15, released October 21, 2024: Maintenance LTS, with security fixes until January 31, 2027.
- Next.js 16, released October 21, 2025: Active LTS.
Active LTS gets new features and fixes. Maintenance LTS gets only critical and security fixes, normally for two years after the major's release; for 15 the policy extended it. The tea shop's 14 gets nothing, not even security fixes. Each row is one place where changes are announced:
| Where | What it tells you | From the tea shop's upgrade |
|---|---|---|
| nextjs.org/blog | A post for each major and minor release, and the security posts: notice of a security release about once a month, then the release. | The Next.js 16.4 post, October 6, 2026. |
| The upgrade guides | One for each major: what breaks, the codemod for each change, and a checklist at the end. | How to upgrade to version 16, in the docs' Upgrading section. |
| GitHub releases | Every published version, canaries too, with the changes merged into it. | The v16.4.3 release and its list of changes. |
| The support policy | Which majors still get fixes, and for how long. Fixes for a major in Maintenance LTS come out as minor releases, even when they break something. | 15 gets security fixes until January 31, 2027. |
| Your terminal | Deprecation warnings from next build and next dev, often with the codemod to run. | The "middleware" file convention is deprecated. |
node_modules/next/dist/docs/ | The docs for the version you have installed, which coding agents read. | 16.4.3's own copy of the version 16 guide. |
npm gives some versions a name, a dist-tag, and npm view next dist-tags lists them. Each row is one tag, as it was on October 30, 2026:
| Tag | What it means | On that day |
|---|---|---|
latest | The newest stable release. npm install next gets it. | 16.4.3 |
canary | Published daily, with the newest changes. The support policy says not to serve production traffic from it. | A 16.5.0-canary version |
rc, beta, preview | Versions before a release, for trying it early: a release candidate is meant to become the release unless a problem shows up. | Older ones, like 16.0.0-beta.0 |
backport | Fixes for the previous major, which is still in Maintenance LTS. | A 15.5 version |
Security fixes have a schedule of their own. Since July 2026, the Next.js blog announces a security release about once a month, with its date and how severe the worst problem is, so you can plan the upgrade; urgent ones still come at once. Read a security post like you read a release post: which versions are affected, and which patch fixes it. (The middleware bypass walks through one.)
Codemods: changes made by a program
A codemod is a program that rewrites source code. @next/codemod reads each file into a tree, the way a compiler does, changes the parts one migration is about, and writes the file back. You run it in the project's folder with the transform's name and a path: npx @next/codemod@16.4.3 next-async-request-api .. It refuses to run while git has uncommitted changes (--force skips that), so that its changes are the only ones in git diff. The recording did the same: it committed the shop's files, ran two codemods, and showed the diff after each one. Open the Terminal tab:
app/teas/[id]/page.tsximport { notFound } from "next/navigation";import { getCurrency } from "../../currency";import { teas } from "../../teas"; export function generateMetadata({ params }: { params: { id: string } }) { const tea = teas.find((item) => item.id === params.id); return { title: tea ? tea.name : "Tea shop" };} export default function TeaPage({ params }: { params: { id: string } }) { const tea = teas.find((item) => item.id === params.id); if (!tea) notFound(); return ( <main> <h1>{tea.name}</h1> <p> {tea.price} {getCurrency()} </p> </main> );}$ npx @next/codemod@16.4.3 next-async-request-api .Executing command: jscodeshift --parser=tsx --ignore-pattern=**/node_modules/** --ignore-pattern=**/.next/** --extensions=tsx,ts,jsx,js,mjs --transform /my-app/node_modules/@next/codemod/transforms/next-async-request-api.js .Processing 7 files...All done.Results:0 errors5 unmodified0 skipped2 ok$ git add -A && git diff --stageddiff --git a/app/currency.ts b/app/currency.tsindex a3bc85d..46ca70c 100644--- a/app/currency.ts+++ b/app/currency.ts@@ -1,5 +1,6 @@-import { cookies } from "next/headers";+import { cookies, type UnsafeUnwrappedCookies } from "next/headers"; export function getCurrency() {- return cookies().get("currency")?.value ?? "USD";+ return (/* @next-codemod-error Await this API and update its callers; remove the temporary UnsafeUnwrappedCookies cast after repairing the migration. */+ cookies() as unknown as UnsafeUnwrappedCookies).get("currency")?.value ?? "USD"; }diff --git a/app/teas/[id]/page.tsx b/app/teas/[id]/page.tsxindex e2f6047..78c4836 100644--- a/app/teas/[id]/page.tsx+++ b/app/teas/[id]/page.tsx@@ -2,12 +2,14 @@ import { notFound } from "next/navigation"; import { getCurrency } from "../../currency"; import { teas } from "../../teas"; -export function generateMetadata({ params }: { params: { id: string } }) {+export async function generateMetadata(props: { params: Promise<{ id: string }> }) {+ const params = await props.params; const tea = teas.find((item) => item.id === params.id); return { title: tea ? tea.name : "Tea shop" }; } -export default function TeaPage({ params }: { params: { id: string } }) {+export default async function TeaPage(props: { params: Promise<{ id: string }> }) {+ const params = await props.params; const tea = teas.find((item) => item.id === params.id); if (!tea) notFound(); return ( $ npx @next/codemod@16.4.3 middleware-to-proxy .Executing command: jscodeshift --parser=tsx --ignore-pattern=**/node_modules/** --ignore-pattern=**/.next/** --extensions=tsx,ts,jsx,js,mjs --transform /my-app/node_modules/@next/codemod/transforms/middleware-to-proxy.js .Processing 7 files...All done.Results:0 errors6 unmodified1 skipped0 ok$ git add -A && git diff --stageddiff --git a/middleware.ts b/proxy.tssimilarity index 85%rename from middleware.tsrename to proxy.tsindex f5dfc92..e25a32e 100644--- a/middleware.ts+++ b/proxy.ts@@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; // Old links said /tea/mint. Send them to /teas/mint.-export function middleware(request: NextRequest) {+export function proxy(request: NextRequest) { const id = request.nextUrl.pathname.slice("/tea/".length); return NextResponse.redirect(new URL(`/teas/${id}`, request.url)); } $ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./app/currency.ts:4:10Error: You have an unresolved @next/codemod comment "Await this API and update its callers; remove the temporary UnsafeUnwrappedCookies cast after repairing the migration." that needs review. After review, either remove the comment if you made the necessary changes or replace "@next-codemod-error" with "@next-codemod-ignore" to bypass the build error if no action at this line can be taken. 2 | ... 3 | ...> 4 | ...t this API and update its callers; remove the temporary UnsafeUnwrappedCookies cast afte... | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 5 | ...ppedCookies).get("currency")?.value ?? "USD"; 6 | ... 7 | ... Ecmascript file had an error Import trace: Server Component: ./app/currency.ts ./app/teas/[id]/page.tsx at ignore-listed framesRead it from the top:
next-async-request-apilooked at 7 files and changed 2. Inapp/teas/[id]/page.tsx, both functions becameasync,paramsbecameprops.paramstyped as aPromise, and a new first line waits for it:const params = await props.params;.- In
app/currency.ts, it didn't fix the code. It added a cast,as unknown as UnsafeUnwrappedCookies, so the file still type-checks, and a comment starting with@next-codemod-error: Await this API and update its callers. middleware-to-proxyrenamedmiddleware.tstoproxy.tsand its function toproxy. Git shows it as a rename, with 85% of the file the same.- Then the build stopped: You have an unresolved @next/codemod comment. Next.js refuses to build with one, so a cast that only hides the problem can't reach production.
next-async-request-api rewrote the tea's page, which it knows how to change. In app/currency.ts it couldn't tell how to change the callers, so it left a comment, and next build refuses to build until you deal with it.Why couldn't it fix getCurrency? A page or layout can become async safely: Next.js is the only one that calls it. A helper has callers of its own, and making it async changes what every one of them gets. So you do it:
import { cookies } from "next/headers"; export async function getCurrency() { return (await cookies()).get("currency")?.value ?? "USD";} Line 3 makes getCurrency async, and line 4 awaits cookies() before it reads the cookie. The one caller, line 19 of the tea's page, becomes {await getCurrency()}. Now the shop builds. Open the Network tab: /teas/mint with the cookie currency=EUR says 3 EUR, and the old link /tea/mint answers 307 with location: /teas/mint, from proxy.ts.
app/currency.tsimport { cookies } from "next/headers"; export async function getCurrency() { return (await cookies()).get("currency")?.value ?? "USD";}GET /teas/mint200
- Sent with the request
cookiecurrency=EUR
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-EncodingX-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 10 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/2r4s4h5xul1n-.js" async=""></script>
<script src="/_next/static/chunks/3wbm-994r45kr.js" async=""></script>
<script src="/_next/static/chunks/2e268anpfvdoc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/27gjg8e11zukd.js" async=""></script>
<title>Mint tea</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Teas</a></nav>
<main>
<h1>Mint tea</h1>
<p>3<!-- --> <!-- -->EUR</p>
</main>
<!--$-->
<!--/$-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
</body>
</html>15: request APIs that return promises
Next.js 15 changed the APIs that read the request. Each one now returns a promise:
cookies(),headers()anddraftMode(), fromnext/headers.- The
paramsprop of pages, layouts, Route Handlers,default.jsfiles and metadata functions likegenerateMetadataandopengraph-image. - The
searchParamsprop, which only pages get, and theirgenerateMetadata.
The reason: a page can start rendering before the request's data is needed, and only the parts that await it wait. Request-time APIs shows what that lets Next.js prerender.
Next.js 15 still let old code read them directly for a while, and logged a warning in development; its upgrade guide called that temporary. Next.js 16 removed it. A TypeScript project fails to build, like the old shop. A JavaScript project has no types to stop it, so the build passes, and the page fails when it runs. Here is the old page as a .jsx file. Open the Terminal tab, and look under next start:
app/teas/[id]/page.jsximport { cookies } from "next/headers";import { teas } from "../../teas"; export default function TeaPage({ params }) { const tea = teas.find((item) => item.id === params.id); const currency = cookies().get("currency")?.value ?? "USD"; return ( <main> <h1>{tea.name}</h1> <p> {tea.price} {currency} </p> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (2/2) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ƒ /teas/[id] ○ (Static) prerendered as static contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready⨯ TypeError: (0 , c.cookies)(...).get is not a function at <unknown> (.next/server/chunks/ssr/_095i2nozvefnq._.js:1:223) { digest: '3149127413'}The build listed ƒ /teas/[id], with no error. The visit got a 500, and the server printed TypeError: … cookies(...).get is not a function, the call on line 6. Only something that asks for the page catches this: a test, or a preview deploy.
Server Components await these promises. A Client Component can't be async, so it unwraps one with React's use. This is from the version 15 guide, not recorded:
"use client";
import { use } from "react";
export default function TeaPage(props: { params: Promise<{ id: string }> }) {
const { id } = use(props.params);
return <h1>Tea: {id}</h1>;
}
Line 1 makes it a Client Component. Line 6 waits for props.params with use. The codemod writes the type by hand, { params: Promise<{ id: string }> }. Since 15.5, next typegen writes a type that knows each route's params, PageProps<"/teas/[id]">, and in 16.4.3 next build writes it too (Dynamic routes uses it). 15 also stopped caching fetch and GET Route Handlers by default: Caching before Cache Components explains that model.
16: proxy.ts, Turbopack and removed options
proxy.ts. Next.js 16 renamed middleware.ts to proxy.ts, and its function to proxy, to say what it is: code that runs in front of every matching request. The old name still works, with the warning the old shop printed. proxy.ts runs on Node.js only; the guide says an app that needs the Edge runtime keeps middleware.ts for now. Options with "middleware" in their names were renamed too, like skipMiddlewareUrlNormalize to skipProxyUrlNormalize, and the codemod changes them. The upgraded shop's route table lists it as ƒ Proxy (Middleware) (proxy.ts teaches it).
Turbopack by default. Every recorded build of the tea shop starts with ▲ Next.js 16.4.3 (Turbopack), without a flag: since 16, next dev and next build use Turbopack. Scripts like next build --turbopack can drop the flag, and --webpack keeps the old bundler. A custom webpack function in next.config is the catch: Turbopack doesn't run it.
Removed options. Some options from older versions are gone in 16. Try three versions of the upgraded shop's next.config.mjs:
next.config.mjsNext.jsnext.config.mjs/** @type {import('next').NextConfig} */const nextConfig = { eslint: { ignoreDuringBuilds: true }, serverRuntimeConfig: { supplierKey: process.env.SUPPLIER_KEY }, experimental: { dynamicIO: true },}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)⚠ `eslint` configuration in next.config.mjs is no longer supported. See more info here: https://nextjs.org/docs/app/api-reference/cli/next#next-lint-options⚠ Invalid next.config.mjs options detected:⚠ Unrecognized key(s) in object: 'eslint', 'serverRuntimeConfig'⚠ See more info here: https://nextjs.org/docs/messages/invalid-next-config > Build error occurredError: Fatal next config errors found in next.config.mjs that must be fixed: Unrecognized key(s) in object: 'dynamicIO' at "experimental" `experimental.dynamicIO` has been replaced by `cacheComponents`. Please update your next.config file accordingly. Learn more: https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponentsThese configuration options are required or have been migrated. Please update your configuration.See more info here: https://nextjs.org/docs/messages/invalid-next-config at ignore-listed framesThree options from older versions. next build warns that eslint is no longer supported and that serverRuntimeConfig is unrecognized, then stops at line 5: experimental.dynamicIO has been replaced by cacheComponents.
The first version shows two kinds of message. Warnings, marked ⚠, for options Next.js no longer supports: eslint, because next build no longer lints, and serverRuntimeConfig, which 16 removed. And an error for one it can't ignore: `experimental.dynamicIO` has been replaced by `cacheComponents`. Here is that file:
/** @type {import('next').NextConfig} */const nextConfig = { eslint: { ignoreDuringBuilds: true }, serverRuntimeConfig: { supplierKey: process.env.SUPPLIER_KEY }, experimental: { dynamicIO: true },}; export default nextConfig; Line 3 is the eslint option, line 4 the runtime config, and line 5 the experiment that became Cache Components. Each row is one change from the version 16 guide: what happens to an app that still has it, and the fix:
| What changed | What happens to an app that still uses it | The fix |
|---|---|---|
Sync params, searchParams, cookies(), headers(), draftMode() | TypeScript stops the build. In JavaScript, the page fails when it runs. | next-async-request-api, then fix what it marks. |
middleware.ts | Still works, with a deprecation warning. It's the only way to keep the Edge runtime. | middleware-to-proxy. proxy.ts runs on Node.js only. |
A webpack function in next.config | next build stops: Turbopack is the default and doesn't run it. | Move it to turbopack options, or build with --webpack. |
experimental.dynamicIO | next build stops: it has been replaced by cacheComponents. | Remove it, or adopt Cache Components on purpose. |
serverRuntimeConfig, publicRuntimeConfig | Removed. next build warns that the keys are unrecognized. | Environment variables, with NEXT_PUBLIC_ only for public values. |
next lint and the eslint option | Removed; next build no longer lints. | next-lint-to-eslint-cli, then eslint . in your scripts. |
AMP, devIndicators.buildActivity, unstable_rootParams | Removed. | No AMP; nothing for the indicator; next/root-params. |
| Node.js 18, TypeScript below 5.1 | No longer supported. | Node.js 20.9 or later, TypeScript 5.1 or later. |
The guide lists more that the tea shop didn't meet: revalidateTag now takes a cache profile as its second argument (Revalidating data), next/image has new defaults, like a four-hour minimumCacheTTL and only quality 75 (Images), and every parallel route slot needs a default.js. The browsers it supports are Chrome, Edge and Firefox 111 and later, and Safari 16.4 and later.
Turning on Cache Components after the upgrade
The upgraded shop builds without Cache Components: its output has no Cache Components enabled line, and /teas/[id] is ƒ, made for every request. An upgrade doesn't turn them on, and the version 16 guide says not to turn them on just to finish one. Since 16.4, the Next.js blog recommends them for every app, new apps from create-next-app have them on, and the blog says they'll be the default in Next.js 17. So it's worth doing, as a step of its own:
next.config.mjs/** @type {import('next').NextConfig} */const nextConfig = { cacheComponents: true,}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)⚠ `cacheComponents` is enabled without a corresponding `partialPrefetching` option. Set `partialPrefetching` to either `true` or `false`. The only reason to set `partialPrefetching` to `false` is if you're migrating an older Cache Components app. The initial release of Cache Components did not include Partial Prefetching. New projects should enable both Cache Components and Partial Prefetching. Both Cache Components and Partial Prefetching will be enabled everywhere in the next major release, and the old configurations will be removed. Learn more: https://nextjs.org/docs/app/guides/adopting-partial-prefetching- Cache Components enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error: Route "/teas/[id]": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`) - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic at body (<anonymous>) at html (<anonymous>)To get a more detailed stack trace and pinpoint the issue, try one of the following: - Start the app in development mode by running `next dev`, then open "/teas/[id]" in your browser to investigate the error. - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/teas/[id]". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /teas/[id]/page: /teas/[id], exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullLine 3 turns Cache Components on. Next.js 16.4.3 warns that partialPrefetching isn't set, then stops at /teas/[id]: the page reads params and a cookie outside Suspense.
Only cacheComponents: true brings two messages. A warning: Next.js 16.4.3 wants partialPrefetching set too, and new apps set both. And an error at /teas/[id], which reads params and a cookie outside Suspense. The docs offer a codemod, cache-components-instant-false, that adds export const instant = false to every page and layout, so the build passes while you fix routes one by one. The recording shows its diff. It's an opt-out, not a fix: the tea page still waits for everything. The last step is the fix:
import { Suspense } from "react";import { notFound } from "next/navigation";import { getCurrency } from "../../currency";import { teas } from "../../teas"; export function generateStaticParams() { return teas.map((tea) => ({ id: tea.id }));} export async function generateMetadata(props: { params: Promise<{ id: string }> }) { const params = await props.params; const tea = teas.find((item) => item.id === params.id); return { title: tea ? tea.name : "Tea shop" };} export default async function TeaPage(props: { params: Promise<{ id: string }> }) { const params = await props.params; const tea = teas.find((item) => item.id === params.id); if (!tea) notFound(); return ( <main> <h1>{tea.name}</h1> <Suspense fallback={<p>{tea.price}</p>}> <Price amount={tea.price} /> </Suspense> </main> );} async function Price({ amount }: { amount: number }) { return ( <p> {amount} {await getCurrency()} </p> );} Lines 6 to 8 list the teas, so the build knows their params. Lines 23 to 25 put the part that reads the cookie in Suspense, with the price as its fallback. Price, on lines 30 to 36, awaits getCurrency(). The build printed ◐ /teas/green and ◐ /teas/mint: a shell with the name and the price, and the currency streamed in for each request. Cache Components and Partial Prerendering teach the model, and the production checklist searches for leftover opt-outs.
Upgrading with a coding agent
A coding agent is an AI assistant that edits your project and runs commands. Next.js 16.4 added a mode of next upgrade for one, which its docs mark Experimental. This part is from the docs and wasn't recorded: it needs the network, and an agent.
# Next.js 16.1 and later: install the latest version and run its codemods
npx next upgrade
# Before 16.1, the same command from @next/codemod
npx @next/codemod@canary upgrade latest
# Hand the upgrade to a coding agent, with the newest upgrade tooling
npx next@canary upgrade --agent=latest
Line 2 is the upgrade command of Next.js 16.1 and later: it installs the newest version and offers its codemods. Before 16.1, line 4 does the same from @next/codemod; the tea shop on 14 would start there. Line 6 hands the upgrade to an agent. The docs describe what it does:
- It picks the target from a policy.
security, the default: for a stable version that a known vulnerability affects, the latest safe release in the earliest eligible major.latest: npm's latest stable release for stable, release candidate, beta and preview apps, and the latest canary for a canary app.experimental-future:latest, then the applicable Future Defaults, features Next.js plans to turn on by default; for App Router apps on 16.3 or later, it currently prepares the Cache Components guide and its adoption Skill. - It copies the upgrade guide of every major it crosses and the codemod commands into a task, and asks the agent to work in a separate git worktree, run the project's checks and verify the result.
- From a terminal, it can start an installed Codex or Claude Code session, or copy the task for another agent. Inside an agent, it prints the task for that agent.
next@canaryruns the newest upgrade tooling, even for an app on an older version.
Since 16.4, next dev and next build can also remind you when an upgrade is available. The reminders are on by default, with the security policy; experimental.agentUpgrade picks another, and false turns them off:
import type { NextConfig } from "next"; const nextConfig: NextConfig = { experimental: { agentUpgrade: "security", },}; export default nextConfig; Line 5 asks for reminders about releases that fix vulnerabilities in the installed version. Agents also read docs: Next.js ships the docs for each version in node_modules/next/dist/docs/, and an AGENTS.md file can point the agent there, so it follows the guide for the version you have, not the one it remembers. Debugging shows the MCP endpoint that lets an agent read the dev server's errors.
An agent runs the same codemods and reads the same guides. Read its diff, as you read the codemod's above, and let the same build, tests and preview prove the result.
A safe upgrade, step by step
Here is the tea shop's upgrade by hand, on a branch of its own:
git switch -c upgrade-next-16
npm install next@16.4.3 react@19.3.0 react-dom@19.3.0
npx @next/codemod@16.4.3 next-async-request-api .
git add -A && git commit -m "Await request APIs"
npx @next/codemod@16.4.3 middleware-to-proxy .
git add -A && git commit -m "Rename middleware.ts to proxy.ts"
npm run build
npx vitest run && npx playwright test
Line 1 makes a branch, so the main branch keeps working. Line 2 installs Next.js 16.4.3 and the React it comes with. Lines 3 and 5 run one codemod each, with the version pinned, and lines 4 and 6 commit after each, so each diff can be read on its own. Line 7 builds, which also type-checks. Line 8 runs the unit tests, then the end-to-end tests (Testing components, End-to-end testing). The docs' guides use @next/codemod@canary, which has the newest codemods; a pinned version keeps the steps the same every time you run them. Between lines 5 and 7, fix what the codemods marked, and anything the guide lists that your app uses.
Then deploy the branch to a preview, a copy of the app at its own address, and check it the way you'd check a launch, with the production checklist. Some changes a codemod makes for you, and some you have to make. Sort them:
- Rename
middleware.tstoproxy.ts. - Make a page await
props.params. - Make
getCurrency()awaitcookies(), and its callers await it. - Replace
next lintin package.json's scripts. - Replace a custom
webpackfunction. - Decide what replaces
experimental.dynamicIO. - Remove
export const experimental_pprfrom pages. - Put the currency in
Suspenseso the shell can be prerendered.
Sort each change: does a codemod make it, or do you?
Common mix-ups
- "An upgrade is just
npm install next@latest." No. The old shop installed in a second and didn't build. The code has to follow what the new version changed. - "A codemod fixes everything." No. It changed the page and left
@next-codemod-errorinapp/currency.ts, and the build refused until that was fixed by hand. - "Deprecated means it no longer works." No.
middleware.tsstill ran, with a warning. It means it will be removed later, so change it now. - "If it builds, it works." No. The JavaScript page built without an error and answered
500. Tests and a preview catch what a build can't. - "Upgrading to 16 turns on Cache Components." No. It's
cacheComponents: true, a change of its own, and it brought new build errors. - "Canary is the newest, so it's the best for production." No. Canaries come out daily, and the support policy says not to serve production traffic from them.
- "An agent's upgrade needs no review." No. Read its diff and run the same checks, as for your own.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The old shop's build printed this. Which command does it suggest?
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
To migrate automatically, run:
npx @next/codemod@canary middleware-to-proxy .
Learn more: https://nextjs.org/docs/messages/middleware-to-proxynpx @next/codemod@canary middleware-to-proxy .. It renames middleware.ts to proxy.ts and the function to proxy, as the recorded diff shows.
A shop moves from Next.js 16.3.6 to 16.4.3. Is that a major, minor or patch upgrade?
16.3.6 → 16.4.3Minor. The major, 16, stayed the same, and the minor went from 3 to 4. In 16, which is in Active LTS, a minor adds features and can deprecate things, but shouldn't break working code. The support policy allows breaking changes in a minor only for fixes to a major in Maintenance LTS, like 15.
next-async-request-api renames the prop to props.params. Which word does it put in front of props.params?
import { notFound } from "next/navigation";
import { getCurrency } from "../../currency";
import { teas } from "../../teas";
export function generateMetadata({ params }: { params: { id: string } }) {
const tea = teas.find((item) => item.id === params.id);
return { title: tea ? tea.name : "Tea shop" };
}
export default function TeaPage({ params }: { params: { id: string } }) {
const tea = teas.find((item) => item.id === params.id);
if (!tea) notFound();
return (
<main>
<h1>{tea.name}</h1>
<p>
{tea.price} {getCurrency()}
</p>
</main>
);
}
await. The codemod adds const params = await props.params; as the first line of TeaPage and of generateMetadata, as the recorded diff shows.
The codemod couldn't fix this helper. To await cookies() in it, which keyword does the declaration on line 3 need?
import { cookies } from "next/headers";
export function getCurrency() {
return cookies().get("currency")?.value ?? "USD";
}
import { cookies } from "next/headers";
export async function getCurrency() {
return (await cookies()).get("currency")?.value ?? "USD";
}
async: export async function getCurrency(), which can then await cookies(). Its caller awaits it too: line 19 of the page, {await getCurrency()}. Then the build passes.
One option in this next.config.mjs stopped next build. What replaced it?
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: { ignoreDuringBuilds: true },
serverRuntimeConfig: { supplierKey: process.env.SUPPLIER_KEY },
experimental: { dynamicIO: true },
};
export default nextConfig;
experimental.dynamicIO stopped it, and cacheComponents replaced it. The eslint and serverRuntimeConfig options only made warnings. Turning on cacheComponents is a change of its own, not a rename.
With Cache Components on, what does next build print for /teas/mint?
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { getCurrency } from "../../currency";
import { teas } from "../../teas";
export function generateStaticParams() {
return teas.map((tea) => ({ id: tea.id }));
}
export async function generateMetadata(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const tea = teas.find((item) => item.id === params.id);
return { title: tea ? tea.name : "Tea shop" };
}
export default async function TeaPage(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const tea = teas.find((item) => item.id === params.id);
if (!tea) notFound();
return (
<main>
<h1>{tea.name}</h1>
<Suspense fallback={<p>{tea.price}</p>}>
<Price amount={tea.price} />
</Suspense>
</main>
);
}
async function Price({ amount }: { amount: number }) {
return (
<p>
{amount} {await getCurrency()}
</p>
);
}
◐ /teas/mint: a static shell with the name and price, made during the build, and the currency streamed in for each request.
Check your understanding
For each question, think about what the new version changed, and who changes the code: a codemod, or you.
Question 1 of 7Next.js goes from 15.5 to 16.0. What does the first number changing tell you?
Choose an answer to see the explanation.
Question 2 of 7What did
next-async-request-apido toexport default function TeaPage({ params }: { params: { id: string } })?Choose an answer to see the explanation.
Question 3 of 7Why did the codemod leave
@next-codemod-errorinapp/currency.tsinstead of fixing it?Choose an answer to see the explanation.
Question 4 of 7The shop still has
middleware.tsafter moving to Next.js 16.4.3. What happens?Choose an answer to see the explanation.
Question 5 of 7Why did
next buildstop for thenext.config.mjswith awebpackfunction?Choose an answer to see the explanation.
Question 6 of 7Does moving to Next.js 16 turn on Cache Components?
Choose an answer to see the explanation.
Question 7 of 7Which order keeps an upgrade safe?
Choose an answer to see the explanation.
Key takeaways
- A major can break code; read its upgrade guide, and the guide of every major you cross. Patches and minors shouldn't, except fixes for a major in Maintenance LTS, which the support policy ships as minors even when they break something.
- Changes are announced on nextjs.org/blog, in the upgrade guides, on GitHub releases, and as warnings in your terminal.
- Run codemods on a clean branch, one at a time, and read each diff. Fix what they mark with
@next-codemod-error. - Since 15,
params,searchParams,cookies(),headers()anddraftMode()return promises; 16 removed the old way. - 16 renamed
middleware.tstoproxy.ts, made Turbopack the default, and removed old options. Cache Components are a separate step. - A build isn't enough: tests, a preview deploy and the production checklist prove the upgrade, yours or an agent's.
Remember the one-liner.
Read what changed, let codemods do what they can, fix the rest by hand, and prove it before you merge.
Coming next: Structuring a large app. It organizes routes, features, data access and UI so a growing app, like the tea shop after its upgrade, stays easy to change.