Back

Next.js 16 Migration Guide: Turbopack, Proxy, Cache Components, and Every Breaking Change Explained

Your CI pipeline just failed after bumping next to ^16.0.0. The error log is 400 lines long, half your middleware is broken, and there's a new proxy.ts file convention you've never seen before. Welcome to the Next.js 16 upgrade.

Next.js 16 is the most architecturally significant release since the App Router landed in v13. Webpack is no longer the default โ€” Turbopack is now the only bundler out of the box. The middleware.ts convention has been replaced by proxy.ts. The entire caching model has been rebuilt around Cache Components and the use cache directive. And if you're running in containers, there are memory behavior changes that will bite you in production if you don't know about them.

This guide walks through every breaking change, explains why it was made, provides the exact codemod commands, and gives you the manual migration path when codemods aren't enough. Whether you're upgrading a small marketing site or a 200-route enterprise app, this is the only document you need.

What Changed and Why

Before diving into the migration steps, here's the high-level picture of what Next.js 16 changed and the reasoning behind each shift:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    Next.js 16 Architecture                   โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚  Turbopack    โ”‚  โ”‚  proxy.ts    โ”‚  โ”‚  Cache           โ”‚   โ”‚
โ”‚  โ”‚  (Only        โ”‚  โ”‚  (Replaces   โ”‚  โ”‚  Components      โ”‚   โ”‚
โ”‚  โ”‚   Bundler)    โ”‚  โ”‚   middleware) โ”‚  โ”‚  ('use cache')   โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ”‚         โ”‚                  โ”‚                    โ”‚             โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚  10x Faster  โ”‚  โ”‚  Clear       โ”‚  โ”‚  Declarative     โ”‚   โ”‚
โ”‚  โ”‚  HMR \u0026 Build โ”‚  โ”‚  Network     โ”‚  โ”‚  Caching with    โ”‚   โ”‚
โ”‚  โ”‚              โ”‚  โ”‚  Boundaries  โ”‚  โ”‚  Granular TTL    โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ”‚                                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                         โ”‚
โ”‚  โ”‚  Async        โ”‚  โ”‚  PPR         โ”‚                         โ”‚
โ”‚  โ”‚  Request APIs โ”‚  โ”‚  (Default)   โ”‚                         โ”‚
โ”‚  โ”‚  (params,     โ”‚  โ”‚              โ”‚                         โ”‚
โ”‚  โ”‚   cookies,    โ”‚  โ”‚              โ”‚                         โ”‚
โ”‚  โ”‚   headers)    โ”‚  โ”‚              โ”‚                         โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
ChangeWhy
Turbopack replaces Webpack as defaultWebpack's architecture couldn't scale to sub-second HMR beyond ~500 modules. Turbopack's incremental computation engine handles 10K+ module graphs with consistent <200ms updates. Webpack remains available via --webpack flag during migration.
proxy.ts replaces middleware.tsMiddleware conflated request-level logic (auth, redirects) with network proxy behavior (rewriting, header injection). proxy.ts clarifies which code runs at the network layer (now in Node.js runtime by default, not Edge).
Async request APIsparams, searchParams, cookies(), and headers() are now always async. This enables better streaming and eliminates implicit blocking in RSC rendering.
Cache Components (use cache)The old revalidate, unstable_cache, and nested cache() patterns created an unpredictable caching hierarchy. use cache provides a single, declarative, composable primitive.
PPR by defaultPartial Prerendering is now the default rendering strategy, combining static shells with streamed dynamic content. No more choosing between SSR and SSG.

Step 0: Before You Start

1. Baseline Your Metrics

Before touching any code, capture your current performance:

# Performance baseline npx next build 2>&1 | tee build-before.log npx @next/bundle-analyzer # Runtime metrics lighthouse https://your-app.com --output json --output-path baseline.json

2. Check Node.js Compatibility

Next.js 16 requires Node.js 20.x or later. Node 18 is no longer supported.

node -v # Must be >= v20.0.0

3. Run the Upgrade Command

npx @next/codemod@latest upgrade

This handles the bulk of the automated migration. But it won't catch everything โ€” the rest of this guide covers what the codemod misses.

Step 1: Turbopack โ€” Webpack Is No Longer the Default

The most visible change: Turbopack is now the default bundler for both development and production. The webpack key in next.config.ts is deprecated โ€” though if you have incompatible loaders, you can temporarily fall back with next dev --webpack or next build --webpack. This escape hatch exists for migration, but the goal is to eliminate it.

What Breaks

If you have any of these in your next.config.ts, they will fail:

