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,
};
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
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'
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):
-
fetchrequests were cached indefinitely by default (cache: 'force-cache') - This led to stale data issues, requiring explicit
revalidateorcache: '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+:
-
fetchrequests 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
}
})
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')
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 })
})
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:
- 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.
- 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).
-
Configuration overload: You needed to understand
dynamic,fetchCache,revalidate,unstable_cache, and React'scache()each with complex option sets. - 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')
}
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>
}
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])
}
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'). -
revalidateTagin 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>
}
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.
}
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} />
}
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-
fetchones) - 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)