DEEP DIVE DEVELOPER WORKFLOWS · UPDATED · 17 MIN READ

Optimizing Next.js 16 App Router Performance with Cache Components

Caching became opt-in in Next.js 16. A practical guide to diagnosing the slowdown, choosing between revalidateTag, updateTag and refresh, and putting caching back where it belongs.

Optimizing Next.js 16 App Router Performance with Cache Components

Executive Summary: Caching Went Opt-In

Next.js 16 shipped on 21 October 2025, and the most consequential thing in it is not a feature. It is a reversal of a default.

In Next.js’s own words, describing Cache Components:

“Unlike the implicit caching found in previous versions of the App Router, caching with Cache Components is entirely opt-in. All dynamic code in any page, layout, or API route is executed at request time by default.”

Read that as an operational statement rather than a design note. Under the App Router as it existed through Next.js 14 and 15, the framework cached aggressively on your behalf and you opted out when you needed fresh data. From Next.js 16, everything runs at request time and you opt in when you want caching.

The Consequence Nobody Leads With

An application upgraded to Next.js 16 without changing a line of code will be slower, not faster. Every page, layout and route handler that was previously served from an implicit cache now executes on every request. Your time to first byte rises, your database load rises, and your compute bill rises with them. This is expected behaviour, not a regression, and the fix is deliberate rather than automatic.

This article is not a tour of the release notes. It is a guide to putting caching back on purpose: where to measure first, which of the three new cache APIs to reach for, and what changed underneath you in routing, images and middleware while you were reading about Turbopack.


What Changed: Implicit Cache to Cache Components

The old model

Through Next.js 14 and 15, the App Router made caching decisions for you across several layers, and the experimental flags for finer control accumulated over time: experimental.ppr for Partial Prerendering, experimental.dynamicIO for the newer data model.

The result was powerful and difficult to reason about. A common experience was a page that would not update in production and an afternoon spent working out which of four caches was responsible.

The new model

Cache Components centre on the "use cache" directive, which can be applied to pages, components and functions. The compiler automatically generates cache keys wherever it is used.

Enable it in configuration:

// next.config.ts
const nextConfig = {
  cacheComponents: true,
};

export default nextConfig;

The design intent is stated plainly in the release: the out-of-the-box experience is now “better aligned with what developers expect from a full-stack application framework.” Code runs when it is called. Caching happens where you asked for it.

Where PPR went

If you have been following Partial Prerendering since its 2023 introduction, this is the update you need.

The experimental.ppr flag has been removed, along with the route-level export const experimental_ppr. experimental.dynamicIO has been renamed to cacheComponents.

PPR itself did not go away. Cache Components “complete the story of Partial Prerendering” - the ability to serve a fast static shell while streaming dynamic portions through Suspense is now part of the Cache Components model rather than a separate opt-in feature.

If you go looking for the PPR flag in Next.js 16 documentation, you will not find it. That is the reason.

The API translation table

Next.js 14 / 15Next.js 16
Implicit caching by default"use cache" directive, opt-in
experimental.pprRemoved, folded into Cache Components
export const experimental_pprRemoved
experimental.dynamicIORenamed to cacheComponents
revalidateTag(tag)revalidateTag(tag, profile) - second argument now required
No equivalentupdateTag(tag) for read-your-writes
No equivalentrefresh() for uncached data
middleware.tsproxy.ts, Node.js runtime

The Upgrade Trap: Why Your App Gets Slower

The mechanism

There is nothing subtle happening. Work that was previously done once and cached is now done on every request.

A product page that read from a database and was implicitly cached now reads from the database each time it is requested. A layout that fetched navigation data once now fetches it per request. A route handler that returned a cached response now recomputes it.

At low traffic this is invisible. At production traffic it is the difference between a database at five per cent utilisation and one at fifty.

The symptoms

Three signals show up together, and seeing all three after an upgrade is diagnostic:

  1. Time to first byte rises, particularly on pages that were previously fully static.
  2. Database or upstream API load rises, roughly in proportion to request volume rather than to unique content.
  3. Compute cost rises, which on usage-billed platforms shows up as a line on an invoice rather than as an alert.

Diagnosing with the new logs

Next.js 16 extended development request logs to show where time is actually spent, splitting it into two phases:

  • Compile - routing and compilation
  • Render - running your code and React rendering