// โŒ These no longer work in Next.js 16 module.exports = { webpack: (config) => { config.resolve.alias['@'] = path.resolve(__dirname, 'src'); config.module.rules.push({ test: /\.svg$/, use: ['@svgr/webpack'], }); return config; }, };

Migration Path

For aliases: Use the built-in paths in tsconfig.json โ€” Turbopack respects them natively:

// tsconfig.json { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }

For SVG imports: Use @svgr/turbopack instead of @svgr/webpack:

npm install @svgr/turbopack
// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { turbopack: { rules: { '*.svg': { loaders: ['@svgr/turbopack'], as: '*.js', }, }, }, }; export default nextConfig;

For other custom loaders: Check if a Turbopack-compatible version exists. Most popular loaders (CSS modules, SASS, image optimization) are built into Turbopack. For the rest, the turbopack.rules config provides the escape hatch.

Verify

# Development npx next dev # If it starts without errors, Turbopack is working # Production build npx next build

If you see Module not found errors during the build that didn't exist before, it's almost certainly a loader compatibility issue. Check the Turbopack compatibility table for your specific loaders.

Step 2: middleware.ts โ†’ proxy.ts

This is the change that causes the most confusion. The old middleware.ts has been split into two conceptual layers:

  1. proxy.ts โ€” Network-level operations (rewriting URLs, injecting headers, geolocation routing). Runs in the Node.js runtime by default (unlike the old middleware which defaulted to Edge).
  2. Server-side logic in route handlers โ€” Authentication checks, session validation, and business logic now belong in your actual route handlers, layouts, or server actions.

What a Typical middleware.ts Looked Like

// โŒ OLD: middleware.ts (Next.js 15) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { // Auth check const token = request.cookies.get('session-token'); if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } // Locale detection const locale = request.headers.get('Accept-Language')?.split(',')[0] || 'en'; const response = NextResponse.next(); response.headers.set('x-locale', locale); // A/B test routing const bucket = Math.random() > 0.5 ? 'a' : 'b'; response.headers.set('x-ab-bucket', bucket); return response; } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };

The New proxy.ts

// โœ… NEW: proxy.ts (Next.js 16) import type { NextRequest } from 'next/server'; export function proxy(request: NextRequest) { const url = request.nextUrl.clone(); // Locale detection (network-level concern) const locale = request.headers.get('Accept-Language')?.split(',')[0] || 'en'; // A/B test routing (network-level concern) const bucket = Math.random() > 0.5 ? 'a' : 'b'; return { headers: { 'x-locale': locale, 'x-ab-bucket': bucket, }, }; } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };

Where Does Auth Go?

Auth checks move to layouts or route handlers where they belong:

// app/dashboard/layout.tsx import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { const session = await getSession(); if (!session) { redirect('/login'); } return <>{children}</>; }

This is actually a better architecture. Auth logic now lives next to the routes it protects, making the security model explicit and auditable rather than hidden in a global middleware file.

Codemod

npx @next/codemod@latest middleware-to-proxy

The codemod handles simple header/rewrite patterns but won't automatically move auth logic. Review the output carefully.

Step 3: Async Request APIs

Every dynamic request API is now strictly async. This is the change with the highest file count impact โ€” expect to modify every page, layout, and route handler that accesses params, search params, cookies, or headers.

Before (Next.js 15)

// โŒ Synchronous access no longer works export default function Page({ params, searchParams, }: { params: { slug: string }; searchParams: { q?: string }; }) { const title = params.slug; const query = searchParams.q; return <div>{title} - {query}</div>; }

After (Next.js 16)

// โœ… All request APIs are async export default async function Page({ params, searchParams, }: { params: Promise<{ slug: string }>; searchParams: Promise<{ q?: string }>; }) { const { slug } = await params; const { q } = await searchParams; return <div>{slug} - {q}</div>; }

cookies() and headers()

// โŒ Before import { cookies, headers } from 'next/headers'; export default function Page() { const cookieStore = cookies(); const headerList = headers(); // ... } // โœ… After import { cookies, headers } from 'next/headers'; export default async function Page() { const cookieStore = await cookies(); const headerList = await headers(); // ... }

Codemod

npx @next/codemod@latest async-request-apis

This codemod has a high success rate (~90%). It adds async to functions, wraps with await, and updates type signatures. Spot-check the results โ€” it occasionally misses edge cases in custom utility functions that pass params through.

Step 4: Cache Components and the use cache Directive

This is the biggest conceptual shift. The old caching model (a mix of revalidate, unstable_cache, fetch cache options, and cache()) has been replaced by a single, composable primitive: the use cache directive.

