Caching in Next.js used to feel like magic you could not control: some fetch calls were cached, some were not, and nobody on the team was quite sure why. The Next.js use cache directive fixes that. Since Next.js 16, you turn on Cache Components, write 'use cache' at the top of a function or component, and you decide exactly what is cached, for how long, and how to refresh it. In this guide you will learn the whole model step by step, with runnable code for Next.js 16.3 (the current Active LTS line).
- Enable it with
cacheComponents: trueinnext.config.ts. Without it, Next.js 16 is dynamic by default. - Add
'use cache'to an async function (data-level) or component (UI-level). Arguments become part of the cache key. - Always pair it with
cacheLife('hours' | 'days' | ...)and tag it withcacheTag('posts'). - After a mutation:
updateTag()in Server Actions (user sees change now) orrevalidateTag(tag, 'max')for background refresh. - Anything that reads
cookies(),headers()or fresh data goes inside<Suspense>, not inside'use cache'.
What is the Next.js use cache directive?
'use cache' is a directive, just like 'use client' or 'use server'. You place it as the first line inside an async function, an async Server Component, or at the top of a file. Next.js then stores the return value of that function and reuses it for later calls with the same inputs.
It was introduced as the main caching primitive in Next.js 16.0 (October 2025) and, in 16.3, it also powers client-side caching, Partial Prefetching and “Instant Navigations”. The big idea is simple: nothing is cached unless you say so. That is the opposite of the old App Router behaviour in Next.js 13–14, where fetch was cached implicitly.
Explicit
Only functions marked with 'use cache' are cached. No hidden magic.
Composable
Cache a single DB query, a component, or an entire page.
data · UI · pageControllable
Set lifetimes with profiles and invalidate precisely with tags.
cacheLife · cacheTagFast by design
Cached output joins the prerendered static shell, served from a CDN.
Partial Prerendering'use cache' works only in the App Router and only when Cache Components is enabled. If you are still on the Pages Router, nothing in this article applies to pages/.The mental model: a static shell with dynamic holes
With Cache Components, Next.js renders each route at build time and sorts every piece of UI into one of three buckets:
- Static – pure code, module imports, synchronous reads. Prerendered automatically.
- Cached – anything inside
'use cache'. The result is prerendered too and becomes part of the static shell. - Dynamic – uncached fetches,
cookies(),headers(),searchParams. These must sit inside<Suspense>. The fallback ships in the shell; the real content streams in at request time.
This approach is called Partial Prerendering (PPR), and it is the default behaviour once Cache Components is on. The diagram below shows how one product page is split.
Step 1: Enable Cache Components
Create a new app (or upgrade an existing one) to the latest Next.js 16 release:
# new project
npx create-next-app@latest my-cache-app
cd my-cache-app
# or upgrade an existing app
npm install next@latest react@latest react-dom@latest
Then switch the feature on in your config file:
next.config.tsimport type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
partialPrefetching: true next to it to try Instant Navigations. Start with cacheComponents alone, get your routes clean, then add prefetching.Once enabled, old route segment options such as export const dynamic = 'force-static', export const revalidate = 60 and fetchCache are no longer used. Remove them and express the same intent with 'use cache' and cacheLife. Next.js will tell you in the terminal if any are left.
Step 2: Data-level caching
The easiest place to start is a data function. Here we cache a list of products coming from a database (or any API):
app/lib/data.tsimport { cacheLife, cacheTag } from 'next/cache'
import { db } from './db'
export async function getProducts() {
'use cache'
cacheLife('hours') // fresh for about an hour
cacheTag('products') // label so we can invalidate later
return db.product.findMany({ orderBy: { createdAt: 'desc' } })
}
export async function getProduct(id: string) {
'use cache'
cacheLife('hours')
cacheTag('products', `product-${id}`)
return db.product.findUnique({ where: { id } })
}
Notice getProduct(id): the id argument is automatically part of the cache key. getProduct('1') and getProduct('2') get separate entries. Values captured from the surrounding scope are included in the key too.
Step 3: UI-level caching with Suspense
You can also cache a whole component. Below is a complete page that mixes all three buckets: static header, a cached post list, and a per-user block that reads cookies.
app/blog/page.tsximport { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'
export default function BlogPage() {
return (
<>
{/* 1. Static: prerendered automatically */}
<header>
<h1>DevDojo Blog</h1>
</header>
{/* 2. Cached: becomes part of the static shell */}
<PostList />
{/* 3. Dynamic: fallback in shell, content streams per request */}
<Suspense fallback={<p>Loading your preferences…</p>}>
<UserPreferences />
</Suspense>
</>
)
}
type Post = { id: number; title: string }
async function PostList() {
'use cache'
cacheLife('days')
cacheTag('posts')
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
const posts: Post[] = await res.json()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
async function UserPreferences() {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return <aside>Your theme: {theme}</aside>
}
Run npm run build and you will see this route marked as partially prerendered. The header and post list are in the HTML immediately; only <UserPreferences /> waits for the request.
cookies() call opted the entire route into dynamic rendering. Now the <Suspense> boundary contains it, so the rest of the page stays static.Step 4: Control freshness with cacheLife
cacheLife() tells Next.js how long a cached result stays good. Each profile has three timings:
- stale – how long the browser can reuse the data without asking the server.
- revalidate – after this, the next request gets the cached copy and triggers a background refresh (stale-while-revalidate).
- expire – after this with no traffic, the next request waits for fresh data.
These are the built-in profiles in Next.js 16.3:
| Profile | Good for | stale | revalidate | expire |
|---|---|---|---|---|
default | Used when you don’t call cacheLife | 5 min | 15 min | never |
seconds | Live scores, prices | 30 s | 1 s | 1 min |
minutes | Social feeds, news | 5 min | 1 min | 1 hour |
hours | Inventory, weather | 5 min | 1 hour | 1 day |
days | Blog posts, articles | 5 min | 1 day | 1 week |
weeks | Podcasts, newsletters | 5 min | 1 week | 30 days |
max | Legal pages, CMS content + webhook | 5 min | 30 days | 1 year |
You can also define your own named profiles in the config, or pass an inline object for a one-off case:
next.config.tsimport type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
editorial: {
stale: 600, // 10 minutes
revalidate: 3600, // 1 hour
expire: 86400, // 1 day
},
},
}
export default nextConfig
// anywhere inside a 'use cache' scope
cacheLife('editorial')
// or inline (seconds)
cacheLife({ stale: 60, revalidate: 300, expire: 3600 })
seconds profile, revalidate: 0, or an expire under 5 minutes is excluded from the prerendered shell. Wrap such components in <Suspense>. Also, if you nest a short-lived cache inside another 'use cache' that has no explicit cacheLife, Next.js throws during prerender, which is one more reason to always set it.Step 5: cacheTag, updateTag and revalidateTag
Time-based refresh is fine for many pages, but when a user creates or edits something you want to refresh now. That is what tags are for. You tag cached data with cacheTag(), then invalidate by tag after a change.
updateTag: the user should see their change immediately
Use updateTag inside a Server Action when the person who made the change must see it on the next screen (read-your-own-writes):
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/app/lib/db'
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: {
title: String(formData.get('title')),
content: String(formData.get('content')),
},
})
updateTag('posts') // expire every entry tagged 'posts' right now
redirect(`/blog/${post.id}`)
}
app/blog/new/page.tsx
import { createPost } from '../actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Write something…" required />
<button type="submit">Publish</button>
</form>
)
}
revalidateTag: refresh in the background
Use revalidateTag when a small delay is fine, for example when your CMS sends a webhook. It works in Server Actions and Route Handlers. In Next.js 16 the second argument (a cacheLife profile) is required; 'max' is the recommended value.
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-webhook-secret')
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ ok: false }, { status: 401 })
}
const { tag } = await request.json() // e.g. { "tag": "posts" }
revalidateTag(tag, 'max') // stale-while-revalidate
return Response.json({ ok: true, revalidated: tag })
}
Test it locally with curl:
curl -X POST http://localhost:3000/api/revalidate \
-H "Content-Type: application/json" \
-H "x-webhook-secret: $WEBHOOK_SECRET" \
-d '{"tag":"posts"}'
updateTag | revalidateTag | revalidatePath | |
|---|---|---|---|
| Where | Server Actions only | Server Actions + Route Handlers | Server Actions + Route Handlers |
| Behaviour | Expires immediately | Stale-while-revalidate | Everything for one path |
| Use when | User must see own change | CMS/webhook, slight delay OK | You don’t know the tags |
revalidatePath('/') can throw away far more cache than you intended.Step 6: Cookies, headers and per-user data
You cannot call cookies(), headers() or read searchParams directly inside a plain 'use cache' function, because the result would be shared between users. The recommended pattern is: read the runtime value outside, then pass it in as an argument. The argument becomes part of the cache key.
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
export default function ProfilePage() {
return (
<Suspense fallback={<p>Loading profile…</p>}>
<ProfileContent />
</Suspense>
)
}
// Not cached: reads the cookie at request time
async function ProfileContent() {
const userId = (await cookies()).get('userId')?.value ?? 'guest'
return <CachedProfile userId={userId} />
}
// Cached per userId
async function CachedProfile({ userId }: { userId: string }) {
'use cache'
cacheLife('minutes')
const res = await fetch(`https://jsonplaceholder.typicode.com/users/1?u=${userId}`)
const user = await res.json()
return <h2>Hello, {user.name}</h2>
}
Next.js 16 also ships two variants of the directive for special cases:
'use cache: private'– can read cookies/headers directly; the result is cached only in that user’s browser (great for prefetching personalised UI).'use cache: remote'– stores entries in a shared, durable cache handler instead of per-instance memory. Useful on serverless platforms, where in-memory entries don’t survive between requests.
Need a truly unique value per request, like a request ID or the current time? Call connection() first and wrap the component in Suspense:
import { connection } from 'next/server'
async function RequestId() {
await connection() // defer to request time
return <p>Request ID: {crypto.randomUUID()}</p>
}
If you are new to async patterns like these, our guide on mastering JavaScript Promises is a good refresher, since Server Components are just async functions under the hood.
Interactive: which caching tool should I use?
Click a tab to match your data to the right pattern.
Public content: blog posts, product catalogue, docs
Use 'use cache' + cacheLife('days') (or 'max' for CMS content) + cacheTag(). Trigger revalidateTag(tag, 'max') from a webhook when content changes. Result: served from the static shell, refreshed only when needed.
User-generated content: comments, posts, settings
Cache reads with 'use cache' + cacheTag('comments'). In the Server Action that saves the data, call updateTag('comments') so the author sees the change on the very next render.
Personalised UI: dashboard, cart, profile
Read cookies() inside a component wrapped in <Suspense>, then pass the user ID to a cached child (cache key per user). Or use 'use cache: private' for browser-only caching that can be prefetched.
Real-time data: stock prices, live scores, request IDs
Don’t cache at all, or use cacheLife('seconds'). Put the component inside <Suspense> with a good fallback. Use await connection() before Date.now(), Math.random() or crypto.randomUUID().
Common errors and fixes
| Error / symptom | Why it happens | Fix |
|---|---|---|
| Uncached data was accessed outside of <Suspense> (shown as a “blocking-route” insight in 16.3) | A component fetches fresh data or reads cookies/headers with no Suspense boundary above it. | Wrap it in <Suspense fallback={...}>, or cache it with 'use cache'. |
Error using cookies() / headers() inside 'use cache' | Runtime APIs can’t run in a shared cache scope. | Read the value outside and pass it as an argument, or use 'use cache: private'. |
| “blocking-prerender-current-time” / “-random” / “-crypto” | Date.now(), Math.random() or crypto.randomUUID() during prerender. | await connection() + Suspense, or move it inside 'use cache' if one shared value is fine. |
Error about export const revalidate / dynamic | Route segment configs are replaced by Cache Components. | Delete them; use cacheLife() in the cached scope instead. |
| Data never updates after saving | No tag on the cached function, or tag names don’t match. | Add cacheTag('x') and call updateTag('x') with the exact same string. |
| Error during prerender about nested short-lived cache | A cacheLife('seconds') component is used inside a 'use cache' scope with no explicit cacheLife. | Add an explicit cacheLife() to the outer scope. |
| Cache seems to reset on every request (serverless) | Default runtime cache is in-memory per instance. | Use 'use cache: remote' with a configured cache handler for high hit-rate data. |
| “Only plain objects can be passed…” style serialization errors | Passing a class instance or function as a cached argument / return value. | Pass IDs or plain objects; return plain JSON-like data. |
Best practices
- Always call
cacheLife()in every'use cache'scope, right at the top. It makes behaviour obvious to the next developer. - Tag generously, invalidate narrowly. Use a broad tag (
'posts') and a specific one (`post-${id}`) on the same entry. - Push dynamic work deep down the tree. The lower your
cookies()or uncached fetch sits, the more of the page stays in the static shell. - Keep cached return values small. Return only the fields the UI needs, not whole ORM objects.
- Don’t cache per-user data in a shared scope unless the user ID is an argument. Never leak one user’s data to another.
- Use
updateTagfor forms,revalidateTag(tag, 'max')for webhooks. Avoid the old single-argumentrevalidateTag(tag); it is deprecated in Next.js 16. - Write good Suspense fallbacks. Skeletons that match the final layout prevent layout shift and improve Core Web Vitals.
- Check the dev overlay. In 16.3, Instant Insights and the Navigation Inspector show exactly which parts are blocking.
Migrating from the old caching model
| Old (Next.js 14/15) | New (Cache Components) |
|---|---|
fetch(url, { next: { revalidate: 3600 } }) | Wrap the fetch in a 'use cache' function with cacheLife('hours') |
fetch(url, { next: { tags: ['posts'] } }) | cacheTag('posts') inside the cached function |
unstable_cache(fn, keys, opts) | 'use cache' directive inside fn |
export const revalidate = 60 | cacheLife({ revalidate: 60 }) |
export const dynamic = 'force-dynamic' | Default behaviour; just use Suspense |
The official Next.js caching documentation and the cacheLife API reference have more detail and a step-by-step migration guide.
FAQ
Is “use cache” stable in Next.js 16?
Yes. Cache Components and the 'use cache' directive shipped as a stable, opt-in feature in Next.js 16.0 and were extended in 16.3. You enable it with cacheComponents: true.
Do I still need to cache fetch() calls?
With Cache Components, fetch is not cached by default. If you want a fetch result reused, put it inside a function or component marked with 'use cache'.
What is the difference between updateTag and revalidateTag?
updateTag expires the cache immediately and works only in Server Actions, so the user sees their own change. revalidateTag(tag, 'max') serves stale data while refreshing in the background and also works in Route Handlers.
Can I use “use cache” in a Client Component?
No. It runs on the server. You can cache a Server Component and render Client Components inside it, or pass cached data down to them as props.
Where is the cache stored?
Prerendered results live in the static shell (on disk or your host’s CDN). Runtime entries live in memory per server instance by default, 'use cache: remote' uses a shared cache handler, and 'use cache: private' lives only in the browser. All caches are scoped to one deployment.
Does this work when self-hosting on a VPS or Docker?
Yes. On a long-running Node.js server the in-memory cache persists between requests, so plain 'use cache' works well. For multiple instances, configure a shared cache handler.
Conclusion
The Next.js use cache directive turns caching from guesswork into a clear, readable decision in your code. Turn on cacheComponents, mark slow and shareable work with 'use cache', give it a lifetime with cacheLife, label it with cacheTag, and put everything request-specific behind <Suspense>. Do that, and your pages load from a static shell instantly while still showing fresh data exactly where it matters.
A good next step: take one slow page in your project, split it into static, cached and dynamic parts like Figure 1, and measure the difference. And if you are also exploring AI tooling, check out our hands-on guide on how to build an MCP server in TypeScript.
New posts every day on DevDojo
Practical tutorials on React, Next.js, Node.js, Python, AI and interview prep, written in simple English.
Bookmark DevDojo and drop your Cache Components questions in the comments below.
Explore more tutorials →