DEV Community

Cover image for Next.js 16 Cache Components: The End of Route-Level Caching Chaos (with use cache & PPR)
Mainuddin Mehedi
Mainuddin Mehedi

Posted on

Next.js 16 Cache Components: The End of Route-Level Caching Chaos (with use cache & PPR)

In the recent Next.js 16 update, the team introduced a new thing called cache components. It's a new model with a fundamental shift in how we think about caching and rendering in Next.js applications. Leveraging Partial Pre-Rendering (PPR) and use cache directive to enable instant navigation with granular control over what gets cached and when.

Unlike the previous model where caching used to be implicit, heuristic-based, and largely route-bound, the new model makes caching explicit and component-centric. This new method allows us to have granular control over the whole caching model of our application by allowing us to explicitly cache at component, function and even at the file level.

Quick Reference: The New Caching Pattern at a Glance

Enable cacheComponents in next.config.js:

const nextConfig = {
  cacheComponents: true,
};
Enter fullscreen mode Exit fullscreen mode

Core idea: Everything is dynamic by default. You explicitly opt in to caching at the component or function level using the 'use cache' directive.

Key APIs:

  • 'use cache' → Caches the return value (data or rendered UI)
  • cacheLife('hours') (or custom profile) → Controls time-based revalidation
  • cacheTag('products') + updateTag('products') → On-demand invalidation
  • revalidateTag → Stale-while-revalidate behavior

How rendering and caching now blend (PPR):

  • Static shell (pages prerendered at build time)
  • Cached dynamic components (rendered at build, cached, revalidated automatically)
  • True dynamic holes (wrapped in <Suspense>, streamed at request time)

This creates instant static shells with fresh or cached dynamic content giving us the best of both worlds.

Now let's look into the Next.js caching system and establish a contrasting view of both approaches.

Previous Model

So, the thing that has changed in Next.js caching is the handling of Full route cache and Data cache. The other two caching layers remain the same and are very simple.

Before getting into it let's have a look at the four layers of caching in Next.js and how they worked according to the previous caching system.

Layer How it worked
Request Memoization Deduplicates identical fetch calls during a single render
Data Cache Empty unless you explicitly cached (fetch with force-cache or unstable_cache)
Full Route Cache Implicitly cached static routes (RSC payload/HTML) in the server. Opted out when dynamic APIs detected
Router Cache Client-side RSC payload cache for soft navigation. Pages are cached in the client as the user navigates.

Full Route Cache

In the previous model, Full route cache used to be handled implicitly for a purely static page. This means pages that don't have any dynamic data are automatically cached. But the problem was that we didn't have any option to handle things from the component level. For this reason, whenever a page had dynamic operations that are uncached, the whole route became dynamic. It didn't matter whether I had a purely static component. Just because it was used in a dynamic page, it re-rendered despite not having any dynamic data and no changes.

Though we had our option to work with route level caching using methods like these:

// app/page.js — Route-level configuration only
export const dynamic = 'auto' // 'force-dynamic' | 'force-static' | 'error'
export const revalidate = 60 // seconds
export const fetchCache = 'auto' // Complex override options
Enter fullscreen mode Exit fullscreen mode

The problem was that one uncached fetch in a deeply nested component would force the entire route to be dynamic. You couldn't easily have a mostly-static page with one dynamic widget without complex workarounds or splitting into multiple routes.

The fetchCache option in the route level configuration provided many options to handle how fetch requests are cached but they were confusing.

export const fetchCache = 'auto' // Complex override options -
// 'auto' | 'force-cache' | 'force-no-store' | 'only-cache' | 'only-no-store'
// 'default-cache' | 'default-no-store'
Enter fullscreen mode Exit fullscreen mode

It threw error if the route level configuration didn't match with the individual fetch configurations for cache.

Data Cache

The control we had over the caching system was at the Data Cache level.

Before Next.js 15 (Oct 2024):

  • fetch requests were cached indefinitely by default (cache: 'force-cache')
  • This led to stale data issues, requiring explicit revalidate or cache: 'no-store' to opt out

The "Auto" Heuristic:
With dynamic: 'auto' (the default), Next.js applied a complex rule: it cached fetch requests discovered before any dynamic APIs (cookies(), headers(), etc.) were used, but not those discovered after. This execution-order dependency confused many developers.

