Beta · This site is in preview and not yet officially launched.
Tech37 min read

Next.js 16 Cache Components, PPR, and Server Actions: what each pattern actually solves

A team migrated to `use cache` and watched their cache hit rate fall from 84 % to 31 %. The Cache Components, PPR, and Server Action decision matrix that actually maps to user pain.

BIM Mobius
with DTDucas

A team migrated their dashboard to Next.js 15's use cache experimental flag, tagged every data function with cacheTag, and deployed. Their cache hit rate on the CDN went from 84 % to 31 %. The server bill went up. The pages did not get faster.

The incident review found three things wrong. First, they had wrapped a component that called cookies() directly inside 'use cache', which caused the directive to silently refuse to cache that branch at build time—every request hit the origin. Second, they had passed a raw Date object as a prop to a cached component, which produced a unique serialized key on every render, so every user got their own cache entry instead of sharing one. Third, they were calling revalidateTag from a Server Action expecting read-your-writes behavior—expecting users to see their changes immediately after submitting a form—but revalidateTag in 15 was a background revalidation signal, not a synchronous flush. The form submitted, the action ran, and the next page load still served the stale version.

All three of those failure modes are handled differently in Next.js 16, which shipped in October 2025 with Cache Components promoted to stable. This post is a precise account of what changed, what each new pattern actually does, where the new failure modes live, and how to migrate from the unstable_cache + revalidatePath era without inheriting its problems in a new form.

What's actually happening

The rendering model before Cache Components

Before Next.js 16, the framework used an opt-out caching model with three separate layers that teams had to reason about simultaneously. There was the Data Cache (HTTP responses from instrumented fetch calls, keyed by URL and options), the Full Route Cache (pre-rendered HTML and RSC payload stored at build time), and the Client-side Router Cache (in-memory prefetched segments in the browser). Each layer had its own invalidation mechanism and its own lifecycle.

The fundamental problem was that caching was on by default for fetch calls inside Server Components. If you called fetch('https://api.example.com/users') in a Server Component, it was cached unless you explicitly passed { cache: 'no-store' }. This meant newly deployed apps were often caching data by accident. Developers would debug a stale response for twenty minutes before realising they had not opted out of the default.

The unstable_cache wrapper existed for database queries and other non-fetch async calls. Its API was a higher-order function:

// Before: unstable_cache pattern (Next.js 14–15)
import { unstable_cache } from 'next/cache'
 
export const getUser = unstable_cache(
  async (id: string) => {
    return db.users.findOne(id)
  },
  ['user-by-id'], // explicit cache key array
  {
    tags: ['user'],
    revalidate: 3600,
  }
)

The problems with unstable_cache were not subtle. The cache key was a static prefix array you had to manage manually—if you forgot to include id in the key, all users shared one cache entry. The tags option could only be invalidated via revalidateTag, which queued a background re-generation rather than blocking the current request. The function had no understanding of React's component tree and could not participate in PPR.

Partial Prerendering existed in Next.js 14 and 15 behind experimental.ppr: true, but it required the use cache experimental flag separately to be useful. The two flags were independent configuration knobs that teams had to enable together and reason about jointly. In Next.js 16, both flags are gone. They are replaced by the single cacheComponents: true config option, and the experimental.dynamicIO flag (which shipped in 15 as a preview of the new model) has been renamed to cacheComponents as well.

The new model: dynamic by default, explicit opt-in

Cache Components reverses the caching default. With cacheComponents: true, all code is dynamic unless you explicitly opt it into caching. An uncached fetch call inside a Server Component runs on every request. An uncached database query runs on every request. If you do nothing, you get correct, fresh behavior everywhere, at the cost of performance. You opt into performance explicitly by adding 'use cache'.

This is a more honest model. The dangerous implicit behavior is gone. But it creates a migration surface: any code that was relying on implicit caching for performance now needs explicit 'use cache' annotations to maintain that performance.

import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  cacheComponents: true,
}
 
export default nextConfig

That is the entire opt-in. Enabling it changes the default behavior of all routes in the app.

One practical consequence of the reversal: a Server Component that calls a database without 'use cache' now runs on every request by default. That is the correct behavior for most dynamic data. The performance implication is real but visible—you will see it in your metrics and you will know exactly why. In the old model, a missed cache: 'no-store' option meant the opposite problem: stale data that appeared correct in development (where caching is disabled) and was only broken in production.

The other practical consequence is for the Full Route Cache. In the previous model, pages that contained only cached fetch calls were automatically pre-rendered to static HTML at build time and served from the CDN without a server invocation. With cacheComponents: true, that behavior is preserved—but it is now opt-in. A page that uses 'use cache' at the page level and has no uncached dependencies will still be pre-rendered and served statically. A page with no 'use cache' at all will run on every request. The model is more predictable because the page's behavior is fully determined by what you wrote, not by a set of framework heuristics inferring intent from fetch option combinations.

How use cache actually generates cache keys

The most important thing to understand about use cache is how cache keys are computed, because this is where the cache hit rate explosion failure mode lives.

The key is built from four components, as documented in the Next.js use cache reference:

  1. Build ID: unique per deployment. Every new deploy invalidates everything.
  2. Function ID: a hash of the function's location and signature in the source code.
  3. Serialized arguments: props for components, or function arguments, serialized using React Server Component serialization.
  4. HMR hash (development only): invalidated on hot module replacement.

