Caching in Next.js 16: A Practical Guide to use cache
Learn how caching works in Next.js 16 with use cache, cacheLife, cacheTag and updateTag. Clear examples with Prisma, plus the mistakes to avoid.
CarnielOn this page10 sections
If you used the App Router in Next.js 14 or 15, you have probably asked this question at least once: why is this page still showing old data? Caching happened in several places at once, some of it on by default, and it was hard to tell which layer was holding on to what.
Next.js 16 fixes this with a different approach called Cache Components. The rule is simple: nothing is cached unless you ask for it. You ask for it with one directive, 'use cache'.
This post walks through how it works using a small product catalog built with Prisma. By the end you will know how to cache data, control how long it lives, clear it after a change, and keep user-specific data out of the shared cache.
What changed in Next.js 16
In earlier versions, many requests were cached by default and you opted out with options like cache: 'no-store' or export const dynamic = 'force-dynamic'.
With Cache Components turned on, it works the other way round. Data is dynamic by default, which means it is fetched fresh on every request. When you want something cached, you mark it with 'use cache'. You decide what gets cached, and the code shows it clearly.
Three helpers from next/cache do the rest:
cacheLifesets how long a cached result stays valid.cacheTaggives a cached result a name so you can clear it later.updateTagandrevalidateTagclear tagged results after your data changes.
Step 1: Turn on Cache Components
Add the flag to your Next.js config:
ts
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigOnce this is on, Next.js will point out any data access it cannot place during development. That feedback is useful, so read the messages instead of silencing them.
Step 2: Cache a data function
Here is a function that loads products from the database. Without the directive, it would hit the database on every request.
ts
// lib/products.ts
import { cacheLife, cacheTag } from 'next/cache'
import { prisma } from '@/lib/prisma'
export async function getProducts() {
'use cache'
cacheLife('hours')
cacheTag('products')
return prisma.product.findMany({
orderBy: { createdAt: 'desc' },
})
}'use cache' must be the first line inside the function. After that, the result is stored and reused until it expires or you clear it.
You do not write a cache key. Next.js builds the key from the function and its arguments. So if a function takes an ID, each ID gets its own entry:
ts
export async function getProduct(id: string) {
'use cache'
cacheLife('days')
cacheTag('products', `product-${id}`)
return prisma.product.findUnique({ where: { id } })
}Notice that this function has two tags. The first lets you clear every product at once. The second lets you clear just this one.
You can also put 'use cache' at the top of a Server Component, or at the top of a file to cache everything it exports. I prefer caching data functions, because the cache rules stay next to the query they describe.
Step 3: Decide how long data should live
cacheLife accepts a named profile. The built-in ones are seconds, minutes, hours, days, weeks and max.
Pick the profile based on how often the data really changes:
Stock levels during a sale:
minutesA product catalog:
hoursBlog posts or CMS pages:
days, ormaxcombined with a tag you clear on publish
The Next.js docs recommend setting cacheLife explicitly in every cached function. If you leave it out, a default profile applies, and anyone reading the code later has to guess how long the data lives.
If you need exact numbers, pass an object instead of a profile name:
ts
cacheLife({ revalidate: 600 }) // refresh after 10 minutesStep 4: Clear the cache after a change
Caching is only half the job. When someone edits a product, the old version must go. This is where tags come in, and Next.js 16 gives you two functions for it.
updateTag: the user should see the change right away
Use updateTag inside a Server Action when a user changes something and expects to see the result on the next screen. An admin editing a price is a good example.
ts
// app/admin/products/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { prisma } from '@/lib/prisma'
export async function updatePrice(id: string, formData: FormData) {
const price = Number(formData.get('price'))
await prisma.product.update({
where: { id },
data: { price },
})
updateTag(`product-${id}`)
updateTag('products')
}The next read fetches fresh data, so the admin never sees the old price after saving. Note that updateTag only works inside Server Functions.
revalidateTag: fresh soon is good enough
Sometimes the change comes from outside your app, for example a webhook from a CMS or a payment provider. Nobody is staring at the screen waiting for it. In that case, use revalidateTag with a profile:
ts
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const secret = request.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
revalidateTag('products', 'max')
return Response.json({ revalidated: true })
}This marks the entries as stale. Visitors keep getting the cached version while a fresh one is prepared in the background, so nobody waits on a slow query.
A simple way to choose: if a person just clicked Save, use updateTag. If a system sent a signal, use revalidateTag.
Step 5: Keep user data out of the shared cache
A cached result is shared by everyone who calls the function with the same arguments. That is why you cannot read cookies(), headers() or searchParams inside a 'use cache' scope. If you could, one user's data might be served to another.
The fix is to read request data outside the cached function and pass only what you need as an argument.
Say your store lets shoppers pick a currency, saved in a cookie:
ts
// lib/prices.ts
import { cacheLife, cacheTag } from 'next/cache'
import { prisma } from '@/lib/prisma'
export async function getPrices(currency: string) {
'use cache'
cacheLife('minutes')
cacheTag('prices')
return prisma.price.findMany({ where: { currency } })
}tsx
// app/shop/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { getProducts } from '@/lib/products'
import { getPrices } from '@/lib/prices'
export default function ShopPage() {
return (
<main>
<ProductGrid />
<Suspense fallback={<p>Loading prices...</p>}>
<PriceList />
</Suspense>
</main>
)
}
async function ProductGrid() {
const products = await getProducts()
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}
async function PriceList() {
const currency = (await cookies()).get('currency')?.value ?? 'NGN'
const prices = await getPrices(currency)
return (
<ul>
{prices.map((price) => (
<li key={price.id}>{price.amount}</li>
))}
</ul>
)
}Two things are happening here:
ProductGriduses cached data only, so it can be part of the page that loads instantly.PriceListreads a cookie, which makes it dynamic. It sits inside<Suspense>, so the rest of the page shows first and the prices stream in. Becausecurrencyis an argument, shoppers using naira get one cache entry and shoppers using dollars get another.
This is the pattern to remember: cache the shared part, stream the personal part.
Mistakes to avoid
Reading cookies or headers inside a cached function. Next.js will throw an error. Move the read outside and pass the value in.
Passing arguments that cannot be serialized. The cache key is built from your arguments, so pass plain values like strings, numbers and simple objects. Do not pass class instances or functions.
Leaving out cacheLife. It works, but the lifetime becomes invisible. Set it every time.
Tagging too broadly. If every function uses the tag data, one change clears everything. Use specific tags like product-${id} next to broad ones like products.
Assuming the cache survives everything. Entries from 'use cache' are not guaranteed to persist across deployments or between serverless instances. If you need a shared, long-lived cache, look at the 'use cache: remote' variant and a cache handler for your platform.
Coming from older Next.js versions
If you are upgrading an existing app, this is how the old APIs map to the new ones:
BeforeWith Cache Componentsfetch(url, { next: { revalidate: 3600 } })'use cache' with cacheLife('hours')fetch(url, { next: { tags: ['posts'] } })'use cache' with cacheTag('posts')unstable_cache(fn, keys, options)A function with 'use cache' (no key array needed)
The official migration guide covers the edge cases. While you are upgrading, also make sure you are on the latest 16.3 patch. The Next.js team now ships security fixes on a published schedule, so staying current is easy to plan for.
Wrapping up
The Next.js 16 caching model comes down to a few ideas:
Everything is fresh by default. Add
'use cache'where you want caching.Set a lifetime with
cacheLifeand a name withcacheTag.Clear with
updateTagafter user actions andrevalidateTagfor background updates.Read cookies and headers outside cached code, then pass the values in.
Start small. Pick one slow query in your app, cache it, tag it, and clear it from the action that changes it. Once that works, the rest of the model will make sense.
If you have questions or want me to cover something next, reach out on X or LinkedIn.