Sharing is caring!
Last month I watched a pricing page serve a stale number for eleven minutes during a flash sale. Nothing was broken in the code I could see. The page was static, the API was fine, and the cache was doing exactly what we told it to do — we just told it the wrong thing in 2024 and never revisited it. That is the App Router caching story in one sentence: powerful defaults, invisible decisions, expensive surprises.
Next.js 16 is the release that finally stops pretending caching is something you configure once and forget. Cache Components, Turbopack by default, proxy.ts, and async-everything are not cosmetic changes. They are a new contract with the framework. This is what I changed on real projects, in the order I would do it again.
Next.js 16 in one page
If you only remember four things, remember these:
- Turbopack is the default bundler for
next devandnext build. A custom webpack config now fails the build until you opt out with--webpack. - Cache Components makes caching opt-in and declarative: you mark what is cached with
'use cache', and everything else stays dynamic until you say otherwise. middleware.tsis deprecated in favour ofproxy.ts, which runs on the Node.js runtime only.- Synchronous access to
params,searchParams,cookies(),headers()anddraftMode()is gone. It isawaitor nothing.
Versions matter here. I wrote this against Next.js 16.4 (the October 2026 release), React 19.3, and Node 20.9+ as the new floor. If you are still on the 15 canary with PPR enabled, the upgrade guide is explicit: stay where you are until you have a migration plan, because PPR in 16 behaves differently.
Turbopack is not a flag anymore
The first surprise is the least dramatic. Your package.json probably still passes --turbopack like it is 2025:
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start"
}
}Remove the flags. Both commands use Turbopack by default now, and 16.4 tunes it further: the disk cache is roughly 20–25% smaller thanks to Zstandard compression, server-side HMR only recompiles what the current request needs, and production CSS Module class names are shorter. Builds that used to take minutes on a mid-size app now finish in the time it takes to refill a coffee.
The catch is webpack plugins. If a config injects a webpack key, the build fails on purpose instead of silently ignoring it. You have three honest options: run next build --turbopack and accept that the config is ignored, migrate the config to Turbopack equivalents, or keep webpack with next build --webpack. I would not keep webpack in 2026 unless a plugin is genuinely irreplaceable. In my case one image-optimisation plugin was the blocker; replacing it took an afternoon and removed two build warnings.
Cache Components: the model I wish we had in 2024
Cache Components is the umbrella for what used to be three half-overlapping features: Dynamic IO, use cache, and Partial Prerendering. You enable it with two config flags:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfigTwo things happen immediately. First, experimental.ppr, experimental.dynamicIO and route-level experimental_ppr are gone — remove them. Second, uncached data access outside a <Suspense> boundary becomes a build error. That sounds hostile until you remember the alternative: a production page that quietly falls back to dynamic rendering and nobody notices until the load test.
Inside a cached function, the directive is familiar but the tooling is finally stable. cacheLife and cacheTag lost their unstable_ prefixes, and you can compose them per function:
import { cacheLife, cacheTag } from 'next/cache'
export async function getProduct(slug: string) {
'use cache'
cacheLife('hours')
cacheTag(`product-${slug}`)
const res = await fetch(`${process.env.API_URL}/products/${slug}`)
if (!res.ok) throw new Error(`Product fetch failed: ${res.status}`)
return res.json()
}Read that function as a sentence: cache this for hours, tag it so I can invalidate it precisely, and let the rest of the route stay dynamic. The page that consumes it needs a Suspense boundary, because the shell is rendered statically while the product data streams:
import { Suspense } from 'react'
import { getProduct } from '@/lib/products'
export default async function Page(props: PageProps<'/products/[slug]'>) {
const { slug } = await props.params
return (
<Suspense fallback={<ProductSkeleton />}>
<Product slug={slug} />
</Suspense>
)
}The part I like is the honesty. A cached component cannot read cookies or headers, because those are request-specific. If it needs them, it is not cached — and the compiler tells you instead of letting you ship a shared cache keyed by nothing. That single rule would have saved me the pricing incident.
Invalidation: updateTag is the one you want in actions