Next.js 15+:

  • fetch requests are NOT cached by default (cache: 'no-store')
  • You must explicitly opt in with cache: 'force-cache'

Caching fetch requests

const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache', // Opt-in to caching
  next: {
    revalidate: 3600, // Revalidate every hour
    tags: ['products'] // For on-demand invalidation
  }
})
Enter fullscreen mode Exit fullscreen mode

unstable_cache for Non-Fetch Data

For database queries or other async operations that weren't native fetch calls, was cached using unstable_cache:

import { unstable_cache } from 'next/cache'

const getCachedUser = unstable_cache(
  async (id: string) => {
    return db.user.findUnique({ where: { id } })
  },
  ['user-cache-key'], // Cache key segments
  {
    revalidate: 3600, // Seconds
    tags: ['users']   // For revalidation
  }
)

// Usage
const user = await getCachedUser('123')
Enter fullscreen mode Exit fullscreen mode

This wrapped arbitrary async functions in the Data Cache. But it required manually constructing cache keys and managing revalidation tags across the application.

React cache() (Request Memoization only)

import { cache } from 'react'

export const getItem = cache(async (id) => {
  return await db.item.findUnique({ id })
})
Enter fullscreen mode Exit fullscreen mode

React's cache() provided deduplication within a single request's render tree, preventing duplicate database hits if the same function was called multiple times during one render. It didn't persist across users, builds, or page navigations. Once the request ended, the cache was gone.

Revalidation in the Previous Model

Revalidation was handled in two ways:

Time-based: Set next.revalidate on fetch or revalidate in unstable_cache config to automatically refresh after a set duration. We could also revalidate in the route level configuration using export const revalidate = 60 for time-based revalidation of the entire route.

On-demand: Use revalidateTag('tag-name') or revalidatePath('/path') inside a Server Action or Route Handler to immediately purge cached data after mutations.

The Pain Points

The previous model created several developer experience issues:

  1. Route-level granularity: You couldn't mix static and dynamic content on the same page easily. One dynamic component (needing cookies) forced the entire route to render dynamically.
  2. Implicit behavior: The "auto" heuristics were unpredictable. Whether a fetch was cached depended on execution order relative to dynamic APIs. (Fixed with Next.js 15 update).
  3. Configuration overload: You needed to understand dynamic, fetchCache, revalidate, unstable_cache, and React's cache() each with complex option sets.
  4. No component boundaries: To cache a component, you had to cache the entire route or use experimental PPR with confusing segment configs.

New Model: Explicit, Component Level Caching

Next.js 16's Cache Components solve these problems by inverting the model: nothing is cached unless you explicitly say so, and you can now mark individual components, functions, or even UI segments for caching while leaving the rest dynamic.

The Mental Shift

Aspect Previous Model New Model
Default behavior Heuristic-based (cached before dynamic APIs) Nothing cached by default
Granularity Route-level only (page/layout) Component, function, or file level
Opt-in method cache: 'force-cache' on fetch, route segment configs 'use cache' directive
Non-fetch caching unstable_cache or React cache() (limited) 'use cache' wraps any function
Static/Dynamic mix Required complex PPR flags Native via Suspense boundaries

Core Philosophy

  • Dynamic by default - nothing is cached unless you say so.
  • Explicit at component/function level - caching is declared where the work happens.
  • Unified API for data and UI caching.
  • Partial Prerendering (PPR) is the default - static shell + dynamic streaming.

The difference is pretty clear. Instead of reasoning about route-level heuristics and hoping the framework guesses right, you now just tell Next.js what to cache right where the code lives.

How use cache Works

The 'use cache' directive is the foundation. When added above a component or function, it tells Next.js to cache the result:

File Level - Cache everything exported

'use cache' // At the very top of the file

export default async function Page() {
  const data = await fetch('https://api.example.com/data')
  return <div>{/* render */}</div>
}

// All exports in this file are cached
export async function getData() {
  return db.query('SELECT * FROM items')
}
Enter fullscreen mode Exit fullscreen mode

Component-level caching

export async function ProductCard({ id }) {
  'use cache'
  const product = await db.query('SELECT * FROM products WHERE id = ?', [id])
  return <div>{product.name}</div>
}
Enter fullscreen mode Exit fullscreen mode