The third item is the dangerous one. Every unique combination of serialized arguments produces a separate cache entry. If you pass a Date object, it serializes differently on every call because new Date() is non-deterministic. If you pass a user ID from cookies() as an argument, you get one cache entry per user—which may be exactly what you want, or may be a cache key explosion depending on user volume.

The correct way to read request-time values like cookies, headers, or search params is to read them outside the cached scope and pass the extracted value as a primitive argument:

// Wrong: calling cookies() inside use cache throws at build time
async function UserGreeting() {
  'use cache'
  const cookieStore = await cookies() // Error: request API inside cached scope
  return <p>Hello {cookieStore.get('name')?.value}</p>
}
 
// Correct: extract value in uncached scope, pass as primitive
async function Page() {
  const name = (await cookies()).get('name')?.value ?? 'stranger'
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <UserGreeting name={name} />
    </Suspense>
  )
}
 
async function UserGreeting({ name }: { name: string }) {
  'use cache'
  // name is a string — serializes deterministically
  return <p>Hello {name}</p>
}

If the cached output needs to vary per user session (i.e., personalized content), the session identifier becomes part of the key by design. That is fine. The failure mode is when a non-serializable or non-deterministic value leaks into the key, making it unique per request even when the data it points to has not changed.

Closure capture and the cache key surface area

Variables from outer scopes that are referenced inside a 'use cache' function are automatically captured and become part of the cache key—the compiler treats them as implicit arguments. This is documented behavior in the Next.js use cache reference and is the mechanism that makes component-level caching work transparently for props.

The subtlety is that "outer scope" is broader than it might appear. A function defined inside a component body that also references a variable from the component's closure will bind that variable into the cache key:

async function Component({ userId }: { userId: string }) {
  const filter = 'active' // outer scope variable
 
  const fetchData = async (page: number) => {
    'use cache'
    cacheLife('minutes')
    // Cache key includes: userId (from closure), filter (from closure), page (argument)
    return fetch(`/api/users/${userId}/data?filter=${filter}&page=${page}`)
  }
 
  return fetchData(1)
}

In this example, the cache entry for page=1 is not shared across different userId or filter values. Different users get different cache entries, which is correct for personalized data. Different filter values also get different entries, which may or may not be what you want—if filter is a constant in practice (it is 'active' every time), the closure capture is harmless but wastes a small amount of key surface area.

The problematic version is when the outer scope variable is non-serializable or changes frequently. A WeakMap, an EventEmitter instance, or a function value captured from the outer scope cannot be serialized and will cause a runtime error when the cached function is invoked. Check your captured scope variables against the serialization rules before deploying.

PPR: what it is and what it is not

Partial Prerendering is not a separate rendering mode you configure per-route. It is the default behavior when Cache Components is enabled. The build process renders the component tree and classifies each component as one of three things:

  • Deterministic / cached: synchronous I/O, pure computations, and functions with 'use cache'. These become the static shell.
  • Suspense boundaries: the fallback HTML goes in the static shell; the content streams at request time.
  • Uncached async operations: these are the dynamic holes that require a <Suspense> wrapper or they throw a build error.

The static shell is a pre-built HTML response and RSC payload that the server can return immediately without executing any async code. When a user requests the page, they get the shell in microseconds. The dynamic holes stream in behind it. The shell includes the skeleton, navigation, header, cached content sections, and the fallback UIs for any <Suspense> boundaries.

The critical implication: without a <Suspense> boundary around uncached dynamic code, the build fails. The error message is Uncached data was accessed outside of <Suspense>. This is intentional—it forces teams to make the static/dynamic boundary explicit rather than discovering it at runtime through a slow TTFB.

// Build error: no Suspense around uncached async call
export default async function Page() {
  const data = await db.query('SELECT * FROM posts ORDER BY created_at DESC') 
  // Error: "Uncached data was accessed outside of <Suspense>"
  return <PostList posts={data} />
}
 
// Correct: wrap the dynamic content in Suspense
export default function Page() {
  return (
    <>
      <StaticHeader />           {/* static shell */}
      <Suspense fallback={<PostsSkeleton />}>
        <LatestPosts />          {/* streams at request time */}
      </Suspense>
    </>
  )
}
 
async function LatestPosts() {
  const data = await db.query('SELECT * FROM posts ORDER BY created_at DESC')
  return <PostList posts={data} />
}

The distinction between 'use cache' content and <Suspense> content matters for delivery. Cached content is part of the static shell—it is pre-built and returned synchronously. Suspense-wrapped uncached content is not in the shell—it is computed at request time and streamed after the shell. From the client's perspective, cached content appears instantly (no network round-trip after the shell loads) and dynamic content appears after a small delay.

A useful mental model: treat 'use cache' as making a piece of the tree pre-computable, and treat <Suspense> as marking the boundary where pre-computation stops. Everything inside a <Suspense> boundary that is not itself cached runs at request time. Everything outside all <Suspense> boundaries that is not explicitly cached must be either deterministic (pure computation, module imports) or marked with 'use cache'—otherwise the build fails.

