⬆️ Chapter 25: Upgrading to Next.js 16
Every breaking change in one place, the codemods that automate most of it, and how to migrate from the Pages Router.
📖 Plain English Explanation
Next.js 16 is the largest breaking release since the App Router itself. If you're on 15, this chapter is your checklist. If you're on 14 or earlier, you have two upgrades ahead of you — do 15 first, then 16.
The changes fall into four groups:
- Renames —
middleware→proxy,unstable_cacheLife→cacheLife. Mechanical, codemod-able. - Removed compatibility shims — synchronous
paramsandcookies()finally throw instead of warning. - Removed features — AMP,
next lint,serverRuntimeConfig,next/legacy/image. - Changed defaults — Turbopack on, image caching,
qualities,revalidateTag's signature.
The first two are mostly automated. The fourth is where you'll spend your debugging time, because nothing errors — behaviour just changes.
Budget a day for a small app, a week for a large one, and do it on a branch.
🤖 The fastest path
Next.js 16 ships something genuinely useful for this: a generated AGENTS.md that points your AI assistant at the version-matched docs bundled inside node_modules/next/dist/docs/.
npx @next/codemod@canary agents-md
The generated block is blunt:
This is NOT the Next.js you know. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in
node_modules/next/dist/docs/before writing any code.
If you use Copilot, Cursor, or Claude Code on this project, run it before anything else. It's the difference between an assistant that writes middleware.ts from memory and one that reads the actual docs for your installed version.
🔧 Step 1: the codemods
# Main upgrade — packages + several mechanical migrations
npx @next/codemod@canary upgrade latest
That one handles:
- Updating
next.config.jsto the newturbopackkey - Migrating
next lintto the ESLint CLI - Renaming
middleware→proxy - Removing
unstable_prefixes from stabilized APIs - Removing
experimental_pprfrom route segments
It does not run every codemod. If you still have synchronous request APIs from the Next.js 15 compatibility period:
npx @next/codemod@canary next-async-request-api .
Individual codemods, if you prefer to go one at a time:
npx @next/codemod@canary middleware-to-proxy .
npx @next/codemod@canary next-lint-to-eslint-cli .
Commit after each one. A codemod that touches 200 files is much easier to review as its own commit.
📦 Step 2: dependencies and requirements
npm install next@latest react@latest react-dom@latest
npm install -D @types/react@latest @types/react-dom@latest
New minimums:
| Requirement | Minimum |
|---|---|
| Node.js | 20.9.0 (Node 18 unsupported) |
| TypeScript | 5.1.0 |
| React | 19.2 |
| Chrome / Edge / Firefox | 111+ |
| Safari | 16.4+ |
node --version # if this says 18.x, upgrade before anything else
📋 The complete breaking-change checklist
① Turbopack is the default
// package.json — before
{ "scripts": { "dev": "next dev --turbopack", "build": "next build --turbopack" } }
// after
{ "scripts": { "dev": "next dev", "build": "next build" } }
// next.config.ts — the config key moved out of experimental
// ❌ experimental: { turbopack: { … } }
// ✅ turbopack: { … }
If you have a custom webpack config, next build now fails deliberately rather than silently ignoring it. Three options:
next build --turbopack # ignore the webpack config
next build --webpack # opt out of Turbopack
...or migrate the config to turbopack options. If you see this error without having written a webpack config yourself, a plugin is adding one.
Turbopack also caches to disk between runs (turbopackFileSystemCache, on by default), and next dev/next build now use separate output directories so they can run concurrently.
② Async Request APIs — no more compatibility shim
// ❌ throws in Next.js 16
export default function Page({ params }: { params: { slug: string } }) {
return <h1>{params.slug}</h1>
}
const theme = cookies().get('theme')
const ua = headers().get('user-agent')
// ✅
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
return <h1>{slug}</h1>
}
const theme = (await cookies()).get('theme')
const ua = (await headers()).get('user-agent')
Affects cookies(), headers(), draftMode(), params in every file convention, and searchParams in pages.
Run npx next typegen to get the PageProps<'/route'>, LayoutProps<'/route'>, and RouteContext<'/route'> helpers, which make the migration type-safe.
③ Async params in image and sitemap functions
// ❌
export default function Image({ params, id }) {
const slug = params.slug
const imageId = id
}
// ✅
export default async function Image({ params, id }) {
const { slug } = await params
const imageId = await id
}
// ❌
export default async function sitemap({ id }) {
const start = id * 50000
}
// ✅
export default async function sitemap({ id }) {
const start = Number(await id) * 50000
}
Note the asymmetry: generateImageMetadata and generateSitemaps still receive synchronous params. Only the generating functions changed.
④ middleware → proxy
mv middleware.ts proxy.ts
// ❌ export function middleware(request) {}
// ✅ export function proxy(request) {}
// next.config.ts
// ❌ skipMiddlewareUrlNormalize: true
// ✅ skipProxyUrlNormalize: true
The edge runtime is not supported in proxy — it runs on Node.js and setting runtime throws. If you specifically need edge, keep using middleware for now.
⑤ Caching APIs
// ❌ TypeScript error — second argument now required
revalidateTag('posts')
// ✅
revalidateTag('posts', 'max')
// ❌
import {
unstable_cacheLife as cacheLife,
unstable_cacheTag as cacheTag,
} from 'next/cache'
// ✅ both stable
import { cacheLife, cacheTag } from 'next/cache'
Two new functions worth adopting (Chapter 15):
import { updateTag, refresh } from 'next/cache'
updateTag('user-42') // read-your-writes, Server Actions only
refresh() // re-render the current route on the client
⑥ PPR → Cache Components
// ❌ all removed
experimental: { ppr: true }
experimental: { dynamicIO: true }
experimental: { useCache: true }
// ❌ per-route segment config removed
export const experimental_ppr = true
// ✅
cacheComponents: true
Read this one carefully. It is not a rename — the model changed. Enabling cacheComponents will surface build errors for uncached data outside <Suspense> boundaries.
If you're actively using experimental.ppr on a Next.js 15 canary, the official guidance is to stay on that canary until you can migrate deliberately. If you weren't using it, just delete the flags. Chapter 16 covers the migration.
⑦ next/image defaults
| Setting | Next.js 15 | Next.js 16 |
|---|---|---|
minimumCacheTTL | 60s | 4 hours |
qualities | any | [75] |
imageSizes | includes 16 | 16 removed |
local ?query | allowed | needs localPatterns |
| local IPs | allowed | blocked (SSRF) |
maximumRedirects | unlimited | 3 |
// restore any of these if you need them
images: {
minimumCacheTTL: 60,
qualities: [50, 75, 100],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
localPatterns: [{ pathname: '/assets/**', search: '?v=1' }],
dangerouslyAllowLocalIP: true,
maximumRedirects: 5,
}
Also deprecated: next/legacy/image (use next/image) and images.domains (use images.remotePatterns).
⑧ Parallel routes need default.js
Error: No default component was found for the parallel route slot "@modal"
Every @slot folder now requires a default.tsx:
// app/dashboard/@modal/default.tsx
export default function Default() {
return null
}
Or, if a missing match means an invalid URL:
import { notFound } from 'next/navigation'
export default function Default() {
notFound()
}
⑨ Removed features
AMP — gone entirely:
// ❌ removed
import { useAmp } from 'next/amp'
export const config = { amp: true }
// and the `amp` key in next.config
next lint — removed, and next build no longer lints:
{ "scripts": { "lint": "eslint ." } }
npx @next/codemod@canary next-lint-to-eslint-cli .
The eslint key in next.config is also removed.
Runtime config — removed:
// ❌
serverRuntimeConfig: { dbUrl: process.env.DATABASE_URL }
publicRuntimeConfig: { apiUrl: '/api' }
// ✅ server
const dbUrl = process.env.DATABASE_URL
// ✅ client
const apiUrl = process.env.NEXT_PUBLIC_API_URL
// ✅ runtime read (not build-time)
import { connection } from 'next/server'
await connection()
const config = process.env.RUNTIME_CONFIG
unstable_rootParams — use next/root-params.
devIndicators options — appIsrStatus, buildActivity, buildActivityPosition removed. The indicator itself remains.
⑩ ESLint flat config
@next/eslint-plugin-next now defaults to flat config, aligning with ESLint v10:
// eslint.config.mjs
import next from '@next/eslint-plugin-next'
export default [
{
plugins: { '@next/next': next },
rules: { ...next.configs.recommended.rules },
},
]
⑪ Scroll behaviour
Next.js used to override a global scroll-behavior: smooth during navigation so transitions felt instant. It no longer does. To get the old behaviour:
<html lang="en" data-scroll-behavior="smooth">
⑫ Modern Sass API
sass-loader moved to v16. Turbopack doesn't support the legacy tilde prefix:
/* ❌ */ @import '~bootstrap/dist/css/bootstrap.min.css';
/* ✅ */ @import 'bootstrap/dist/css/bootstrap.min.css';
⑬ Build output metrics
Size and First Load JS were removed from next build output because they were inaccurate for RSC payloads. Use Lighthouse or Vercel Analytics instead (Chapter 22).
⑭ next dev config loading
The config file is now loaded once instead of twice. Consequence: checking process.argv.includes('dev') inside next.config returns false.
// ❌
if (process.argv.includes('dev')) startDevServer()
// ✅
if (process.env.NODE_ENV === 'development') startDevServer()
typegen and build are still visible in process.argv.
📅 A migration plan
Day 1 — mechanical
git checkout -b upgrade/next-16
node --version # ≥ 20.9
npx @next/codemod@canary agents-md
npx @next/codemod@canary upgrade latest # commit
npx @next/codemod@canary next-async-request-api . # commit
npm run build # expect failures
Day 2 — fix the build
Work through the errors. Most will be async params the codemod missed, missing default.tsx files, and revalidateTag signatures. Fix, build, repeat.
Day 3 — verify the changed defaults
Nothing errors here, so you have to look:
□ Images — check quality, cache TTL, srcset in DevTools
□ Scroll — does navigation still feel right?
□ Proxy — does it still match the routes you expect?
□ Caching — NEXT_PRIVATE_DEBUG_CACHE=1 npm run start
□ Env vars — anything that was in serverRuntimeConfig
Day 4 — test
npm run test:run
npx playwright test # against the production build
Click through the app manually. Codemods don't catch behavioural regressions.
Later — Cache Components
Adopt cacheComponents: true as a separate piece of work, on its own branch. Bundling it with the version upgrade means you can't tell which change broke what.
🔄 Migrating from the Pages Router
A different, larger project. The good news is you can do it incrementally — pages/ and app/ coexist, with app/ taking precedence on conflicts.
The mapping
| Pages Router | App Router |
|---|---|
pages/index.js | app/page.tsx |
pages/about.js | app/about/page.tsx |
pages/blog/[slug].js | app/blog/[slug]/page.tsx |
pages/_app.js | app/layout.tsx |
pages/_document.js | app/layout.tsx |
pages/404.js | app/not-found.tsx |
pages/500.js | app/error.tsx |
pages/api/x.js | app/api/x/route.ts |
getServerSideProps | async Server Component |
getStaticProps | async Server Component + use cache |
getStaticPaths | generateStaticParams |
next/router | next/navigation |
<Head> | metadata / generateMetadata |
Data fetching, before and after
// ❌ pages/blog/[slug].js
export async function getStaticProps({ params }) {
const post = await getPost(params.slug)
if (!post) return { notFound: true }
return { props: { post }, revalidate: 3600 }
}
export async function getStaticPaths() {
const posts = await getAllPosts()
return {
paths: posts.map((p) => ({ params: { slug: p.slug } })),
fallback: 'blocking',
}
}
export default function Post({ post }) {
return <article>{post.body}</article>
}
// ✅ app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { cacheLife, cacheTag } from 'next/cache'
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((p) => ({ slug: p.slug }))
}
// fallback: 'blocking' → dynamicParams = true (the default)
async function getCachedPost(slug: string) {
'use cache'
cacheLife('hours') // revalidate: 3600
cacheTag(`post-${slug}`)
return getPost(slug)
}
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getCachedPost(slug)
if (!post) notFound() // notFound: true
return <article>{post.body}</article>
}
The order that works
- Start with
app/layout.tsx— port_app.jsand_document.js. Providers become a Client Component wrapper (Chapter 10). - Move leaf pages first. A simple
/aboutpage is a good first migration and teaches you the shape. - Convert
getServerSidePropstoawaitin the component. - Convert
getStaticPropstouse cache+cacheLife. - Replace
next/routerwithnext/navigation. Different API, not just a different path. - Replace
<Head>withmetadataexports. - Convert
pages/api/*to Route Handlers — or better, to Server Actions if only your own UI calls them. - Add
'use client'only where the build tells you to. Don't pre-emptively mark files. - Delete
pages/when it's empty.
Ship after each route or small group. A six-month migration branch is how migrations die.
⚠️ Common Pitfalls
1. Upgrading 14 → 16 in one jump
Do 15 first. It has its own breaking changes (async APIs with a shim, fetch no longer cached by default) and stacking them makes debugging much harder.
2. Enabling cacheComponents during the version upgrade
Two large changes at once. When something breaks you won't know which caused it.
3. Assuming the codemods caught everything
They handle the mechanical parts. Behavioural changes — image defaults, scroll behaviour, revalidateTag semantics — need manual verification.
4. Not testing against a production build
Some Next.js 16 errors only appear under next start — notably next-request-in-use-cache on dynamically rendered routes, which can pass next build and fail at runtime.
5. Adding 'use client' to fix migration errors
Tempting, and it makes the error go away. It also drags your whole subtree into the browser bundle. Find the actual reason first.
6. Migrating the Pages Router route-by-route without a plan
Get the root layout right first. Everything else nests inside it.
7. Forgetting default.tsx for parallel routes
The most common Next.js 16 build failure.
8. Leaving the old runtime config in place
serverRuntimeConfig doesn't error — it's just gone, and getConfig() returns nothing. Silent undefined values in production.
🎯 When & Why to Use
Upgrade to Next.js 16 when:
✅ You're on 15 and want Cache Components, PPR, or React Compiler
✅ You want Turbopack builds (much faster)
✅ You're starting anything new
✅ You need security fixes (15 gets fewer over time)
Wait when:
⚠️ You're actively using experimental.ppr on a 15 canary
→ the model changed; plan it as its own project
⚠️ You depend on the edge runtime for middleware
→ proxy is Node-only; keep middleware for now
⚠️ A critical dependency doesn't support React 19.2
Migrate Pages → App when:
✅ You want Server Components, streaming, or Server Actions
✅ The Pages Router's lack of new features is costing you
❌ Not as a big-bang rewrite — incrementally, shipping as you go
🏋️ Mini Practice Problems
Problem 1: Migrate the file
// app/products/[id]/page.tsx
import { unstable_cacheTag as cacheTag } from 'next/cache'
export const experimental_ppr = true
export default function Page({
params,
searchParams,
}: {
params: { id: string }
searchParams: { variant?: string }
}) {
const cookieStore = cookies()
const currency = cookieStore.get('currency')?.value
const product = use(getProduct(params.id))
return <ProductView product={product} variant={searchParams.variant} />
}
Six changes. Name each and write the Next.js 16 version.
Problem 2: Fix the config
// next.config.js
module.exports = {
experimental: {
ppr: true,
turbopack: { resolveAlias: { '@ui': './ui' } },
useCache: true,
},
images: { domains: ['cdn.example.com'], minimumCacheTTL: 60 },
eslint: { ignoreDuringBuilds: true },
skipMiddlewareUrlNormalize: true,
serverRuntimeConfig: { secret: process.env.SECRET },
amp: { canonicalBase: 'https://acme.com' },
}
Problem 3: Silent breakage
The upgrade succeeds, tests pass, and you deploy. Which of these could be silently broken?
- A. Image quality on the product gallery
- B. A
revalidateTagcall in a Route Handler - C. Smooth scrolling on anchor links
- D. A feature flag read from
serverRuntimeConfig - E. A parallel route modal
- F. A Sass import using
~bootstrap
For each: does it error, or fail silently? How would you catch it?
Problem 4: Plan the migration
A Pages Router app with 40 pages, 15 API routes, getServerSideProps on 12 pages, and a styled-components design system. Write the migration plan: order, milestones, what you'd defer, and the biggest risk.
💼 Interview Notes
Common Questions
Q: What are the biggest breaking changes in Next.js 16?
Turbopack as the default bundler, synchronous request APIs fully removed, middleware renamed to proxy with no edge runtime, experimental.ppr/dynamicIO/useCache replaced by cacheComponents, revalidateTag requiring a cacheLife profile, changed next/image defaults, mandatory default.js for parallel routes, and the removal of AMP, next lint, and runtime config.
Q: How do you approach the upgrade? On a branch: bump Node to 20.9+, run the upgrade codemod and the async-request-api codemod as separate commits, fix the build errors, then manually verify the changed defaults — those don't error. Test against a production build, and adopt Cache Components as a separate piece of work.
Q: Why is cacheComponents not just a rename of experimental.ppr?
The underlying model changed. Cache Components requires every uncached data access to be inside a <Suspense> boundary and enforces it at build time. Apps using the Next.js 15 canary PPR are advised to stay there until they can migrate deliberately.
Q: What's the migration path from getServerSideProps?
Delete it and await the data directly in the Server Component. For getStaticProps, await it inside a use cache scope with a cacheLife matching the old revalidate. getStaticPaths becomes generateStaticParams, and fallback: 'blocking' becomes the default dynamicParams: true.
Q: Can you migrate from Pages to App incrementally?
Yes — both routers coexist and app/ wins on conflicting paths. Port the root layout first, then move routes in small batches, shipping as you go.
Q: What breaks silently rather than erroring?
Image quality and cache TTL, scroll behaviour, serverRuntimeConfig reads (now undefined), Sass tilde imports under Turbopack, and revalidateTag's staleness semantics. All need manual verification.
Q: Why was middleware renamed to proxy? "Middleware" implied Express-style request processing and encouraged putting business logic there. "Proxy" describes what it is — a network boundary in front of the app. The rename also signals that the team wants it used less.
🏢 Asked at Companies
- Vercel: "Plan the upgrade of a 200-route Next.js 15 app to 16. What ships first?"
- Shopify: "Which Next.js 16 changes would break in production without failing the build?"
- Airbnb: "How would you migrate a large Pages Router app without freezing feature work?"
- Stripe: "Explain the async Request APIs change. Why did they do it?"
📊 Visual Memory Aid
NEXT.JS 16 AT A GLANCE
RENAMED
middleware.ts → proxy.ts
middleware() → proxy()
skipMiddlewareUrl… → skipProxyUrl…
unstable_cacheLife → cacheLife
unstable_cacheTag → cacheTag
unstable_rootParams → next/root-params
NOW ASYNC (throws if sync)
params · searchParams · cookies() · headers() · draftMode()
opengraph-image params + id · sitemap id
REPLACED
experimental.ppr ┐
experimental.dynamicIO ├─► cacheComponents: true
experimental.useCache ┘
experimental_ppr (segment config) → removed
SIGNATURE CHANGED
revalidateTag(tag) → revalidateTag(tag, profile)
REMOVED
AMP · next lint · serverRuntimeConfig · publicRuntimeConfig
next/legacy/image · images.domains · devIndicators options
Size / First Load JS from build output
DEFAULTS CHANGED (⚠️ no error — verify manually)
Turbopack on · minimumCacheTTL 60s→4h · qualities → [75]
imageSizes drops 16 · maximumRedirects → 3
local IPs blocked · scroll-behavior no longer overridden
MIGRATION ORDER
1. node ≥ 20.9
2. npx @next/codemod@canary agents-md
3. npx @next/codemod@canary upgrade latest ← commit
4. npx @next/codemod@canary next-async-request-api . ← commit
5. fix build errors
6. verify changed defaults manually
7. test against next build && next start
8. (later, separately) cacheComponents: true
🎯 Key Takeaways
- Run the codemods first —
upgrade latestandnext-async-request-apihandle most of the mechanical work. Commit each separately so the diffs stay reviewable. - The changed defaults are the dangerous part. Image quality, cache TTL, scroll behaviour, and
serverRuntimeConfigall change behaviour without erroring. cacheComponentsis a separate project. It's not a rename ofexperimental.ppr, and bundling it with the version upgrade makes both harder to debug.- Test against
next build && next start. Some Next.js 16 errors — notably request APIs insideuse cache— only surface at runtime on dynamic routes. - Pages → App is incremental. Both routers coexist; port the root layout, then move routes in batches and ship continuously. Never a six-month branch.
Next Chapter: Capstone Project →
Practice: Take a Next.js 15 app, branch it, and run the full upgrade. Keep a log of every error and how you fixed it — then check that log against the checklist above and note which items you'd have missed without it.