Next.js 16 is here, and it’s the kind of release where the team finally stabilizes everything they’ve been testing for the past year. Experimental features are graduating to stable, deprecated APIs are getting removed, and the whole framework is getting faster.
If you’re planning to upgrade, here’s what changed and why it matters.
Route parameters are now asynchronous
The biggest change you’ll notice is that route parameters are now async. Every component that used to just grab params or searchParams now needs to await them. This hits pages, layouts, route handlers, and even those specialized image generation functions (opengraph-image, twitter-image, icon, apple-icon).
The pattern looks simple enough at first:
// Next.js 15 - synchronous paramsexport default function Page({ params }) { const { slug } = params}
// Next.js 16 - asynchronous paramsexport default async function Page({ params }) { const { slug } = await params}But it goes deeper than just pages. Layouts need the same treatment. In route handlers, segmentData.params is now a promise. Even generateMetadata functions need you to await both params and searchParams before you can use them.
Image generation functions get it too. When you’re using generateImageMetadata, both params and id are promises:
// Next.js 16 - image generationexport async function generateImageMetadata({ params }) { const { slug } = await params return [{ id: '1' }, { id: '2' }]}
export default async function Image({ params, id }) { const { slug } = await params const imageId = await id // id is now Promise<string>}Why the change? It’s about streaming and concurrent rendering. When params are synchronous, Next.js has to block and wait for everything to resolve before it can start rendering. Making them async lets the framework start streaming your page shell while params are still being figured out in the background.
This matters more when params come from different places. Sure, a slug gets extracted from the URL. But sometimes route parameters need database validation, edge config resolution, or network requests. The async pattern gives Next.js room to optimize all of that without blocking your entire render pipeline.
If you’re using TypeScript, get ready for some type updates. Instead of Params = { slug: string }, you’re writing Params = Promise<{ slug: string }>. That propagates everywhere params are used, which in a big app can be… a lot of files.
For client components that need route parameters, React’s use hook lets you unwrap the promise:
'use client'import { use } from 'react'
export default function ClientPage(props: { params: Promise<{ slug: string }> }) { const params = use(props.params) const slug = params.slug}The automated codemod handles a lot of this, but it’s not perfect. Complex destructuring, params passed through multiple functions, or params used in helper functions might need manual fixes. When the codemod can’t figure something out, it leaves @next-codemod-error comments showing you where to look.
Turbopack becomes the default bundler
Turbopack’s been in the works for years. Vercel rebuilt their bundler from scratch in Rust, and in Next.js 16, it’s finally the default. No more flags, no experimental config. Run next dev or next build, and you’re using Turbopack.
The transition is smooth. Drop the --turbopack flag from your package.json scripts. If you had "dev": "next dev --turbopack", just make it "dev": "next dev". Same for build commands.
The performance difference is real, especially on big apps. Cold starts are faster when you spin up dev. Hot module replacement feels instant. Production builds speed up too, and the gains scale with how many modules you have. If you’re working on something with thousands of modules, you’ll notice.
Config-wise, Turbopack moved out of the experimental section:
// Next.js 15 - experimental configurationconst nextConfig = { experimental: { turbopack: { // options }, },}
// Next.js 16 - stable configurationconst nextConfig = { turbopack: { // options },}There’s a new filesystem caching feature worth knowing about. Enable experimental.turbopackFileSystemCacheForDev or experimental.turbopackFileSystemCacheForBuild, and Turbopack saves compilation artifacts in .next between runs. Restart your dev server or run another build, and it’s way faster. In CI/CD, if you cache the .next directory, your builds speed up significantly.
const nextConfig = { experimental: { turbopackFileSystemCacheForDev: true, turbopackFileSystemCacheForBuild: true, },}Tracing got better too. Run npx next internal trace .next/dev/trace-turbopack and you get detailed traces showing where compilation time goes. Super helpful when you’re debugging slow builds.
If you need Webpack for something specific, there’s an opt-out. Add --webpack to your build command: "build": "next build --webpack". Dev still uses Turbopack, but you’ve got an escape route.
Legacy Webpack patterns might need tweaking. Take Sass imports with the tilde prefix (~). That was a Webpack thing. Turbopack doesn’t support it out of the box, but you can set up a resolve alias:
const nextConfig = { turbopack: { resolveAlias: { '~*': '*', }, },}Same deal if client code accidentally imports Node modules like fs. You can set up fallbacks to avoid build errors. Better to fix the imports, but resolveAlias gets you through the migration.
React Compiler support
With React 19, the React Compiler went stable. Next.js 16 supports it as a production-ready feature.
The compiler automatically memoizes your components to cut down on unnecessary re-renders. Less time wrapping everything in useCallback and useMemo.
Enable it like this:
const nextConfig = { reactCompiler: true,}It’s stable but opt-in. You control when to turn it on. How much it helps depends on your app. Components with gnarly re-render patterns will see the biggest wins.
Improved caching APIs
Next.js caching has always been… complicated. Too much magic, too much guessing about what’s cached and when it expires. Version 16 fixes this by stabilizing caching APIs that put you in control.
These APIs lost their unstable_ prefix and became production-ready. The shift is from automatic, implicit caching to explicit, developer-controlled caching.
cacheLife lets you set exactly how long cached data lives. No more guessing from config files. You declare it in code. User profile data caches for hours, stock prices for seconds. You decide.
cacheTag gives you a tagging system. Fetch some data, tag it. Later, when that data changes, invalidate by tag instead of nuking everything. User updates their profile? Invalidate the user-123 tag, not all user data.
updateTag solves the “read-your-writes” problem. User submits a form, updates their profile, they want to see the change immediately. Without updateTag, they might get stale cache. updateTag expires the tag and fetches fresh data in the same request. User sees their own changes instantly:
'use server'import { updateTag } from 'next/cache'
export async function updateUserProfile(userId: string, profile: Profile) { await db.users.update(userId, profile) // Expire cache and refresh immediately - user sees changes right away updateTag(`user-${userId}`)}The difference between updateTag and revalidateTag? revalidateTag marks data as stale. Users see old data while new data loads in the background. Good for articles or content where staleness is fine. updateTag blocks until fresh data arrives. Immediate visibility.
refresh triggers a client-side router refresh from server actions. Mutate data on the server, tell the client to refresh the affected UI:
'use server'import { refresh } from 'next/cache'
export async function markNotificationAsRead(notificationId: string) { await db.notifications.markAsRead(notificationId) // Refresh the notification count displayed in the header refresh()}Imports are cleaner now:
// Next.js 15 - experimental with aliasingimport { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'
// Next.js 16 - stable, clean importsimport { cacheLife, cacheTag, updateTag, refresh } from 'next/cache'Also, experimental.dynamicIO got renamed to cacheComponents and went stable. Related to Partial Pre-Rendering (PPR):
// Next.js 15const nextConfig = { experimental: { dynamicIO: true, },}
// Next.js 16const nextConfig = { cacheComponents: true,}unstable_cache still exists (still has the unstable prefix) for wrapping async functions with caching. Configure it with tags and revalidation:
import { unstable_cache } from 'next/cache'
const getCachedUser = unstable_cache( async () => getUserById(userId), [userId], { tags: ['user'], revalidate: 3600, // Cache for 1 hour })All this lines up with Next.js’s push toward React Server Components and streaming. When you declare cache behavior explicitly, the framework knows when to fetch, stream, or serve from cache.
Middleware renamed to Proxy
Middleware is now called proxy. Why? “Middleware” means different things in different frameworks. “Proxy” is clearer. It sits between the request and your app, proxying and modifying requests before they hit your routes.
Migration is simple. Rename the file:
mv middleware.ts proxy.tsUpdate your exports:
// Next.js 15export function middleware(request: Request) {}
// Next.js 16export function proxy(request: Request) {}The bigger change: proxy functions only run on Node.js runtime now. Edge runtime support is gone. If you relied on Edge middleware for low-latency request interception, this breaks things.
Why drop Edge? Maintaining two runtimes for the same feature was complex. Edge has constraints Node.js doesn’t. Different APIs, package compatibility issues. Standardizing on Node.js simplifies everything and reduces weird edge cases.
If you were using Edge middleware for geolocation routing, A/B testing, or auth checks near users, you’ve got options. Adapt your proxy logic for Node.js. If you really need Edge proximity for latency, move that logic outside Next.js. Vercel Edge Functions, Cloudflare Workers, whatever your host offers.
One pattern worth considering: use proxy functions purely for routing and rewriting. Keep auth and dynamic logic in server components or route handlers. You get React Server Components’ streaming while keeping security boundaries intact.
There’s a new config option, skipProxyUrlNormalize, that controls URL normalization before it hits your proxy. Matters if you need to preserve specific URL formatting or query param order.
Proxy functions can’t return response bodies anymore. Previously, you could return full responses from middleware, JSON or HTML. Not in Next.js 16. Proxy is strictly for request modification, rewriting, and redirection. Custom responses belong in route handlers or API routes.
Example: auth middleware that returned 401 JSON now redirects to login:
// Old pattern - no longer supportedexport function middleware(request: Request) { if (!isAuthValid(request)) { return NextResponse.json({ message: 'Auth required' }, { status: 401 }) } return NextResponse.next()}
// New pattern - redirect insteadexport function proxy(request: Request) { if (!isAuthValid(request)) { const loginUrl = new URL('/login', request.url) loginUrl.searchParams.set('from', request.nextUrl.pathname) return NextResponse.redirect(loginUrl) } return NextResponse.next()}Better separation of concerns. Request interception stays in proxy. Response generation stays in your routes.
AMP support removed
AMP is gone. No more useAmp hook, no amp page config, no hybrid AMP/HTML pages.
Why? AMP adoption declined as browsers improved and frameworks like Next.js got faster. Modern Next.js already delivers the performance benefits AMP promised. Removing it simplifies the framework.
If you’re using AMP, you’ll need alternatives before upgrading. The good news: Next.js’s built-in optimizations probably already match or beat what AMP gave you.
Image optimization changes
Image handling in Next.js 16 got tighter security and better performance. Changes to how next/image processes and caches images, all based on real-world data and security issues.
Local images with query strings now need explicit config. Before, you could do <Image src="/assets/photo?v=1" /> without setup. That created an attack vector for filesystem enumeration. Now you declare it:
const nextConfig = { images: { localPatterns: [ { pathname: '/assets/**', search: '?v=1', }, ], },}Whitelist-based security instead of permissive. If your image versioning uses query params, declare the patterns upfront.
Default minimum cache TTL jumped from 60 seconds to 4 hours (14400 seconds). Cost optimization. Image revalidation is expensive—bandwidth, processing, CDN costs. Most images don’t change fast enough to justify revalidation every minute. When Next.js processes an external image without explicit Cache-Control headers, it assumes longer cache lifetime.
Need shorter cache? Set it explicitly:
const nextConfig = { images: { minimumCacheTTL: 60, // Restore 60-second default },}Image quality defaults changed to fix srcset bloat. Before: multiple quality variants for responsive images. Now: single quality level ([75]) by default. Smaller srcset attributes, fewer variants to process and cache. For most apps, one quality level works. Need multiple (premium vs. bandwidth-constrained)? Declare them:
const nextConfig = { images: { qualities: [50, 75, 100], },}16px got dropped from the default imageSizes array. Why? Usage analysis showed 16px images are almost all favicons, which don’t go through next/image anyway. Fewer variants for responsive images. Need 16px? Add it back:
const nextConfig = { images: { imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], },}Remote images got security hardening. images.domains is deprecated. Use images.remotePatterns with explicit protocol:
// Deprecatedconst nextConfig = { images: { domains: ['example.com'], },}
// Secureconst nextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'example.com', }, ], },}Prevents accidental HTTP loading. Makes security explicit. Restrict further by pathname and port if needed.
images.maximumRedirects limits HTTP redirects when fetching images. Default changed from unlimited to 3. Prevents DoS where an image URL redirects forever. Disable redirects (maximumRedirects: 0) or increase for edge cases.
For local IP development, images.dangerouslyAllowLocalIP is your escape hatch. Next.js blocks image optimization for local IPs by default. Set to true to allow it. The “dangerously” prefix means use this only in private networks.
Runtime configuration replaced with environment variables
serverRuntimeConfig and publicRuntimeConfig are gone. Use environment variables instead. Simpler, works everywhere.
Migration:
NEXT_PUBLIC_API_URL="/api" # Client-sideDATABASE_URL="postgres://..." # Server-side onlyClient-side vars need NEXT_PUBLIC_ prefix. Server-side vars don’t. That’s it.
ESLint integration changes
next lint command removed. Use ESLint directly.
Codemod for migration:
npx @next/codemod@canary next-lint-to-eslint-cli .Updates your project to use ESLint directly. The eslint config option in next.config.js is gone too.
Migration strategy
Upgrading to Next.js 16 needs care. The breaking changes are big enough that rushing will give you runtime errors or bugs that only show up in production.
Start with the automated codemod:
npx @next/codemod@canary upgrade latestThis handles the mechanical stuff automatically. Updates package.json to Next.js 16 and React 19. Converts synchronous params and searchParams to async, adding await and marking functions async. Renames experimental.dynamicIO to cacheComponents. Removes unstable_ prefix from cache imports.
The codemod isn’t perfect. It runs on static analysis and can’t always figure out the right transformation. When it’s stuck, it leaves @next-codemod-error comments showing what needs manual review.
Common manual fixes: params passed through helper functions, complex destructuring the codemod can’t parse, components exported/imported across files where types aren’t available, params stored in variables then passed to other functions.
After the codemod, test systematically. Route handlers are high priority—segmentData.params affects every route handler with dynamic segments. Page components with dynamic routes or search params need testing. Layout components that access params need checking. Metadata generation functions now await both params and searchParams.
Images need attention. Using query strings on local images? Add localPatterns config. Review external image domains. Migrate from domains to remotePatterns. Check image caching config works with the new 4-hour default TTL.
Middleware migration is more than renaming. Verify logic works on Node.js runtime if you used Edge. Middleware that returned response bodies needs refactoring to redirects/rewrites. Test auth flows carefully—response bodies to redirects changes UX.
Environment variables are critical if you used serverRuntimeConfig or publicRuntimeConfig. Server-side values in env vars without NEXT_PUBLIC_ prefix. Client-side values prefixed with NEXT_PUBLIC_ and accessible in client components.
TypeScript projects have extensive type changes. Update everywhere you referenced params or searchParams. { slug: string } becomes Promise<{ slug: string }> everywhere. Run tsc --noEmit to catch type errors before runtime.
Dev vs. production behavior can differ. Always test production builds locally before deploying. Run npm run build then npm start. Check caching behavior—it differs between dev and production.
Parallel routes got a breaking change that’s easy to miss: empty slots now require default.js. Using parallel routes with potentially empty slots? Create default.js files that return null or call notFound():
// app/@slot/default.tsxexport default function Default() { return null}Scroll behavior changed subtly. Next.js 16 doesn’t override CSS scroll-behavior: smooth during navigation anymore. If you relied on the old behavior (Next.js temporarily setting scroll to auto), add data-scroll-behavior="smooth" to your root HTML:
export default function RootLayout({ children }) { return ( <html lang="en" data-scroll-behavior="smooth"> <body>{children}</body> </html> )}CI/CD pipelines might need tweaking. Caching .next between builds? Directory structure changed. New .next/dev directory enables concurrent dev and build. Update cache config to include it. Using Turbopack filesystem caching in CI? Cache the entire .next directory.
Build adapters hit alpha with an experimental API. Working on custom deployment targets or need to hook into the build process? experimental.adapterPath points to a custom adapter module. Advanced feature for hosting providers and framework authors.
Error monitoring might surface new patterns from async params. Make sure error tracking captures full async stack traces. Some error boundaries might need adjusting if they caught errors from synchronous param access.
For complete details, see the official Next.js 16 announcement.