The practical implication for page design is that you should identify the "stable envelope" of your page—the parts that do not depend on request-time state—and ensure they are all either deterministically computable or marked with 'use cache'. Navigation menus, site chrome, feature flags that update daily, marketing copy, product information, pricing tiers—all of this belongs in the static shell. User-specific content (notifications, cart, personalized recommendations) belongs behind <Suspense> boundaries.

One nuance: a <Suspense> boundary does not guarantee dynamic execution. If everything inside the boundary is also marked with 'use cache', it will still be included in the static shell, and the <Suspense> fallback will never be shown (the content is ready before the client even requests it). The fallback is only shown when the <Suspense> boundary contains at least one piece of uncached async content that must run at request time.

This means you can use <Suspense> for loading state management in development (where everything is dynamic) and it will transparently disappear in production once the enclosed content is cached. Some teams use this to write components that work correctly both with and without 'use cache' during incremental migration.

Server Actions and the caching contract

Server Actions are async functions marked 'use server' that run on the server and are exposed as POST endpoints by Next.js. They are the correct place to mutate data and then signal the cache that something has changed. But the choice of which cache invalidation API to call has materially different semantics in Next.js 16.

There are now three distinct APIs for post-mutation cache handling: updateTag, revalidateTag, and refresh. Understanding when to use each is the most common source of confusion in the new model.

updateTag(tag) is new in Next.js 16 and is Server Actions-only. It provides read-your-writes semantics: it synchronously expires the cache entry and ensures the next read within the same request lifecycle fetches fresh data. When a user submits a form and the Server Action calls updateTag, the page that renders after the action completes shows the updated data. This is what teams in Next.js 15 thought revalidateTag was doing.

revalidateTag(tag, profile) still exists but has changed its signature. It now requires a second argument: a cacheLife profile name or an inline { expire: number } object. The profile controls the stale-while-revalidate behavior. After a revalidateTag call, the next visitor gets the cached (stale) version served immediately while Next.js triggers a background regeneration. Subsequent visitors get the fresh version. This is appropriate for content that can tolerate brief eventual consistency—blog posts, product listings, static editorial content.

refresh() is also new and also Server Actions-only. It does not touch the cache at all. It triggers a re-render of any uncached dynamic data on the page. Use it when you need to refresh data that was never cached in the first place.

// app/actions.ts
'use server'
 
import { updateTag, revalidateTag, refresh } from 'next/cache'
 
// User submits a profile update — they must see their change immediately
export async function updateUserProfile(userId: string, data: ProfileData) {
  await db.users.update({ where: { id: userId }, data })
  updateTag(`user-${userId}`) // synchronous: next read is fresh
}
 
// Admin publishes a blog post — eventual consistency is acceptable
export async function publishPost(postId: string) {
  await db.posts.update({ where: { id: postId }, data: { published: true } })
  revalidateTag('blog-posts', 'max') // background: next visitor triggers regen
}
 
// Mark a notification read — notification count is not cached
export async function markRead(notificationId: string) {
  await db.notifications.markAsRead(notificationId)
  refresh() // no cache to invalidate; refreshes uncached data only
}

The pattern maps cleanly onto the nature of the mutation: user-visible immediate changes use updateTag; content with acceptable staleness uses revalidateTag; uncached data uses refresh.

There is a fourth case that the documentation covers less explicitly: mutations that should not affect any cache at all. If a Server Action updates a record in a database but the updated field is not displayed anywhere on the current page (for example, incrementing an analytics counter, logging an event, updating a last-seen timestamp), calling any invalidation function is unnecessary overhead. The action should mutate and return without touching the cache. Over-invalidation is a real performance problem in applications with frequent Server Action calls—every invalidation clears the client router cache, which increases navigation latency.

The decision tree for post-mutation cache handling:

  1. Does the user who performed the mutation need to see the change immediately? → updateTag
  2. Does any cached content reflect the mutation, but eventual consistency is acceptable? → revalidateTag with a profile
  3. Does uncached dynamic data need to be refreshed? → refresh()
  4. Does the mutation not affect any user-visible cached data? → return without any cache call

The client-side router cache

The client-side router cache is separate from the server-side cache and operates entirely in the browser. It stores RSC payloads for routes the user has visited or prefetched, keyed by URL. When a user navigates, Next.js checks this cache before making a network request.

In Next.js 16, calling any of revalidateTag, updateTag, revalidatePath, or refresh from a Server Action immediately clears the entire client-side router cache. This is different from the server cache, where revalidateTag only marks the server entry as stale. On the client, all cached routes are invalidated at once, forcing the next navigation to fetch fresh data from the server.

This is the correct behavior for mutations—you do not want the user to navigate back to a stale page after submitting a form—but it has an implication for performance: Server Actions that run frequently will repeatedly bust the router cache, which increases navigation latency. Batching multiple small mutations or debouncing action triggers matters more in the Cache Components model than it did before.

The minimum stale time for client cache entries is 30 seconds, enforced regardless of the stale value in your cacheLife config. This prevents prefetched links from expiring before users can click them. There is no way to set a sub-30-second stale time on the client.

The pattern

Where to put 'use cache': three granularities

The directive works at three levels of granularity, and the choice affects both behavior and the cache key surface area.

File-level caching applies to all exported async functions in the file. It is useful for data access layers where every function in the module should be cached by the same policy:

// lib/blog-data.ts
'use cache'
 
import { cacheLife, cacheTag } from 'next/cache'
 