Build output received the same treatment, with each step timed:

 Next.js 16 (Turbopack)

 Compiled successfully in 615ms
 Finished TypeScript in 1114ms
 Collecting page data in 208ms
 Generating static pages in 239ms
 Finalizing page optimization in 5ms

This split matters for diagnosis. Slow Compile is a build and bundling problem, addressed by Turbopack configuration and filesystem caching. Slow Render is your code executing, which is what caching addresses. Treating one as the other wastes a day.

The right order of operations

The temptation after an upgrade is to add "use cache" broadly until performance returns. Resist it, because you will end up with the implicit-caching problem you just escaped, only now written by hand and harder to audit.

The sequence that works:

  1. Measure first. Establish which routes are actually slow and why, using the Compile and Render split.
  2. Cache the expensive and stable. Data that is costly to produce and changes infrequently is the highest-value target.
  3. Leave the cheap and volatile alone. A fast query on frequently changing data does not need a cache, and caching it introduces staleness for no gain.
  4. Verify with numbers, not with the feeling that it seems quicker.

Putting Caching Back Deliberately

Choosing a granularity

"use cache" works at three levels, and the level you choose determines what your cache key includes.

Function level is the finest and usually the best starting point. Cache the expensive data access rather than the component that renders it:

async function getProductDetails(productId: string) {
  'use cache';
  cacheLife('hours');
  cacheTag(`product-${productId}`);

  const product = await db.products.findUnique({ where: { id: productId } });
  return product;
}

Component level caches rendered output. Useful for expensive components with stable inputs - a rendered markdown body, a complex table, a chart.

Page level caches the whole route. This is closest to the old static behaviour and appropriate for genuinely static pages: marketing pages, documentation, blog posts.

Cache lifetimes

cacheLife and cacheTag are both stable in Next.js 16.

cacheLife accepts built-in profile names including 'max', 'hours' and 'days', or an inline object such as { expire: 3600 }. Next.js recommends 'max' for most cases, because it enables background revalidation for long-lived content.

The practical guidance: match the profile to how stale the data is allowed to be from the user’s point of view, not to how often it technically changes. A product description that changes twice a year and a stock count that changes twice a minute have very different tolerances even though both live in the same table.

Tagging for targeted invalidation

cacheTag is what makes precise invalidation possible. Tag by the entity the cache entry describes, so that a write to that entity can invalidate exactly the entries that depend on it:

cacheTag(`product-${productId}`);
cacheTag(`category-${categoryId}`);

An entry can carry several tags. Invalidating any one of them invalidates the entry, which is what lets a category update clear every product page in that category without enumerating them.


Choosing Between revalidateTag, updateTag and refresh

Next.js 16 gives you three ways to invalidate, and choosing wrongly produces bugs that are hard to attribute. This is the section worth bookmarking.

revalidateTag(tag, profile) - stale-while-revalidate

The signature changed. A cacheLife profile is now required as the second argument:

import { revalidateTag } from 'next/cache';

revalidateTag('blog-posts', 'max');      // recommended for most cases
revalidateTag('news-feed', 'hours');
revalidateTag('analytics', 'days');
revalidateTag('products', { expire: 3600 });

revalidateTag('blog-posts');              // deprecated single-argument form

The profile enables stale-while-revalidate behaviour: when users request tagged content they receive cached data immediately while Next.js revalidates in the background.

Use it for content that tolerates eventual consistency. A blog post updated in a CMS does not need to appear for every reader within the same second.

updateTag(tag) - read-your-writes

New in Next.js 16, and Server Actions only. It expires the cache and immediately reads fresh data within the same request:

'use server';

import { updateTag } from 'next/cache';

export async function updateUserProfile(userId: string, profile: Profile) {
  await db.users.update(userId, profile);

  // User sees their own change immediately
  updateTag(`user-${userId}`);
}

Use it wherever a user performs an action and expects to see the result. Forms, settings pages, anything where “I saved that and it did not change” is an unacceptable experience.

This distinction is the single most common source of confusion between the two APIs, and the rule is simple: if the person who triggered the change is looking at the result, use updateTag.

refresh() - uncached data only

Also new, also Server Actions only, and it does not touch the cache at all:

'use server';

import { refresh } from 'next/cache';

export async function markNotificationAsRead(notificationId: string) {
  await db.notifications.markAsRead(notificationId);

  // Refresh the uncached notification count in the header
  refresh();
}