Function-level caching

import { cacheTag } from 'next/cache'

async function getProduct(id) {
  'use cache'
  cacheTag(`product-${id}`)
  return await db.query('SELECT * FROM products WHERE id = ?', [id])
}
Enter fullscreen mode Exit fullscreen mode

Actually that's all there is to it. We don't need to construct cache keys, no wrapper functions, no route configs. It's just a directive that is placed right where the caching is taking place.

Cache Lifecycle Control

Cache lifecycle is encouraged to be controlled via tagging. When using the use cache directive, you can set a tag for the cache using cacheTag. Then later on you can use that tag with updateTag or revalidateTag to invalidate the cache.
You can also use revalidatePath to invalidate all cached data for a specific route path.

Golden Rule:

  • updateTag() can only be used in Server Actions ('use server').
  • revalidateTag in a Server Action or Route Handler

Time-based revalidation:

import { cacheLife } from 'next/cache'

export async function WeatherWidget() {
  'use cache'
  cacheLife('hours') // Uses predefined profile
  // Or custom: cacheLife({ stale: 3600, revalidate: 900, expire: 86400 })

  const weather = await fetchWeather()
  return <div>{weather.temp}°C</div>
}
Enter fullscreen mode Exit fullscreen mode

On-demand invalidation:

// In your component
import { cacheTag } from 'next/cache'

export async function ProductList() {
  'use cache'
  cacheTag('products')
  return await db.query('SELECT * FROM products')
}

// In a Server Action
import { updateTag } from 'next/cache'

export async function createProduct(formData) {
  await db.insert('products', formData)
  updateTag('products') // Immediately invalidate
  // or you may do 
  revalidateTag('products', 'max') // stale-while-revalidate
  // Stale content is served immediately while fresh content loads in the background.
}
Enter fullscreen mode Exit fullscreen mode

NOTE:
The second argument in revalidateTag sets how long stale content can be served while fresh content generates in the background. Using 'max' gives the longest stale window.

Partial Pre-Rendering (PPR) Integration

The new model shines with the combination of PPR. You can now compose static and dynamic content naturally:

// app/page.js
export default async function Page() {
  return (
    <main>
      {/* Static: Cached at build time */}
      <Header />

      {/* Static: Cached with 1-hour revalidation */}
      <ProductRecommendations />

      {/* Dynamic: Streamed at request time */}
      <Suspense fallback={<CartSkeleton />}>
        <ShoppingCart /> {/* Uses cookies, not cached */}
      </Suspense>

      {/* Dynamic: User-specific, not cached */}
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile />
      </Suspense>
    </main>
  )
}

// components/ProductRecommendations.js
import { cacheLife } from 'next/cache'

export async function ProductRecommendations() {
  'use cache'
  cacheLife('hours')
  const products = await db.query('SELECT * FROM recommendations')
  return <ProductGrid products={products} />
}
Enter fullscreen mode Exit fullscreen mode

Notice how the page naturally composes all three - a static header, cached recommendations, and dynamic user content without a single route segment config. This is the composability that was missing before.

What Gets Cached?

With 'use cache', Next.js caches:

  • The return value (data or rendered UI)
  • The component's rendered output (if used in a component)
  • Arguments passed to the function (as cache key parts)

This works for:

  • Database queries (Prisma, Drizzle, raw SQL)
  • External API calls (even non-fetch ones)
  • Expensive computations
  • Entire component render outputs

Wrapping Up

The shift from Next.js's previous caching model to cache components is more than just a new API, it's a fundamentally different way of thinking. Instead of fighting route-level heuristics and managing a patchwork of fetch options, unstable_cache, and segment configs, you now have a single directive 'use cache' that works at the component, function, or file level.

The mental model is clean: everything is dynamic by default. You explicitly opt in to caching exactly where you need it, with cacheLife for time-based control and cacheTag/updateTag for on-demand invalidation. Combined with PPR, this means your pages get instant static shells while streaming fresh content, no complex configuration required.

If you've ever been frustrated by a single cookies() call making your entire page dynamic, or puzzled over why your data was stale despite setting revalidate, this new model is the answer. It puts caching decisions where they belong, next to the code that actually needs them.

Top comments (0)