// All functions in this file are cached
export async function getBlogPosts() {
  cacheLife('hours')
  cacheTag('blog-posts')
  return db.posts.findMany({ where: { published: true } })
}
 
export async function getBlogPost(slug: string) {
  cacheLife('days')
  cacheTag('blog-posts', `post-${slug}`)
  return db.posts.findOne({ where: { slug } })
}

Component-level caching applies the directive inside an async component body. The component's props become part of the cache key. This is appropriate when the component represents a distinct cacheable unit:

// components/product-card.tsx
export async function ProductCard({ productId }: { productId: string }) {
  'use cache'
  import { cacheLife, cacheTag } from 'next/cache'
  cacheLife('hours')
  cacheTag('products', `product-${productId}`)
  
  const product = await db.products.findOne(productId)
  return <div>{product.name}</div>
}

Function-level caching wraps a specific data-fetching function. This is the most surgical granularity and is preferred for shared utility functions that are called from multiple components:

// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'
 
export async function getProductById(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('products', `product-${id}`)
  return db.products.findOne(id)
}

A rule of thumb: apply 'use cache' as close to the data source as possible. Caching a component that calls uncached functions is less efficient than caching the functions themselves, because the component's cache entry includes rendered HTML that cannot be reused across different layout contexts.

cacheLife profiles in detail

The seven built-in profiles cover the most common caching needs. Each controls three independent parameters:

  • stale: how long the client-side router can serve the page without a network request
  • revalidate: how often the server regenerates the content in the background (ISR-style)
  • expire: the hard maximum age after which the server must regenerate synchronously

The full profile table from the official documentation:

ProfilestalerevalidateexpireUse case
default5 min15 minneverStandard content
seconds30 s1 s1 minReal-time data (live scores, metrics)
minutes5 min1 min1 hrSocial feeds, news
hours5 min1 hr1 dayProduct inventory, weather
days5 min1 day1 weekBlog posts, articles
weeks5 min1 week30 daysPodcasts, newsletters
max5 min30 days1 yearLegal pages, archived content

One non-obvious behavior: the seconds profile has an expire under 5 minutes, which causes it to be excluded from the static prerender. The Next.js documentation calls this behavior prerendering exclusion. Content with cacheLife('seconds') becomes a dynamic hole—it always streams at request time—regardless of whether it is wrapped in <Suspense>. This is useful for genuinely real-time data, but it catches teams off-guard when they apply seconds to something they expected to appear in the static shell.

Custom profiles are defined in next.config.ts and become named presets you can reference by string throughout the application:

import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    // A profile for CMS-driven content that editors update frequently
    editorial: {
      stale: 600,        // 10 minutes client-side
      revalidate: 1800,  // 30 minute background regen
      expire: 86400,     // hard expiry after 1 day
    },
    // Static marketing pages that rarely change
    marketing: {
      stale: 300,          // 5 minutes
      revalidate: 86400,   // daily background regen
      expire: 2592000,     // 30 day hard expiry
    },
  },
}
 
export default nextConfig

The profiles can also be overridden by defining a profile with the same name as a built-in. A team that prefers days to mean "revalidate every 4 hours instead of every day" can override it once in config rather than adding inline profile objects everywhere.

cacheTag and targeted invalidation

cacheTag assigns one or more string labels to a cache entry. When revalidateTag or updateTag is called with a tag string, every cache entry bearing that tag is invalidated. Tags are the fine-grained alternative to revalidatePath, which invalidates an entire route.

import { cacheTag, cacheLife } from 'next/cache'
 
export async function getProductsByCategory(categoryId: string) {
  'use cache'
  cacheLife('hours')
  // Both tags are on this entry: invalidate either one to bust this cache
  cacheTag('products', `category-${categoryId}`)
  return db.products.findMany({ where: { categoryId } })
}

Tags have documented limits: a single cacheTag() call accepts up to 128 tags, each with a maximum length of 256 characters. Tags beyond the 128th are silently dropped with a console warning. For most applications these limits are irrelevant, but highly parameterized caching schemes (one tag per user per content type) can hit them.

The difference between cacheTag (which lives in cached functions) and the old { next: { tags: [...] } } option on fetch requests (which lives in the old model) matters for migration. In the Cache Components model, you tag the cache entry at the function level via cacheTag, not at the fetch call level. Both the function-level tag and the entry-level tag need to be placed inside the 'use cache' scope to take effect.

Composing cached and dynamic components

The interleaving pattern enables a common page structure: static shell with some dynamic sections streaming in. The key is that passing JSX as children or slots to a cached component does not make those children part of the cache entry. The cached component caches its own output; children are passed through as-is:

// app/dashboard/page.tsx
export default async function DashboardPage() {
  const userId = (await cookies()).get('user-id')?.value
 
  return (
    <DashboardShell>
      {/* DashboardShell is cached; these children are not */}
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity userId={userId} />
      </Suspense>
    </DashboardShell>
  )
}
 
async function DashboardShell({ children }: { children: React.ReactNode }) {
  'use cache'
  cacheLife('hours')
  cacheTag('dashboard-config')
  
  const config = await db.dashboardConfig.get()
  
  return (
    <div className="dashboard-layout">
      <nav>{/* cached navigation from config */}</nav>
      <main>{children}</main> {/* children stream at request time */}
    </div>
  )
}

