Next.js `use cache` In-Depth Guide: On-Demand Cache Invalidation Strategy with cacheTag and revalidateTag
Have you ever set fetch() options to cache: 'no-store' only to find the entire page switching to dynamic rendering? Or perhaps you've left caching enabled without knowing when content would refresh, and ended up slapping on a 5-minute revalidate as a stopgap. Next.js's implicit caching system is powerful, but it has a structural limitation: it's hard to precisely control how it behaves.
With use cache — introduced experimentally in Next.js 15 and promoted to a stable API in Next.js 16 — a new approach to solving this problem is now possible. Developers can now declare directly in code what to cache, for how long, and under what conditions. This shift isn't just an API swap; it's a fundamental change in how caching is expressed. The result is a broader build-time static generation surface and the ability to trace cache bugs directly in the code.
This article walks through practical code patterns for configuring built-in and custom cacheLife profiles, hierarchical invalidation strategies using cacheTag, and how to choose between revalidateTag and updateTag depending on the situation. The primary audience is developers already running Next.js App Router with basic Server Action experience — the goal is to help you design cache strategies optimized per domain, moving beyond the page-level caching limitations of ISR.
Core Concepts
The use cache Directive — The Starting Point for Explicit Caching
use cache is a React directive that instructs the framework to cache the return value of an async function or component. The scope of its effect depends on where it's declared.
// Declared at the top of a file → all exports in that file are cached
'use cache'
export async function getProducts() {
return db.product.findMany() // Prisma Client example
}// Declared inside a function/component → only that return value is cached
export async function getPost(id: string) {
'use cache'
return db.post.findUnique({ where: { id } }) // Prisma Client example
}Automatic cache key generation: Arguments passed to the function and closure values are automatically serialized and included in the cache key.
getPost('abc')andgetPost('xyz')are managed as separate cache entries. However, if the closure contains values that cannot be serialized — such as function or class instances — a runtime error will occur. It's best to limit values passed into the cache scope to serializable primitives, objects, and arrays.
cacheLife — Controlling Cache Lifetime Along Three Axes
cacheLife(profile) sets the lifetime of a cache entry within a use cache scope. Cache lifetime is defined by three properties.
| Property | Meaning |
|---|---|
stale |
How long the client router considers the cache fresh without revalidating with the server |
revalidate |
How often the server regenerates the cache in the background (stale-while-revalidate) |
expire |
The maximum time before the cache must expire. Must be longer than revalidate |
The stale-while-revalidate behavior is easiest to understand through this timeline:
[Request 1] ──→ Immediately return stale cache + Start background regeneration
↓
[Regeneration complete] → New data stored in cache
[Request 2] ──→ Return new cacheOn Request 1, the user instantly receives the previous cached data while the server quietly prepares fresh data in the background. From Request 2 onward, the updated data is served. Responses are fast, but note that data a user has just modified themselves won't be reflected immediately — this is covered later under updateTag.
Next.js provides the following built-in profiles:
| Profile | stale | revalidate | expire | Notes |
|---|---|---|---|---|
seconds ⚠️ |
30s | 1s | 1min | Dynamic zone — excluded from prerender |
minutes |
5min | 1min | 1hr | |
hours |
5min | 1hr | 1day | |
days |
5min | 1day | 1week | |
weeks |
5min | 1week | 1month | |
max |
5min | 1month | Infinity | Near-permanent cache |
⚠️ Dynamic Zone (Dynamic Hole): Profiles where
revalidateorexpireis less than 5 minutes (seconds) are excluded from prerendering. "Excluded from prerender" means no static HTML is generated at build time — the server function runs on every request. Even with a cache declaration, don't expect a performance benefit. If you need updates within 1 minute, consider dynamic rendering instead of caching.
When built-in profiles don't fit your domain requirements, you can define custom profiles in next.config.ts.
// next.config.ts
// Flag name as of Next.js 16.
// In Next.js 15, use dynamicIO: true or useCache: true.
const nextConfig = {
experimental: { cacheComponents: true },
cacheLife: {
'product-catalog': {
stale: 300, // 5 minutes
revalidate: 3600, // 1 hour
expire: 86400, // 1 day
},
'realtime-stock': {
stale: 0,
revalidate: 5,
expire: 10,
},
'static-content': {
stale: 300,
revalidate: 86400,
expire: Infinity,
},
},
}
export default nextConfigcacheTag — A Tag System for Selective Invalidation
cacheTag(...tags: string[]) assigns identifier tags to a cache entry. When revalidateTag(tag) or updateTag(tag) is called later, all cache entries carrying that tag are invalidated.
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache'
// In Next.js 15, these are prefixed with unstable_.
// In Next.js 16, they are stabilized as cacheTag and cacheLife.
export async function getPost(id: string) {
'use cache'
cacheLife('hours')
cacheTag(`post:${id}`, 'posts') // Assign both an individual tag and a group tag
return db.post.findUnique({ where: { id } }) // Prisma Client example
}Tag limits: Maximum 256 characters per tag, and up to 128 tags per cache entry.
revalidateTag vs updateTag — When to Use Which
Both functions perform tag-based invalidation, but they behave differently and suit different scenarios.
| Function | Where to use | Behavior | Primary use case |
|---|---|---|---|
revalidateTag |
Server Action, Route Handler | stale-while-revalidate: immediately serves existing cache, then regenerates in the background | Webhooks, external system integrations, non-urgent updates |
updateTag |
Server Action only | Immediate expiry: the next request always returns fresh data | Data the user has directly modified |
updateTagversion note: In Next.js 15, it is available asunstable_updateTag. It was stabilized asupdateTagin Next.js 16. Use the import that matches your project's Next.js version. GitHub Discussion #84805 covers the design intent behind both functions.
read-your-own-writes: This is the pattern where a user who just updated their profile image immediately sees the change reflected on screen. Because
revalidateTagregenerates in the background, the change may not appear immediately in this scenario.updateTagis the right choice here.
Practical Application
The three tools covered above (cacheLife, cacheTag, and revalidateTag/updateTag) show their full value when used in combination. The examples below each address a different scenario, along with guidance on which pattern to choose.
Example 1: Separating Group and Individual Invalidation with Hierarchical Tags
This is the most common pattern when a post list and individual posts need to be invalidated independently. By assigning two tags simultaneously, you can selectively invalidate either "just this one post" or "the entire post list."
// lib/posts.ts
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache'
// Next.js 16: cacheTag, cacheLife (import without unstable_)
export async function getPost(id: string) {
'use cache'
cacheLife('hours')
cacheTag(`post:${id}`, 'posts') // Individual tag + group tag
return db.post.findUnique({ where: { id } }) // Prisma Client example
}
export async function getAllPosts() {
'use cache'
cacheLife('minutes')
cacheTag('posts') // Group tag only
return db.post.findMany()
}// app/actions/post.ts
'use server'
import { revalidateTag, updateTag } from 'next/cache'
// Next.js 15: unstable_updateTag as updateTag
import { redirect } from 'next/navigation'
// User directly modifies a post → use updateTag for immediate expiry
export async function updatePost(id: string, data: PostData) {
await db.post.update({ where: { id }, data })
updateTag(`post:${id}`) // Immediate expiry: next request must return the latest data
// Note: redirect() throws internally.
// If you add a try/catch later, redirect() must be placed outside the catch block.
redirect(`/posts/${id}`)
}
// New post created → only invalidate the list
export async function createPost(data: PostData) {
const post = await db.post.create({ data })
updateTag('posts') // Invalidate all cache entries with the 'posts' tag
redirect(`/posts/${post.id}`)
}| Code point | Description |
|---|---|
cacheTag(post:${id}, 'posts') |
Assigns both tags so either the individual post or the full list can be invalidated |
updateTag(post:${id}) |
Precisely targets only that post for immediate expiry |
updateTag('posts') |
Invalidates all entries tagged with posts (the list, etc.) at once |
Example 2: Webhook-Based On-Demand Invalidation (Headless CMS Integration)
Where Example 1 covers "user-initiated changes," this example covers scenarios where an external system — such as DatoCMS or Sanity — triggers a change. Since external changes can tolerate eventual consistency rather than immediate reflection, revalidateTag is appropriate here.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
// Validate the webhook secret
// ⚠️ Security note: querystring secrets can appear in server and CDN logs.
// In production, use an Authorization header or HMAC signature verification instead.
const secret = req.headers.get('authorization')?.replace('Bearer ', '')
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
let tag: string
try {
const body = await req.json()
tag = body?.tag
if (!tag || typeof tag !== 'string') {
return Response.json({ error: 'tag field is required' }, { status: 400 })
}
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
// stale-while-revalidate: serves existing cache immediately, returns fresh data from the next request onward
revalidateTag(tag)
return Response.json({ revalidated: true, tag })
}Configure your CMS to fire a webhook on content changes in the following format:
POST https://your-site.com/api/revalidate
Authorization: Bearer YOUR_SECRET
Body: { "tag": "products" }Example 3: Separating Cache Strategies per Domain with Custom Profiles
Where Examples 1 and 2 focus on invalidation strategies, this example is about explicitly declaring upfront "how long to keep which data" per domain. Managing real-time stock prices and nearly-static content under the same profile is inefficient.
// next.config.ts
export default {
experimental: { cacheComponents: true }, // Next.js 16 flag
cacheLife: {
'realtime-stock': { stale: 0, revalidate: 5, expire: 10 },
// ⚠️ revalidate: 5 → less than 5 minutes, so this falls in the Dynamic Zone (Dynamic Hole).
// Use this profile to unify the cache interface, but don't expect static generation benefits.
'user-profile': { stale: 30, revalidate: 60, expire: 300 },
'static-content': { stale: 300, revalidate: 86400, expire: Infinity },
},
}// lib/stock.ts
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as cacheLife
} from 'next/cache'
export async function getStockPrice(ticker: string) {
'use cache'
cacheLife('realtime-stock') // Use custom profile name
cacheTag(`stock:${ticker}`)
return fetchStockAPI(ticker)
}
export async function getStaticPage(slug: string) {
'use cache'
cacheLife('static-content')
cacheTag(`page:${slug}`)
return db.page.findUnique({ where: { slug } }) // Prisma Client example
}Example 4: Guaranteeing read-your-own-writes with updateTag in a Server Action
Unlike Example 2 (webhooks), this example covers cases where data a user has directly changed must be immediately reflected on screen. The difference between the two examples comes down to "who triggers the change."
// app/actions/profile.ts
'use server'
import { updateTag } from 'next/cache'
// Next.js 15: import { unstable_updateTag as updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function updateUserProfile(formData: FormData) {
const userId = await getCurrentUserId()
await db.user.update({
where: { id: userId },
data: {
name: formData.get('name') as string,
avatar: formData.get('avatar') as string,
},
})
// Use updateTag instead of revalidateTag:
// The next request is guaranteed to return the latest profile data.
updateTag(`user:${userId}`)
// redirect() throws internally, so if you add a try/catch later,
// keep redirect() outside the catch block so it isn't caught.
redirect('/profile')
}Why is
revalidateTagnot appropriate here?revalidateTagserves the existing cache immediately and regenerates in the background. The profile changes the user just saved may still appear as old data on the next request, harming UX.updateTagmarks the entry as immediately expired, so the next request always fetches fresh data.
The Most Common Mistakes in Practice
Having looked at all four examples, it's worth noting the pitfalls you'll most commonly encounter during implementation.
- Confusing
revalidateTagandupdateTag: UsingrevalidateTagfor data a user has directly modified (profiles, settings, etc.) can result in changes not appearing on screen right away, causing UX issues. UseupdateTagimmediately after a user's write operation. - Calling
cookies()orheaders()directly inside a cache scope: Calling these APIs directly inside a function marked withuse cachewill cause a runtime error. You must read their values outside the cache scope and pass them in as arguments. - Using only the default
use cachein serverless environments: In environments like Lambda or Vercel Functions where multiple instances can be created, using the default in-memory cache means the same data gets cached separately in each instance, leading to consistency issues. In production, it's recommended to configureuse cache: remoteor a custom handler based on Redis/Upstash (covered in the next article).
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Fine-grained cache control | Independent cache strategies can be applied at the component and function level — far more precise than page-level ISR |
| Explicit caching intent | What is being cached is immediately apparent from the code, eliminating the debugging difficulty of implicit caching |
| Automatic cache key generation | Function arguments and closure values are automatically serialized to create per-parameter cache entries |
| On-demand invalidation | Selectively invalidate only specific tags while leaving unrelated caches intact |
| PPR integration | Cached components are included in the static shell, improving LCP (Largest Contentful Paint) performance |
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| In-memory cache limitations | Cache cannot be shared across instances in serverless environments; memory may be released after requests | Use use cache: remote or a Redis/Upstash custom handler — specific configuration will be covered in the next installment |
| No access to runtime APIs | cookies(), headers(), and searchParams cannot be called directly within the cache scope |
Read values outside and pass them as function arguments |
| Visual delay with stale-while-revalidate | When using revalidateTag, a user's own changes may not be reflected immediately |
Use updateTag for user-initiated changes |
seconds profile performance issues |
If revalidate < 5 minutes, excluded from prerender and server function runs on every request | For sub-1-minute updates, consider dynamic rendering instead of caching |
use cache: private not yet stable |
Personalized caching depends on PPR runtime prefetching and is still experimental | Until stabilized, use existing approaches like server-side sessions in parallel |
| Configuration complexity | Requires the cacheComponents: true flag. Mixing with existing fetch caching or unstable_cache can cause confusing behavior |
Unify new projects on use cache; migrate existing projects incrementally |
ISR (Incremental Static Regeneration): Next.js's existing caching strategy that generates static HTML at build time and regenerates it on a set schedule.
use cacheallows this strategy to be applied at the component and function level rather than the page level.
PPR (Partial Prerendering): A hybrid rendering strategy in Next.js that serves both a static shell (cached portions) and dynamic streaming (real-time portions) on a single page. Components marked with
use cacheare included in this static shell.
Wrapping Up
The combination of use cache + cacheLife + cacheTag is the most powerful way to explicitly express — at the code level — what to cache, for how long, and under what conditions. It lets you move beyond the page-level caching limitations of ISR and apply a precise strategy tailored to each component and data fetching function.
Here are three steps you can start with right now:
- Add
experimental: { cacheComponents: true }tonext.config.tsto enable the Cache Components feature. If you're already running a Next.js 15+ project, one line of configuration is all it takes to get started with the new caching system (in Next.js 15, usedynamicIO: trueoruseCache: true). - Pick one data fetching function that's called frequently but doesn't change often, and add
'use cache'along withcacheLife('hours'). Adding acacheTagat the same time will make it easy to wire up the invalidation flow later. - In the Server Action that mutates that data, call
updateTagorrevalidateTagto complete the invalidation flow. UseupdateTagfor data the user directly changes, andrevalidateTagwhen an external system — like a CMS webhook — is the trigger.
Next article: Configuring a custom cache handler connecting
use cache: remotewith Upstash Redis — a production pattern for sharing cache across instances in serverless environments.
References
Official Documentation
- Functions: cacheLife | Next.js
- Functions: cacheTag | Next.js
- Functions: revalidateTag | Next.js
- Functions: updateTag | Next.js
- Directives: use cache | Next.js
- next.config.js: cacheLife | Next.js
- Getting Started: Caching and Revalidating | Next.js
- Getting Started: Revalidating | Next.js
Official Blog and Academy
- Our Journey with Caching | Next.js Official Blog
- Cache Components for Instant and Fresh Pages | Vercel Academy
Community Discussions (ongoing)
- updateTag vs revalidateTag · vercel/next.js Discussion #84805 — A thread discussing the design intent of
updateTagand its differences fromrevalidateTag. The API status may change, so it's recommended to cross-reference with the latest release notes.
CMS Integration Guides