Next.js 16 changes the invalidation API in a way that matters for correctness, not just style. revalidateTag now requires a cache profile as its second argument, and it keeps stale-while-revalidate semantics:
import { revalidateTag } from 'next/cache'
// Mark as stale; readers may still see the previous value while it revalidates
revalidateTag('product-overview', 'max')That is correct for a blog index or a catalogue where a few seconds of staleness is invisible. It is wrong for a form submission where the user expects to see their own change immediately. For that case, use updateTag inside a Server Action:
'use server'
import { updateTag } from 'next/cache'
export async function savePrice(productId: string, price: number) {
await db.products.update({ where: { id: productId }, data: { price } })
// Expire and refresh within the same request: read-your-writes
updateTag(`product-${productId}`)
}My rule of thumb: updateTag in every mutation that returns the mutated data to the same user, revalidateTag(tag, profile) for everything else. Mixing them up is how you get the classic “I saved it, but the page still shows the old value” bug report.
proxy.ts replaced middleware.ts, and it is Node-only
The rename is more than cosmetics. middleware.ts was tied to the edge runtime story; proxy.ts is explicitly a network-boundary and routing hook, and it runs on the Node.js runtime. You cannot configure it back to edge. If your middleware depends on edge-only APIs or on running close to the user, keep middleware.ts for now — the upgrade guide says edge support will be addressed in a follow-up minor.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const country = request.headers.get('x-vercel-ip-country') ?? 'US'
const response = NextResponse.next()
response.headers.set('x-user-country', country)
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Rename the function too, not just the file. The config flags follow: skipMiddlewareUrlNormalize is now skipProxyUrlNormalize. The official codemod handles the mechanical part, but it will not tell you that a cookie-dependent redirect now executes on every request in Node instead of at the edge. Measure that before you ship.
Async request APIs: the compatibility window closed
Next.js 15 let you keep synchronous params access with a warning. That window is closed. This applies to pages, layouts, route handlers, default.js, metadata image routes, and sitemap ids:
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const query = await props.searchParams
return <h1>{slug} — page {String(query.page ?? 1)}</h1>
}Run npx next typegen once and use the generated PageProps, LayoutProps and RouteContext helpers. They are version-matched to your installed Next.js, which beats hand-writing a params interface that drifts after every upgrade. For client components that genuinely need to unwrap a promise synchronously, React’s use() is the intended escape hatch.

The next/image defaults that quietly changed
Image behaviour is where upgrades bite teams that never read release notes. These are the new defaults in 16, and what to do about them:
| Change | New default | What I do |
|---|---|---|
minimumCacheTTL | 4 hours (was 60s) | Keep it for product images; lower it for user-uploaded avatars |
qualities | [75] only | Add the values you actually request, or quality props get coerced |
imageSizes | 16px removed | Nothing, unless you really serve 16px assets |
| Local images with query strings | Blocked by default | Declare images.localPatterns with the allowed search pattern |
| Local IP optimisation | Blocked | Only enable dangerouslyAllowLocalIP for private networks |
images.domains | Deprecated | Move to remotePatterns |
The TTL change is the one that hurts. Our CDN used to revalidate product shots every minute; after the upgrade they were cached for four hours, which is great for cost and terrible if your merchandising team swaps a hero image at noon. Decide per route, not globally. If you need the old behaviour back, set images.minimumCacheTTL: 60 and accept the CPU cost.
Self-hosting is better, but it is still your job

Vercel makes Cache Components feel free. On your own infrastructure, the cache handler and the tag store are yours. I run output: 'standalone' behind a reverse proxy with two app instances, and the rule that keeps me sane is: every cache tag must have an owner. If a tag can be written by two services, it needs a shared store, not an in-memory map per process.
Two operational notes from the last migration:
- Give the tag store the same backup policy as your database. A cold cache is slow; a corrupted tag store is a correctness bug.
- Log cache hits with the tag name, not just the route. When a page serves stale content, the tag is the first thing you will grep for.
If you are also tightening your styling pipeline, the same “explicit over implicit” philosophy applies to SCSS in 2026. And if your data fetching still waterfalls, read React data fetching in 2026 before turning on Cache Components — caching a bad waterfall just makes it stale and slow.
Who should upgrade now, and who should wait
Upgrade now if you are on 15 already, your app is App Router, and your webpack config is small or nonexistent. The codemod plus one afternoon of Suspense boundaries is a realistic estimate for a mid-size product.
Wait if you are on the 15 canary with PPR, if you depend on edge middleware for geo or auth, or if a critical build plugin still needs webpack. Waiting is not failure; shipping a half-migrated cache layer is.
For the TypeScript side of this stack, TypeScript 7’s native compiler is the other upgrade that changes your daily loop. Pair them: faster builds plus explicit caching is a much better developer experience than either one alone.
FAQ
Do I have to enable Cache Components to upgrade to Next.js 16?
No. It is opt-in and recommended for new apps. Existing apps can upgrade first and adopt it route by route. Note that enabling it is not a rename: uncached data outside Suspense becomes a build error, so plan the migration.
Is proxy.ts just a renamed middleware.ts?
Mostly, with one hard difference: it runs on the Node.js runtime and cannot be configured to edge. If edge execution matters, keep the middleware file until the follow-up release.
Why did my revalidateTag('posts') call start failing?
The second argument (a cache profile, for example 'max') is now required. Use updateTag instead when a Server Action must show the user their own write immediately.
What happened to next lint?
It was removed. Run ESLint or Biome directly, and migrate to flat config if you have not already. next build no longer lints for you.
Images: torkildr (CC BY-SA 2.0), rawpixel (CC0) via Openverse and Flickr.