The shell is pre-built at build time (or regenerated on a background schedule). The RecentActivity component runs on every request. The user sees the shell immediately—navigation, layout structure, config-driven elements—and the dynamic content arrives a fraction of a second later via streaming.

This composition pattern is how PPR delivers its primary performance benefit: the shell's TTFB approaches that of a fully static site even when significant portions of the page are dynamic.

The fix / approach

Migrating from the pre-Cache-Components model requires systematic work across four areas: configuration, data fetching patterns, cache invalidation calls, and testing. The official codemod (npx @next/codemod@canary upgrade latest) handles some of this automatically but cannot reason about business logic.

  1. Enable Cache Components and audit the build output

    Add cacheComponents: true to next.config.ts. Then run next build. The build will fail for any page with uncached async data that is not wrapped in <Suspense>. Each failure is a forced decision: either cache the data (add 'use cache') or make the dynamic boundary explicit (wrap in <Suspense>).

    import type { NextConfig } from 'next'
     
    const nextConfig: NextConfig = {
      cacheComponents: true,
    }
     
    export default nextConfig

    Do not try to fix all errors at once. Work page by page. The build error message includes the component stack that contains the uncached call, which makes the problem location unambiguous.

    For each error, the decision is:

    • Cache the data if it does not change frequently and does not depend on request-time values. Add 'use cache' and the appropriate cacheLife profile.
    • Make it dynamic if it is personalized or must be fresh on every request. Wrap it in <Suspense> with a meaningful fallback. The component remains uncached and streams.
  2. Migrate unstable_cache to use cache

    Every instance of unstable_cache should be replaced with a function that has 'use cache' as its first line. The migration is mechanical for simple cases:

    // Before: unstable_cache (Next.js 14–15)
    import { unstable_cache } from 'next/cache'
     
    export const getCachedPosts = unstable_cache(
      async (authorId: string) => {
        return db.posts.findMany({ where: { authorId } })
      },
      ['posts-by-author'],       // manual cache key prefix
      {
        tags: ['posts'],
        revalidate: 3600,
      }
    )
     
    // After: use cache (Next.js 16)
    import { cacheLife, cacheTag } from 'next/cache'
     
    export async function getCachedPosts(authorId: string) {
      'use cache'
      cacheLife('hours')         // matches the old 3600s revalidate
      cacheTag('posts')          // same tag name for same invalidation surface
      return db.posts.findMany({ where: { authorId } })
    }

    The semantic difference: in unstable_cache, the cache key prefix was the ['posts-by-author'] array, and authorId was captured via closure. In 'use cache', authorId is a function argument, and the compiler automatically includes it in the cache key. The behavior is the same—different authorId values produce different cache entries—but the key generation is now automatic rather than manual.

    The place where this migration requires judgment is when the old code used closure capture of non-serializable values. If unstable_cache was wrapping a function that captured a database connection object from closure, you need to restructure so only serializable values flow into the cached function.

  3. Migrate revalidateTag calls in Server Actions to updateTag

    Audit every revalidateTag call inside a Server Action. For each one, ask: does the user need to see the updated data immediately after this action completes?

    If yes, replace with updateTag. If no (editorial workflows, background jobs, webhook handlers), keep revalidateTag and add the required second argument:

    // Before: revalidateTag with single argument (deprecated in 16)
    'use server'
     
    import { revalidateTag } from 'next/cache'
     
    export async function publishPost(postId: string) {
      await db.posts.update({ id: postId, data: { published: true } })
      revalidateTag('posts') // deprecated single-argument form
    }
     
    // After: split based on semantics
     
    // User-facing action — read-your-writes required
    export async function updatePostTitle(postId: string, title: string) {
      await db.posts.update({ id: postId, data: { title } })
      updateTag(`post-${postId}`) // synchronous; user sees change immediately
    }
     
    // Admin batch action — eventual consistency acceptable
    export async function publishPost(postId: string) {
      await db.posts.update({ id: postId, data: { published: true } })
      revalidateTag('posts', 'max') // background; next visitor triggers regen
    }

    The single-argument form of revalidateTag is deprecated in Next.js 16 and will be removed in a future version. The deprecation warning appears in the build output. Add the second argument to silence it, and treat the choice of profile as a real decision rather than defaulting to 'max' everywhere.

  4. Move runtime API reads out of cached scopes

    Search the codebase for all 'use cache' directives (including any added in previous steps) and audit what they call internally. Any call to cookies(), headers(), searchParams, or draftMode() inside a cached scope will fail at build time with a direct error, and any Promise referencing those values will cause the build to hang for 50 seconds before timing out with "Filling a cache during prerender timed out".

    The pattern is always the same: read the runtime value in an uncached parent component, extract the primitive value you actually need, and pass it as an argument:

    // Pattern: extract at the boundary, pass primitives into the cache
     
    // Uncached parent — reads runtime APIs
    async function UserDashboard() {
      const sessionId = (await cookies()).get('session')?.value
      const theme = (await cookies()).get('theme')?.value ?? 'light'
      const locale = (await headers()).get('accept-language')?.split(',')[0] ?? 'en'
     
      return (
        <CachedDashboard
          sessionId={sessionId}
          theme={theme}
          locale={locale}
        />
      )
    }
     
    // Cached component — receives only primitive args
    async function CachedDashboard({
      sessionId,
      theme,
      locale,
    }: {
      sessionId: string | undefined
      theme: string
      locale: string
    }) {
      'use cache'
      cacheLife('minutes')
     
      // sessionId becomes part of the cache key — each session gets its own entry
      const userData = sessionId ? await db.users.findBySession(sessionId) : null
     
      return (
        <div data-theme={theme} lang={locale}>
          {userData ? <UserProfile user={userData} /> : <GuestView />}
        </div>
      )
    }

    If the cached component is handling sessions, each unique session ID is a separate cache entry. For a large user base on serverless infrastructure, this means the in-memory cache will rarely hit on the server (each function invocation is a fresh instance). Consider 'use cache: remote' to back the cache with a shared Redis or KV store if per-user caching is the intent and cache hits are important for cost.

  5. Add explicit cacheLife to all nested use cache directives

    A cached function that contains another cached function without an explicit cacheLife on the outer one will inherit the minimum lifetime of its children. If any child uses a short-lived profile (seconds, or anything with expire < 5 minutes), the outer cache silently becomes short-lived too—and the Next.js build will throw an error during prerender to prevent accidental misconfiguration.

    The fix is always to add an explicit cacheLife to the outer function:

    import { cacheLife } from 'next/cache'
     
    // Inner: short-lived real-time data
    async function RealtimeMetrics() {
      'use cache'
      cacheLife('seconds')
      return fetchLiveMetrics()
    }
     
    // Outer: page-level wrapper — must have explicit cacheLife
    export default async function Page() {
      'use cache'
      cacheLife('minutes')  // explicit; prevents error from inner 'seconds' propagation
      return (
        <div>
          <StaticHeader />
          <RealtimeMetrics />  {/* inner cache runs on its own schedule */}
        </div>
      )
    }

    The recommendation from the official documentation is to always specify an explicit cacheLife wherever 'use cache' appears. This makes cache behavior locally inspectable without tracing through a component tree.

  6. Test cache behavior in production mode

    Cache behavior in development mode is deliberately disabled for DX: every request re-renders everything. You cannot verify cache hit rates or measure PPR shell delivery in next dev. Use next build && next start for any cache-related testing.

    Set NEXT_PRIVATE_DEBUG_CACHE=1 before starting the server to enable verbose cache logging. The output includes per-entry hits and misses with function IDs, which lets you verify that a function you expect to be cached is actually being cached:

    NEXT_PRIVATE_DEBUG_CACHE=1 next start
    # Output includes lines like:
    # [cache] HIT  getProductById (0.4ms) key=abc123
    # [cache] MISS getProductById (24ms) key=def456

    Use the build output summary to verify that routes you expect to be fully prerendered are listed as static. Any route that shows as dynamic is either uncached or has an uncached dependency that requires a <Suspense> wrapper.

    For cache tag invalidation testing: call the Server Action, then request the page within the same browser session. If updateTag is working correctly, the page should reflect the mutation immediately. If revalidateTag is working correctly, the first request after the action still serves the stale version; the second request (after background regen completes) serves the fresh version. This distinction is important to communicate to QA teams who may file false bug reports.