Use it when an action changes something displayed elsewhere on the page that was never cached - notification counts, live metrics, status indicators. Your cached page shells stay fast while the dynamic values update.

The decision table

SituationAPIWhy
CMS content updated by an editorrevalidateTag(tag, 'max')Readers tolerate brief staleness; background revalidation keeps it fast
User updates their own profileupdateTag(tag)They must see their change immediately
Form submission changing user-visible stateupdateTag(tag)Read-your-writes semantics
Notification count after an actionrefresh()The value was never cached
Product price changed by an adminrevalidateTag(tag, 'hours')Eventual consistency acceptable, freshness bounded
Live dashboard metricDo not cacheCaching volatile data adds staleness for no benefit

The Routing Rewrite You Get for Free

Next.js 16 includes “a complete overhaul of the routing and navigation system”, and unlike the caching changes it requires no code modifications.

Layout deduplication

When prefetching several URLs that share a layout, the layout is downloaded once rather than once per link.

The official example makes the scale clear: a page with fifty product links now downloads the shared layout once instead of fifty times. On a category listing or a search results page, that is a substantial reduction in transfer.

Incremental prefetching

Next.js now prefetches only the parts not already in cache, rather than whole pages. The prefetch cache also:

  • Cancels requests when a link leaves the viewport
  • Prioritises prefetching on hover, or when a link re-enters the viewport
  • Re-prefetches links when their data is invalidated

Each of those addresses a specific waste in the previous implementation. Cancelling on viewport exit alone matters on long scrolling pages where a user passes dozens of links they never intend to click.

The trade-off, stated by Next.js

The release notes are direct about the cost:

“You may see more individual prefetch requests, but with much lower total transfer sizes.”

For nearly all applications this is the right trade. It is worth checking if your infrastructure bills per request rather than per byte, or if you sit behind a CDN or API gateway with per-request pricing - that combination is where more requests at lower total volume could cost more rather than less.


Turbopack and React Compiler: Build-Time Performance

Turbopack is now the default

Turbopack reached stability for development and production and is the default bundler for all applications.

The published figures are 2 to 5 times faster production builds and up to 10 times faster Fast Refresh. These are Vercel’s own numbers and we reproduce them with that attribution.

Adoption before the release was already substantial: more than fifty per cent of development sessions and twenty per cent of production builds on Next.js 15.3 and later were running Turbopack.

If you have a custom webpack configuration, you can opt out:

next dev --webpack
next build --webpack

Filesystem caching

Turbopack now supports filesystem caching in development, storing compiler artefacts on disk between runs:

const nextConfig = {
  experimental: {
    turbopackFileSystemCacheForDev: true,
  },
};

It is in beta, and Vercel reports using it across all internal applications. For a large repository where restarting the dev server is a regular tax, this is worth trying.

React Compiler, and an honest caveat

React Compiler support is stable following the compiler’s 1.0 release. It automatically memoises components, reducing unnecessary re-renders with no code changes:

const nextConfig = {
  reactCompiler: true,
};

It is not enabled by default, and Next.js is direct about why:

“Expect compile times in development and during builds to be higher when enabling this option as the React Compiler relies on Babel.”

That is the trade in one sentence: runtime rendering improvements bought with build-time cost. Whether it is worth taking depends on whether your bottleneck is re-rendering in the browser or waiting for builds in CI. Measure both before deciding, and note that enabling it also requires installing babel-plugin-react-compiler.


Image Defaults Changed, and It Affects Your Bill

Several next/image defaults changed in Next.js 16, and they have direct cost implications that rarely appear in upgrade coverage.

SettingPreviousNext.js 16Effect
images.minimumCacheTTL60 seconds4 hours (14400s)Fewer revalidations for images without cache-control headers
images.qualities[1..100][75]The quality prop is coerced to the closest allowed value
images.imageSizesIncluded 1616 removedSmaller srcset, fewer API variations
images.maximumRedirectsUnlimited3Set to 0 to disable
images.dangerouslyAllowLocalIPAllowedBlocked by defaultSet true only for private networks

The qualities change is the one that catches people. If your code passes quality={90}, that value is now coerced to the closest entry in images.qualities, which by default contains only 75. Your images will render at a different quality than the code says, silently, unless you add the values you want to the configuration.

The minimumCacheTTL increase from sixty seconds to four hours is a straightforward cost reduction for anyone serving images without cache-control headers, and it is the rare breaking change that saves money without requiring action.

