- Published on
Next.js 16 Caching: use cache, cacheLife, and cacheTag
- Authors

- Name
- Salman
- X
- @lamebrowndev

The caching story so far
If you have been using Next.js for a while you have been through some things. Next.js 13 introduced the App Router with aggressive caching by default. Next.js 14 kept that. Next.js 15 flipped it and made everything uncached by default because people kept getting stale data and could not figure out why. Now Next.js 16 introduces a new model with use cache, cacheLife, and cacheTag.
It is okay if you are confused. Everyone is confused. The caching model has changed three times and the docs have been rewritten each time. I am going to explain the Next.js 16 model as clearly as I can, without the "this is so much better now" framing, because honestly it is just different, not obviously better.
The three layers of caching in Next.js 16
There are three things you need to understand. They are separate and they interact.
- fetch caching which is the browser-like cache for individual fetch calls
- use cache which is a directive that caches the output of a function or component
- Full Route Cache which is the build-time prerendering of pages
Let me go through each one.
1. fetch caching
In Next.js 16, fetch is not cached by default. This is the same as Next.js 15. If you call fetch(url) you get a fresh request every time.
// Not cached. Fresh request every time.
const res = await fetch('https://api.example.com/data')
const data = await res.json()
If you want to cache a fetch call, you have two options.
Option A: use the fetch cache option directly.
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // cache for 1 hour
})
Option B: use use cache on the function that does the fetch (more on this below).
The next.revalidate option is still there. It still works. It tells Next.js to cache the response and revalidate after the given number of seconds.
You can also tag a fetch for manual revalidation:
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600, tags: ['posts'] }
})
Then somewhere else in your code you can invalidate it:
import { revalidateTag } from 'next/cache'
await revalidateTag('posts')
This part has not changed much from 15. If you were using next.revalidate and next.tags before, they still work.
2. use cache
This is the new thing in Next.js 16. It is a directive you add to a function or a component that tells Next.js to cache the return value.
import { unstable_cache } from 'next/cache'
async function getPosts() {
'use cache'
const res = await fetch('https://api.example.com/posts')
return res.json()
}
When you call getPosts(), Next.js checks if it has a cached result. If it does, it returns that. If it does not, it runs the function and caches the result.
The cache key is based on the function name and the arguments. So if you call getPosts() twice with no arguments, you get the cached result the second time.
async function getPost(id: string) {
'use cache'
const res = await fetch(`https://api.example.com/posts/${id}`)
return res.json()
}
// First call: fetches and caches
const post1 = await getPost('1')
// Second call: returns cached result
const post2 = await getPost('1')
// Different argument: fetches and caches a new result
const post3 = await getPost('2')
The 'use cache' directive goes at the top of the function body, same as 'use server' and 'use client'.
cacheLife
By default, use cache caches forever. That is probably not what you want. You can control the cache lifetime with cacheLife:
import { cacheLife } from 'next/cache'
async function getPosts() {
'use cache'
cacheLife('hours')
const res = await fetch('https://api.example.com/posts')
return res.json()
}
cacheLife takes a named profile. The built-in profiles are:
'seconds'which revalidates every 1 second'minutes'which revalidates every 5 minutes'hours'which revalidates every 1 hour'days'which revalidates every 1 day'weeks'which revalidates every 1 week'max'which caches indefinitely (the default)
You can also pass a custom duration:
async function getPosts() {
'use cache'
cacheLife({ revalidate: 300 }) // 5 minutes in seconds
const res = await fetch('https://api.example.com/posts')
return res.json()
}
cacheTag
You can tag a cached function so you can invalidate it manually:
import { cacheLife, cacheTag } from 'next/cache'
async function getPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const res = await fetch('https://api.example.com/posts')
return res.json()
}
Then when a new post is created, you invalidate:
import { revalidateTag } from 'next/cache'
'use server'
export async function createPost(formData: FormData) {
await db.post.create({
data: { title: formData.get('title') }
})
await revalidateTag('posts')
}
After revalidateTag('posts') is called, the next request to getPosts() will fetch fresh data and re-cache it.
Caching components
You can also use use cache on React components:
async function ProductCard({ productId }: { productId: string }) {
'use cache'
cacheLife('hours')
cacheTag(`product-${productId}`)
const product = await db.product.findUnique({
where: { id: productId }
})
return (
<div>
<h2>{product.name}</h2>
<p>{product.price}</p>
</div>
)
}
Now every time you render <ProductCard productId="1" />, it uses the cached version. The cache key includes the productId prop, so different products get different cache entries.
This is powerful but be careful. If your component renders dynamic content that should be fresh (like a user's cart), do not cache it.
3. Full Route Cache
This is the build-time prerendering. When you run next build, Next.js tries to prerender every page as static HTML. Whether it can do this depends on a few things.
A page can be statically prerendered if:
- It does not use
cookies(),headers(), orsearchParams - It does not use
draftMode - All its data fetches are cacheable or have a revalidate period
If a page uses any of those, it becomes dynamic and is rendered on every request.
You can force a page to be static or dynamic:
// Force static
export const dynamic = 'force-static'
// Force dynamic
export const dynamic = 'force-dynamic'
// Auto (default)
export const dynamic = 'auto'
The Full Route Cache interacts with use cache in a specific way. If your page is statically prerendered at build time, the use cache functions inside it run during the build. The cached results are baked into the HTML. When the cache expires, the page is re-rendered on the next request.
How these layers interact
Here is where people get confused. Let me walk through a concrete example.
You have a page that shows a list of posts:
// app/posts/page.tsx
import { getPosts } from '@/lib/posts'
export default async function Page() {
const posts = await getPosts()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// lib/posts.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const res = await fetch('https://api.example.com/posts')
return res.json()
}
Here is what happens:
At build time: Next.js tries to prerender
/posts. It callsgetPosts(). The function runs, fetches the data, caches the result with a 1 hour lifetime, and the HTML is generated.First request after build: The prerendered HTML is served. Fast.
After 1 hour: The cache expires. The next request triggers a re-render.
getPosts()runs again, fetches fresh data, caches it for another hour. The new HTML is served.When a new post is created: Your Server Action calls
revalidateTag('posts'). The cache is invalidated immediately. The next request triggers a fresh render.
Now what if the page also reads searchParams?
export default async function Page({
searchParams
}: {
searchParams: Promise<{ page: string }>
}) {
const { page } = await searchParams
const posts = await getPosts(page)
// ...
}
Now the page is dynamic because it reads searchParams. It is not prerendered at build time. But getPosts is still cached. So every request runs the page function, but getPosts returns the cached data unless the cache expired or was invalidated.
This is the key insight: the page being dynamic does not mean your data is uncached. use cache works independently of the page's render mode.
Common mistakes
Mistake 1: Caching user-specific data
// BAD: this caches the cart for ALL users
async function getCart(userId: string) {
'use cache'
cacheLife('hours')
const cart = await db.cart.findUnique({ where: { userId } })
return cart
}
Wait, actually this is fine because userId is part of the cache key. Different users get different cache entries. The mistake is when you do not include the user identifier:
// BAD: this caches the cart for the first user who loads the page
async function getCart() {
'use cache'
cacheLife('hours')
const userId = await getCurrentUserId()
const cart = await db.cart.findUnique({ where: { userId } })
return cart
}
Here the cache key does not include userId because it is fetched inside the function. The first user's cart gets cached and every subsequent user sees the first user's cart. That is a data leak.
Fix: Pass the userId as an argument so it is part of the cache key.
Mistake 2: Forgetting to revalidate after mutations
You cached your data with use cache and cacheTag. You created a new post. But you forgot to call revalidateTag. Now your users see the old list of posts until the cache expires.
'use server'
export async function createPost(formData: FormData) {
await db.post.create({
data: { title: formData.get('title') }
})
// Forgot to call revalidateTag('posts')
}
Fix: Always revalidate after mutations.
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({
data: { title: formData.get('title') }
})
await revalidateTag('posts')
}
Mistake 3: Tag string mismatches
You tag your cache with 'posts' but you revalidate with 'post'. Singular vs plural. Nothing happens.
// Cached with tag 'posts'
cacheTag('posts')
// Revalidated with tag 'post'
await revalidateTag('post') // does nothing
This is the most common bug I see in review. Fix: Use constants for your tag names.
// lib/cache-tags.ts
export const CACHE_TAGS = {
posts: 'posts',
post: (id: string) => `post-${id}`
} as const
// lib/posts.ts
import { CACHE_TAGS } from '@/lib/cache-tags'
cacheTag(CACHE_TAGS.posts)
// app/actions.ts
import { CACHE_TAGS } from '@/lib/cache-tags'
await revalidateTag(CACHE_TAGS.posts)
Mistake 4: Over-caching
You put use cache on everything because it makes your app fast. Now your user profile page shows stale data, your dashboard numbers are wrong, and your real-time feed is anything but real-time.
Fix: Only cache data that is okay to be stale. Product catalogs, blog posts, documentation. Do not cache shopping carts, user notifications, or anything that changes based on user actions and needs to be immediate.
A decision framework
When should you use each caching mechanism? Here is how I decide.
| Scenario | What to use |
|---|---|
| Single API call, okay to be stale | fetch with next.revalidate |
| Single API call, needs manual invalidation | fetch with next.tags + revalidateTag |
| Expensive computation, okay to be stale | use cache with cacheLife |
| Expensive computation, needs manual invalidation | use cache with cacheTag + revalidateTag |
| Page should be fully static | Make sure no dynamic APIs are used |
| Page needs to be dynamic but data can be cached | Use use cache on the data fetch, let the page be dynamic |
| User-specific data | Pass user ID as argument, do not cache inside the function |
The mental model
Stop thinking about caching as a global on/off switch. Next.js 13 tried that and it was a disaster. Next.js 15 tried the opposite and everything was slow. Next.js 16 makes caching opt-in per function.
The mental model is: every function that fetches data or does expensive work is a candidate for use cache. You decide per function how long to cache and when to invalidate. The page renders fresh every time (if dynamic) but the data inside it comes from cache.
That is it. There is no global cache config. There is no hidden default that bites you. You mark what you want cached, you set the lifetime, you tag it for invalidation. Everything else is uncached.
It is more work than the old model but it is also more predictable. I will take predictable over magical any day.
I fix Next.js production issues for a living. If your caching setup is a mess and you need help untangling it, email me.