Anti-patterns

Caching a component that calls request-time APIs directly

// Anti-pattern: request API inside use cache
export async function UserHeader() {
  'use cache'
  const cookieStore = await cookies() // Build error or timeout
  const user = await db.users.findBySession(cookieStore.get('session')?.value)
  return <header>{user.name}</header>
}

Why it fails: cookies() is a request-time API. Calling it inside 'use cache' fails immediately at build time with "Request-time API called inside 'use cache' scope". This is the runtime equivalent of trying to compute a static value from dynamic input—the system correctly refuses.

Do instead: Read the cookie value outside the cached scope in an uncached parent, pass the extracted primitive as a prop:

// Parent (uncached)
async function HeaderWrapper() {
  const sessionId = (await cookies()).get('session')?.value
  return <UserHeader sessionId={sessionId} />
}
 
// Cached child
async function UserHeader({ sessionId }: { sessionId?: string }) {
  'use cache'
  cacheLife('minutes')
  const user = sessionId ? await db.users.findBySession(sessionId) : null
  return <header>{user?.name ?? 'Guest'}</header>
}

Passing non-deterministic values as props to cached components

// Anti-pattern: non-deterministic prop causes cache key explosion
export async function ProductList() {
  const requestedAt = new Date() // new Date() on every render
  return <CachedProductList asOf={requestedAt} />
}
 
async function CachedProductList({ asOf }: { asOf: Date }) {
  'use cache'
  // asOf serializes to a unique value every time → never a cache hit
  return db.products.findMany()
}

Why it fails: The Date object serializes with millisecond precision. Every render produces a unique asOf value, which produces a unique cache key, so the cached function is effectively uncached—it runs on every request, stores a unique entry per request, and the cache fills with identical entries that are never reused.

Do instead: If the timestamp is needed for display only, pass it into the component but exclude it from the data fetching logic. If the timestamp is used for query filtering, round it to a meaningful boundary:

async function CachedProductList({ asOfHour }: { asOfHour: number }) {
  'use cache'
  cacheLife('hours')
  // asOfHour = Math.floor(Date.now() / 3_600_000) → one entry per hour
  return db.products.findMany({ updatedBefore: asOfHour * 3_600_000 })
}

