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) โ โ โ โ
โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
| Change | Why |
|---|---|
| Turbopack replaces Webpack as default | Webpack'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.ts | Middleware 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 APIs | params, 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 default | Partial 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:
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).- 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:
| Preset | Stale | Revalidate | Expire |
|---|---|---|---|
'seconds' | 0s | 1s | 60s |
'minutes' | 5min | 1min | 1hr |
'hours' | 5min | 1hr | 24hr |
'days' | 5min | 1day | 14d |
'weeks' | 5min | 1week | 30d |
'max' | 5min | 30d | 365d |
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
- Remove all
export const revalidate = ...from pages - Replace
fetch(..., { next: { revalidate } })withuse cache+cacheLife - Replace
unstable_cache()calls withuse cachefunctions - 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 Size | Recommended Memory | Recommended CPU |
|---|---|---|
| Small (<50 routes) | 512MB | 0.5 vCPU |
| Medium (50-200 routes) | 1GB | 1 vCPU |
| Large (200+ routes) | 2GB | 2 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
-
nextupgraded to^16.0.0 -
reactandreact-domupgraded to^19.2.0 - All
@next/*packages updated to matching versions - Container memory limits reviewed (increase if < 512MB)
Bundler
- Removed all
webpackconfig fromnext.config.ts - Migrated custom loaders to
turbopack.rules - Verified
tsconfig.jsonpaths work with Turbopack - Ran
npx next buildwithout errors
Routing
- Migrated
middleware.tsโproxy.ts(network concerns only) - Moved auth logic from middleware to layouts/route handlers
- Reviewed
proxy.tsmatcher patterns
Data Fetching
- All
paramsandsearchParamsare nowPromise<T>withawait - All
cookies()andheaders()calls areawaited - Converted
export const revalidateโuse cache+cacheLife - Enabled
cacheComponents: trueinnext.config.ts - Replaced
unstable_cacheโuse cachefunctions - 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 Size | Codemod Coverage | Manual Work | Total Time |
|---|---|---|---|
| Small (<50 routes) | ~85% | 1-2 days | 3-4 days |
| Medium (50-200 routes) | ~75% | 3-5 days | 1-2 weeks |
| Large (200+ routes) | ~60% | 1-2 weeks | 3-4 weeks |
The biggest time sinks are:
- Custom Webpack loaders โ finding Turbopack equivalents
- Complex middleware logic โ decomposing into proxy + layout auth
- Caching strategy redesign โ mapping old
revalidatepatterns touse 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.
Explore Related Tools
Try these free developer tools from Pockit