The Old World (Confusing)

// โŒ Next.js 15: Multiple, overlapping caching mechanisms export const revalidate = 3600; // page-level revalidation async function getData() { const res = await fetch('https://api.example.com/data', { next: { revalidate: 60, tags: ['data'] }, }); return res.json(); } // Plus: unstable_cache, cache(), generateStaticParams...

The New World (Declarative)

First, enable Cache Components in your config:

// next.config.ts const nextConfig: NextConfig = { cacheComponents: true, // Required to use 'use cache' };

Then use the directive:

// โœ… Next.js 16: Single 'use cache' directive import { cacheLife, cacheTag } from 'next/cache'; // Cache at the function level async function getData() { 'use cache'; cacheLife('hours'); cacheTag('data'); const res = await fetch('https://api.example.com/data'); return res.json(); } // Cache at the component level async function ExpensiveWidget() { 'use cache'; cacheLife('days'); cacheTag('widget'); const data = await getData(); return <div>{data.title}</div>; } // Cache at the page level export default async function Page() { 'use cache'; cacheLife('minutes'); return ( <main> <ExpensiveWidget /> <DynamicContent /> </main> ); }

Cache Life Presets

Next.js 16 ships with built-in cache lifetime presets:

PresetStaleRevalidateExpire
'seconds'0s1s60s
'minutes'5min1min1hr
'hours'5min1hr24hr
'days'5min1day14d
'weeks'5min1week30d
'max'5min30d365d

You can also define custom profiles:

// next.config.ts const nextConfig: NextConfig = { cacheLife: { product: { stale: 300, // 5 minutes revalidate: 3600, // 1 hour expire: 86400, // 1 day }, }, };
// Then use it: async function getProduct(id: string) { 'use cache'; cacheLife('product'); cacheTag(`product-${id}`); return db.products.findById(id); }

Revalidation

Tag-based revalidation works the same way, but now it's more powerful because you can tag at any granularity:

// app/api/revalidate/route.ts import { revalidateTag } from 'next/cache'; export async function POST(request: Request) { const { tag } = await request.json(); revalidateTag(tag); return Response.json({ revalidated: true }); }

Migration Strategy

  1. Remove all export const revalidate = ... from pages
  2. Replace fetch(..., { next: { revalidate } }) with use cache + cacheLife
  3. Replace unstable_cache() calls with use cache functions
  4. Add cacheTag() where you need targeted revalidation

Step 5: Partial Prerendering (PPR) by Default

PPR is now the default rendering strategy. This means every page automatically gets a static shell that is served instantly, with dynamic content streamed in via Suspense boundaries.

What This Means in Practice

// This page automatically uses PPR in Next.js 16 export default async function ProductPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; return ( <main> {/* Static shell โ€” served from CDN */} <Header /> <ProductInfo id={id} /> {/* Dynamic content โ€” streamed */} <Suspense fallback={<PriceSkeleton />}> <DynamicPrice id={id} /> </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <UserReviews id={id} /> </Suspense> </main> ); }

The key insight: components that use use cache become the static shell. Components that access cookies, headers, or uncached data become the dynamic holes that are streamed in.

If You Need to Opt Out

// next.config.ts const nextConfig: NextConfig = { experimental: { ppr: false, // Disable PPR globally }, };

Or per-route:

// app/legacy-page/page.tsx export const dynamic = 'force-dynamic'; // This page won't use PPR

Step 6: Memory Optimization for Containers

This is the silent killer. Next.js 16's more aggressive RSC rendering pipeline can consume significantly more memory than v15, especially in containerized environments with strict memory limits.

The Problem

In Kubernetes or Docker deployments with memory limits (e.g., 512MB per pod), you might see OOM kills that didn't happen with Next.js 15. The root cause is Turbopack's in-memory module graph and the RSC rendering engine holding more intermediate state.

The Fix

// next.config.ts const nextConfig: NextConfig = { // Limit Turbopack's memory usage turbopack: { memoryLimit: 256 * 1024 * 1024, // 256MB }, // Enable incremental cache handler for production experimental: { incrementalCacheHandlerPath: './cache-handler.mjs', }, };
// cache-handler.mjs import { CacheHandler } from '@next/cache-handler-redis'; export default class CustomCacheHandler extends CacheHandler { constructor(options) { super({ ...options, redis: { url: process.env.REDIS_URL, }, // Don't hold cache entries in process memory inMemoryCacheEnabled: false, }); } }

Container Resource Recommendations

App SizeRecommended MemoryRecommended CPU
Small (<50 routes)512MB0.5 vCPU
Medium (50-200 routes)1GB1 vCPU
Large (200+ routes)2GB2 vCPU