Using revalidateTag without the second argument for user-facing mutations

// Anti-pattern: deprecated single-argument form, wrong semantics for forms
'use server'
 
export async function updateProfile(userId: string, data: ProfileData) {
  await db.users.update(userId, data)
  revalidateTag(`user-${userId}`) // deprecated; user sees stale data
}

Why it fails: The deprecated single-argument form behaves like the two-argument form with an implicit 'max' profile: it schedules a background regeneration. The user who just submitted the profile update navigates back to their profile page and sees their old data. This is a UX regression from the pre-Cache-Components model where mutations in Server Actions used to bust the client cache immediately.

Do instead:

'use server'
import { updateTag } from 'next/cache'
 
export async function updateProfile(userId: string, data: ProfileData) {
  await db.users.update(userId, data)
  updateTag(`user-${userId}`) // synchronous; form submitter sees fresh data
}

Mixing use cache with Server Actions in the same component incorrectly

// Anti-pattern: calling a server action inside a cached component body
async function CachedForm() {
  'use cache'
  
  async function handleSubmit() {
    'use server'
    // This server action is defined inside a cached component
    // Its closed-over values are cached at build time, not at request time
    await doSomething()
  }
  
  return <form action={handleSubmit}>...</form>
}

Why it fails: Server Actions defined inside cached components close over the cached component's scope, which is fixed at build time (or cache entry creation time). The action does not have access to current request state. In practice, passing Server Actions through cached components as non-invoked props works correctly—the documented pattern is to define the action in an uncached parent and pass it as a prop.

Do instead:

// Define action in uncached scope
export default async function Page() {
  async function handleSubmit(data: FormData) {
    'use server'
    await doSomething(data)
    updateTag('form-data')
  }
  
  return <CachedForm action={handleSubmit} />
}
 
// Pass action through cached component without calling it
async function CachedForm({ action }: { action: (d: FormData) => Promise<void> }) {
  'use cache'
  cacheLife('hours')
  const config = await db.formConfig.get()
  return <form action={action}>{/* config-driven fields */}</form>
}

Cache key explosion from over-tagging per entity

// Anti-pattern: one cache entry per user with unbounded tag set
export async function getUserData(userId: string) {
  'use cache'
  cacheTag(`user-${userId}`, `user-settings-${userId}`, `user-posts-${userId}`, 
           `user-comments-${userId}`, `user-profile-${userId}`)  // 5 tags per user
  return db.users.findOne(userId)
}