There is also a new security restriction: local src values with query strings now require images.localPatterns configuration, introduced to prevent enumeration attacks.


proxy.ts and the Node.js Runtime

middleware.ts has been replaced by proxy.ts, which makes the network boundary explicit and runs on the Node.js runtime.

The migration is mechanical:

// proxy.ts
export default function proxy(request: NextRequest) {
  return NextResponse.redirect(new URL('/home', request.url));
}

Rename the file, rename the exported function to proxy, and leave the logic alone.

The runtime detail deserves emphasis because a great deal of existing writing assumes middleware means edge. proxy.ts runs on Node.js - a single, predictable runtime for request interception. middleware.ts remains available for edge runtime use cases but is deprecated and will be removed.


Migration Checklist

In the order that minimises rework:

  1. Upgrade the platform. Node.js 20.9 or later, TypeScript 5.1 or later. Node.js 18 is no longer supported. Browser targets are Chrome, Edge and Firefox 111+, Safari 16.4+.
  2. Run the codemod: npx @next/codemod@canary upgrade latest.
  3. Make async access explicit. await params, await searchParams, await cookies(), await headers(), await draftMode(). Synchronous access is gone.
  4. Add default.js to every parallel route slot. All slots now require one; builds fail without them. Return null or call notFound() to preserve previous behaviour.
  5. Rename middleware.ts to proxy.ts and rename the exported function.
  6. Replace next lint. The command is removed and next build no longer runs linting. A codemod exists: npx @next/codemod@canary next-lint-to-eslint-cli .
  7. Measure a performance baseline before enabling cacheComponents. You cannot evaluate the caching work without a before.
  8. Enable cacheComponents and add "use cache" according to what you measured.
  9. Update every revalidateTag() call to the two-argument signature, and move read-your-writes cases to updateTag().
  10. Review next/image configuration against the new defaults, particularly qualities.

Two removals worth noting separately: AMP support is gone entirely, and serverRuntimeConfig and publicRuntimeConfig have been removed in favour of environment variables.


Debugging with Next.js DevTools MCP

Next.js 16 introduced DevTools MCP, a Model Context Protocol integration that gives AI agents contextual insight into a running application.

An agent connected through it receives:

  • Next.js knowledge about routing, caching and rendering behaviour
  • Unified logs from browser and server without switching contexts
  • Automatic error access with detailed stack traces, no manual copying
  • Page awareness of the active route

For the caching work described in this article, that combination is genuinely useful. Diagnosing why a page is slower after upgrading requires correlating server render times, cache behaviour and client navigation - exactly the context that is otherwise spread across three tools.

If you are evaluating how AI tooling fits into your workflow more broadly, our guide to choosing an AI coding assistant covers the decision framework.


Frequently Asked Questions

Will upgrading to Next.js 16 make my application slower?

Yes, if you change nothing else. Caching is now opt-in, so code that was previously served from an implicit cache executes on every request. The fix is to add caching deliberately where measurement shows it is needed.

What happened to the experimental.ppr flag?

It has been removed, along with the route-level experimental_ppr export. Partial Prerendering’s capabilities are now part of the Cache Components model, which Next.js describes as completing the PPR story.

When should I use updateTag instead of revalidateTag?

Use updateTag when the user who triggered the change needs to see it immediately, such as after submitting a form. Use revalidateTag with a cacheLife profile when eventual consistency is acceptable, such as CMS content.

Is middleware.ts gone?

It is deprecated and will be removed in a future version. Rename it to proxy.ts and rename the exported function to proxy. Note that proxy.ts runs on the Node.js runtime rather than the edge.

Should I enable React Compiler?

It is stable but not enabled by default, and Next.js warns that compile times will be higher because it relies on Babel. Enable it if your bottleneck is browser re-rendering rather than build speed, and measure both before and after.

Do I need to change code for the routing improvements?

No. Layout deduplication and incremental prefetching require no code modifications and apply automatically.


Where This Fits

Caching becoming opt-in has a direct cost dimension, since more work at request time means more compute. Our Vercel review covers Active CPU pricing, which changes how that compute is billed, and our Netlify review covers the 2026 credit model on the alternative platform. If you generate App Router code with v0, it is worth confirming which Next.js version its output targets.

Previous Blog How to Build & Deploy an End-to-End AI Agent Pipeline in 2026 Next Blog How to Choose the Right AI Coding Assistant for Your Dev Team