Monitor with:

# Watch memory usage during build docker stats --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" # Profile Node.js memory during runtime NODE_OPTIONS="--max-old-space-size=1024 --heapsnapshot-near-heap-limit=3" npm start

Step 7: next.config.ts Changes

Several configuration options have been renamed or restructured:

// next.config.ts โ€” Full Next.js 16 configuration import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // โœ… Turbopack config (replaces webpack config) turbopack: { rules: { '*.svg': { loaders: ['@svgr/turbopack'], as: '*.js', }, }, resolveAlias: { // Custom aliases if tsconfig paths aren't enough 'legacy-lib': './src/lib/legacy-adapter', }, }, // โœ… Cache life profiles cacheLife: { product: { stale: 300, revalidate: 3600, expire: 86400 }, blog: { stale: 60, revalidate: 900, expire: 86400 }, }, // โœ… Image optimization (mostly unchanged) images: { remotePatterns: [ { protocol: 'https', hostname: '**.example.com' }, ], }, // โœ… Redirects and rewrites (same API) async redirects() { return [ { source: '/old-path', destination: '/new-path', permanent: true }, ]; }, }; export default nextConfig;

Removed Options

These next.config.ts options no longer exist:

// โŒ All of these are removed in Next.js 16 { webpack: () => {}, // Use turbopack.rules swcMinify: true, // Always on (via Turbopack) experimental: { appDir: true, // Always on since v14 serverActions: true, // Always on since v15 typedRoutes: true, // Always on }, }

The Complete Migration Checklist

Run through this checklist after running the codemods:

Infrastructure

  • Node.js >= 20.x installed
  • next upgraded to ^16.0.0
  • react and react-dom upgraded to ^19.2.0
  • All @next/* packages updated to matching versions
  • Container memory limits reviewed (increase if < 512MB)

Bundler

  • Removed all webpack config from next.config.ts
  • Migrated custom loaders to turbopack.rules
  • Verified tsconfig.json paths work with Turbopack
  • Ran npx next build without errors

Routing

  • Migrated middleware.ts โ†’ proxy.ts (network concerns only)
  • Moved auth logic from middleware to layouts/route handlers
  • Reviewed proxy.ts matcher patterns

Data Fetching

  • All params and searchParams are now Promise<T> with await
  • All cookies() and headers() calls are awaited
  • Converted export const revalidate โ†’ use cache + cacheLife
  • Enabled cacheComponents: true in next.config.ts
  • Replaced unstable_cache โ†’ use cache functions
  • Added cacheTag() for targeted revalidation

Rendering

  • Verified PPR behavior with Suspense boundaries
  • Added skeleton components for dynamic content
  • Tested with dynamic = 'force-dynamic' where PPR is unwanted

Production

  • Benchmarked build time (aim for improvement with Turbopack)
  • Load-tested with production traffic patterns
  • Monitored memory usage in containers for 24 hours
  • Verified cache hit rates in production

Real-World Migration Timeline

Based on production migrations across teams of different sizes:

App SizeCodemod CoverageManual WorkTotal Time
Small (<50 routes)~85%1-2 days3-4 days
Medium (50-200 routes)~75%3-5 days1-2 weeks
Large (200+ routes)~60%1-2 weeks3-4 weeks

The biggest time sinks are:

  1. Custom Webpack loaders โ†’ finding Turbopack equivalents
  2. Complex middleware logic โ†’ decomposing into proxy + layout auth
  3. Caching strategy redesign โ†’ mapping old revalidate patterns to use cache

What to Expect After Migration

After a clean migration, teams typically report:

  • Dev server startup: 60-80% faster (Turbopack vs Webpack)
  • HMR updates: 5-10x faster (consistent <200ms)
  • Production build: 20-40% faster
  • TTFB: 30-50% improvement with PPR (static shell served instantly)
  • Memory usage: Similar or slightly higher (requires tuning)

The performance gains from Turbopack and PPR alone justify the migration effort. The architectural clarity of proxy.ts and use cache is a long-term maintenance win that pays dividends as your app grows.

Next.js 16 is opinionated. It forces you toward patterns that are objectively better but require upfront migration work. The codemods handle the mechanical changes. The architectural shifts โ€” separating proxy from auth, adopting declarative caching, embracing PPR โ€” those require understanding. This guide gave you both. Time to upgrade.

Next.jsNext.js 16migrationTurbopackReactTypeScriptweb developmentfrontendproxycache componentsPPR

Explore Related Tools

Try these free developer tools from Pockit