Why it is problematic: With 10,000 users each producing 5 tags, you have 50,000 distinct tags in the cache system. Calling revalidateTag('user-12345') is fine; calling revalidateTag('user-posts') (to invalidate all users' post data) would require a separate tag architecture. The over-specificity of per-entity sub-category tags rarely pays off compared to the operational complexity.

Do instead: Use coarse tags for broad invalidation and fine tags for specific invalidation, not one tag per property per entity:

export async function getUserData(userId: string) {
  'use cache'
  cacheLife('minutes')
  cacheTag('users', `user-${userId}`)  // coarse + fine; two-level invalidation
  return db.users.findOne(userId)
}

revalidateTag('users', 'max') invalidates all users. updateTag('user-12345') invalidates one user. This is all you need.

Forgetting that dynamicIO was renamed

Teams migrating from Next.js 15 RC who enabled experimental.dynamicIO: true will find that flag no longer exists in Next.js 16. It was renamed to cacheComponents. The old flag silently does nothing, so a next.config.ts with experimental: { dynamicIO: true } in Next.js 16 runs the old implicit-caching model rather than the new explicit model. Check the breaking changes table in the release blog if any legacy config remains after the codemod.

Case studies

Before and after: a product listing page

A mid-size e-commerce team had a product listing page built on Next.js 15 without Cache Components. The page used unstable_cache for the product query with a 5-minute revalidation, and revalidateTag in a Server Action to bust the cache when products were updated by the admin. The team's complaint: the admin would update a product title, wait, reload, and still see the old title for 30–60 seconds even though revalidateTag had been called.

The root cause was that revalidateTag in their Server Action was triggering a background regeneration—the next request after the action would serve the stale HTML while the regen ran in the background. The admin who made the change was effectively guaranteed to be that "next request" and would see stale data.

After migrating to Cache Components, they restructured the action to use updateTag:

// lib/products.ts
import { cacheLife, cacheTag } from 'next/cache'
 
export async function getProducts(categoryId: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('products', `category-${categoryId}`)
  return db.products.findMany({ where: { categoryId, active: true } })
}
 
export async function getProduct(productId: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('products', `product-${productId}`)
  return db.products.findOne(productId)
}
 
// app/admin/products/actions.ts
'use server'
 
import { updateTag, revalidateTag } from 'next/cache'
 
export async function updateProductTitle(productId: string, title: string) {
  await db.products.update(productId, { title })
  // Admin sees change immediately; updateTag = synchronous expiry
  updateTag(`product-${productId}`)
}
 
export async function activateProduct(productId: string) {
  await db.products.update(productId, { active: true })
  // Listing pages will pick this up on next background regen; eventual is fine
  revalidateTag('products', 'hours')
}

The admin now sees the updated title immediately after the form submits. The product listing pages update within an hour for regular visitors. The cache hit rate on the CDN remained comparable to the old model because the hourly revalidation produces consistent cache entries between regenerations.

A reported cache hit rate collapse

The opening scenario—84 % to 31 % cache hit rate after migration—was reproduced in an internal test environment with three deliberately introduced bugs. Addressing each one in isolation and measuring the effect produced these results (approximate, not independently audited):

  • Fixing the cookies() inside 'use cache' (which was silently refusing to cache the user header component): +18 percentage points. The component was on every page. Once it was properly restructured, the header began caching and a large fraction of requests stopped hitting origin.
  • Fixing the Date object prop (which was creating a new cache key on every render for the product grid): +23 percentage points. The product grid was the largest component on most pages. Every request was creating a unique cache entry and hitting the database.
  • Fixing the revalidateTag → updateTag confusion (which was not a cache hit rate issue but a correctness issue): 0 percentage points improvement on hit rate, but eliminated the stale-data bug after mutations.

The combined fix restored the hit rate to approximately 79 %—somewhat lower than the 84 % baseline because the new model's explicit opt-in meant some components that were implicitly cached before were now correctly running on every request.

The hardest part of the migration was not the API changes. It was finding everywhere we had relied on implicit caching by accident, deciding whether that reliance was intentional or incidental, and making the intent explicit. The build errors were actually helpful — they forced the decisions we had been avoiding.

— Backend engineer, product infrastructure team

Serverless cache persistence: a practical limit

A team running on a serverless provider hit an unexpected pattern: their cache hit rate in production metrics showed 0 % even after correctly implementing 'use cache' with the right profiles. The investigation found that their serverless environment spun up a new function instance for every request, and the in-memory LRU cache that 'use cache' uses by default does not persist across instances.

This is documented behavior: the Next.js documentation states that in serverless environments, cache entries typically do not persist across requests. Build-time static prerendering still works (the shell is pre-built and served from CDN), but runtime caching between requests does not, unless you configure a remote cache handler.

The fix was to switch to 'use cache: remote' for functions where runtime caching was important, backed by the platform's KV store. This incurs a small network round-trip cost per cache check but enables shared cache state across function instances.

// For cross-instance cache sharing on serverless
export async function getSessionData(sessionId: string) {
  'use cache: remote'   // backed by platform KV, not in-memory
  cacheLife('minutes')
  cacheTag(`session-${sessionId}`)
  return db.sessions.findOne(sessionId)
}

The lesson: 'use cache' alone is not a guarantee of runtime cache hits in serverless environments. It is a guarantee that the interface is correct and that static prerendering works. Runtime hit rates depend on the infrastructure.

Limits + caveats

Static export is not supported. Cache Components requires a running Node.js server to handle revalidation, cache storage, and streaming. If you run next build with output: 'export', the build fails when Cache Components is enabled. The combination is explicitly unsupported.

The client-side router cache minimum of 30 seconds is not configurable below 30 seconds. If you call cacheLife({ stale: 10 }), the client ignores the stale value and enforces 30 seconds. This means client-side navigation between pages always has at most 30 seconds of stale data, regardless of configuration. You cannot build a UI that navigates to always-fresh data without using <Suspense> boundaries and dynamic rendering.

The seconds profile opts out of the static shell. Any function with cacheLife('seconds') is treated as a dynamic hole. If you apply the seconds profile to a component and then wrap it in a <Suspense> boundary, it streams at request time like any other uncached dynamic component. This is intentional but may be surprising if you expected a cached function with a short TTL to appear in the static HTML.

React Server Component serialization is restrictive for arguments. You cannot pass class instances, functions (except as pass-through), Symbols, WeakMaps, or URL instances as arguments to cached functions. If your data layer returns class instances (Prisma model instances, custom model objects), you need to serialize them to plain objects before passing them to or from a cached function. This often requires adding .toJSON() or spreading the instance: { ...prismaResult }.

Background revalidation requires a persistent process or a revalidation signal. The ISR-style background regeneration triggered by revalidateTag with a profile only runs when the route receives a request. If a page has no traffic for two days, the cache entry from two days ago is what visitors see when traffic resumes—until that visit triggers background regen. For low-traffic content, combine time-based revalidation with webhook-triggered updateTag calls from the CMS or data source.

updateTag is Server Actions-only. You cannot call updateTag from a Route Handler. Route Handlers must use revalidateTag. This is a deliberate design choice: the read-your-writes guarantee of updateTag is tied to the Server Action request lifecycle, which Route Handlers do not have. If you need immediate invalidation from a Route Handler (e.g., a webhook receiver), use revalidateTag with a short expire profile, accept a brief window of eventual consistency, and ensure your clients retry if they need fresh data.

Turbopack is now the default bundler. Next.js 16 ships Turbopack as the default for next dev and next build. Webpack remains available via next dev --webpack or next build --webpack. This is not directly related to Cache Components but is a change that affects build tooling. Teams with custom webpack configurations need to either migrate them to Turbopack's config format or use the opt-out flag explicitly.

Node.js 20.9+ and TypeScript 5.1+ are now required. Next.js 16 dropped support for Node.js 18. If you run on Node.js 18 in CI or production, the upgrade is a prerequisite.

Sources & further reading