Next.js 16 Caching Explained: use cache, cacheLife, cacheTag and Revalidation
Next.js 16 caching explained: enable Cache Components, use the use cache directive, set cacheLife profiles, tag data with cacheTag and revalidate on demand.
By Harsh Thummar 12–14 min read

Caching has always been the part of the Next.js App Router that confused developers the most. Pages were cached when you didn't expect it, fetch behaved differently depending on where you called it, and a single cookies() call could flip an entire route to dynamic.
Next.js 16 caching takes a different approach. With Cache Components enabled, nothing is cached unless you ask for it. You mark what should be cached with the "use cache" directive, give it a lifetime with cacheLife, label it with cacheTag, and invalidate it when your data changes.
In this guide, I'll walk through how the new model works, how it fits with Suspense and Partial Prerendering, how to cache both fetch calls and database queries, and what to do if your team isn't ready to migrate yet. Every API here is checked against the official Next.js 16 documentation.
Why Caching Changed in Next.js 16
In earlier App Router versions, caching was largely implicit. Next.js tried to guess what could be static, and you used route segment config like export const dynamic and export const revalidate to correct those guesses.
Next.js 16 introduces Cache Components, an opt-in model where:
- Data fetching is dynamic by default
- You explicitly choose what to cache at the file, component, or function level
- Next.js prerenders a static shell and streams dynamic parts into it
You enable it with a single flag in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;This one flag replaces the older experimental.ppr, experimental.useCache and experimental.dynamicIO flags. It also makes Partial Prerendering (PPR) the default rendering behavior in the App Router.
Two things to know first:
- Cache Components requires the Node.js runtime. Routes using the deprecated
runtime = 'edge'export need to be migrated. - Once enabled, route segments that still export
dynamic,revalidate, orfetchCachewill error. Those configs are replaced byuse cacheandcacheLife.
The "use cache" Directive
The "use cache" directive caches the return value of an async function or component. Functions and components that use it must be async.
You can apply it at three levels.
1. Function level (data caching)
This is the level I reach for most often. Cache the data, not the whole page:
// lib/data.ts
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";
export async function getProducts() {
"use cache";
cacheLife("hours");
cacheTag("products");
return db.product.findMany({ orderBy: { createdAt: "desc" } });
}Any component that calls getProducts() now shares the same cache entry.
2. Component level (UI caching)
You can also cache an entire Server Component, including its rendered output:
// components/LatestPosts.tsx
import { cacheLife } from "next/cache";
type Post = { id: string; title: string };
export async function LatestPosts() {
"use cache";
cacheLife("days");
const res = await fetch("https://api.example.com/posts");
const posts: Post[] = await res.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}3. File level (every export)
Putting the directive at the top of a file caches every exported function in it. Every export must be async:
// lib/reports.ts
"use cache";
import { db } from "@/lib/db";
export async function getMonthlyTotals(accountId: string) {
return db.order.aggregate({ where: { accountId }, _sum: { amount: true } });
}
export async function getTopProducts() {
return db.product.findMany({ orderBy: { sales: "desc" }, take: 10 });
}A page.tsx or layout.tsx is a module too, so "use cache" at the top of one caches that route segment. A cached layout does not cache the children it renders. They pass through untouched.
How cache keys work
You don't write cache keys by hand. Next.js builds them from:
- The build ID (a new deployment starts with a fresh cache)
- A hash of the function's location and signature
- The serializable arguments or props
- Any variables captured from outer scopes (closures)
So getProduct("a") and getProduct("b") get separate entries automatically.
cacheLife: Controlling How Long Data Lives
cacheLife sets the lifetime of a cached scope. It must be called inside a use cache scope. Calling it at module scope throws an error.
The built-in profiles cover most needs:
seconds: stale 30s, revalidate 1s, expire 1 minute (near real-time data)minutes: stale 5m, revalidate 1m, expire 1 hour (feeds, news)hours: stale 5m, revalidate 1h, expire 1 day (inventory, pricing)days: stale 5m, revalidate 1d, expire 1 week (blog posts, articles)weeks: stale 5m, revalidate 1w, expire 30 days (newsletters)max: stale 5m, revalidate 30d, expire 1 year (rarely changing content)default: stale 5m, revalidate 15m, never expires (used when you call nocacheLife)
The three properties mean different things:
stale: how long the client router can reuse the data without asking the server. The client enforces a minimum of 30 seconds.revalidate: after this, the next request gets the cached version while the server refreshes it in the background.expire: after this with no traffic, the next request waits for fresh content.
Custom profiles
If the presets don't match your content, define your own in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
catalog: {
stale: 300, // 5 minutes on the client
revalidate: 60 * 30, // refresh every 30 minutes
expire: 60 * 60 * 24, // hard expiry after 1 day
},
},
};
export default nextConfig;Then use it by name with cacheLife("catalog"). You can also pass an inline object like cacheLife({ revalidate: 900 }) for one-off cases. Omitted properties inherit from the default profile, and expire must be longer than revalidate.
Short lifetimes and prerendering
Lifetime affects where cached content can live. A scope with revalidate: 0 or an expire under 5 minutes is excluded from the prerender and becomes a dynamic hole resolved at request time. Of the presets, only seconds falls into this category. Wrap those components in <Suspense>.
My recommendation: call cacheLife in every use cache scope, even if you want the default. The docs recommend the same, and it makes the behavior obvious to whoever reads the code next.
cacheTag, revalidateTag and updateTag
Time-based expiry is only half the story. When a user edits a product, you don't want to wait an hour for the change to appear.
Tagging cached data
cacheTag attaches one or more string tags to a cache entry. You can tag with dynamic values too:
// lib/products.ts
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";
export async function getProduct(id: string) {
"use cache";
cacheLife("hours");
cacheTag("products", `product-${id}`);
const product = await db.product.findUnique({ where: { id } });
return product;
}Tags are case-sensitive and each one can be up to 256 characters.
updateTag: read-your-own-writes
updateTag(tag) immediately expires the tagged data. The next request waits for fresh data instead of seeing stale content. It can only be called from a Server Action:
// app/products/actions.ts
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function updateProduct(id: string, formData: FormData) {
const name = String(formData.get("name") ?? "");
await db.product.update({ where: { id }, data: { name } });
updateTag(`product-${id}`);
updateTag("products");
redirect(`/products/${id}`);
}Use it for forms and admin panels, where the person who made the change expects to see it right away. If you're building forms like this, pair it with input validation as covered in the secure Next.js contact form guide.
revalidateTag: stale-while-revalidate
revalidateTag(tag, profile) marks tagged data as stale. The next visitor still gets the cached version while fresh data loads in the background. It works in Server Actions and Route Handlers.
In Next.js 16 the second argument is required. The docs recommend "max":
// app/api/revalidate/route.ts
import type { NextRequest } from "next/server";
import { revalidateTag } from "next/cache";
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-revalidate-secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { tag } = (await request.json()) as { tag?: string };
if (!tag) {
return Response.json({ error: "Missing tag" }, { status: 400 });
}
revalidateTag(tag, "max");
return Response.json({ revalidated: true });
}This pattern is perfect for CMS webhooks. Give the content a long cacheLife like "max", and let the webhook invalidate it only when something actually changes.
If a webhook needs the data gone immediately and you can't use updateTag (because you're in a Route Handler), pass { expire: 0 }:
revalidateTag("products", { expire: 0 });The single-argument formrevalidateTag(tag)is deprecated in Next.js 16. UseupdateTagin Server Actions orrevalidateTag(tag, "max")elsewhere.
revalidatePath
revalidatePath(path, type?) invalidates everything for a route path. It works the same with or without Cache Components:
import { revalidatePath } from "next/cache";
revalidatePath("/blog/my-first-post"); // one specific page
revalidatePath("/blog/[slug]", "page"); // every page matching the pattern
revalidatePath("/", "layout"); // everythingWhen the path contains a dynamic segment like [slug], the type argument is required.
I prefer tags over paths. A product might appear on several pages, and one updateTag("products") covers all of them, while revalidatePath only refreshes the path you name.
Dynamic Data, Suspense and Partial Prerendering
This is where Next.js 16 caching really pays off. With Cache Components, a single route can mix three kinds of content:
- Static content: plain JSX and module imports, prerendered automatically
- Cached content: anything inside
use cache, included in the static shell - Dynamic content: uncached data or runtime APIs, streamed in behind a
<Suspense>fallback
// app/shop/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { getProducts } from "@/lib/data";
export default function ShopPage() {
return (
<>
<h1>Shop</h1>
{/* Cached: part of the static shell */}
<ProductGrid />
{/* Runtime data: streams at request time */}
<Suspense fallback={<p>Loading your cart...</p>}>
<CartSummary />
</Suspense>
</>
);
}
async function ProductGrid() {
const products = await getProducts();
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
async function CartSummary() {
const cartId = (await cookies()).get("cartId")?.value;
if (!cartId) return <p>Your cart is empty.</p>;
const res = await fetch(`https://api.example.com/cart/${cartId}`);
const cart: { count: number } = await res.json();
return <p>{cart.count} items in your cart</p>;
}At build time, the heading, the product grid and the cart fallback become the static shell, served instantly. The cart streams in per request. Reading cookies() no longer turns the whole route dynamic like it did in the old model.
If you access uncached data or runtime APIs without a Suspense boundary, the dev overlay shows a blocking-route insight telling you to cache it or wrap it in <Suspense>.
Passing runtime values into cached functions
Cached scopes cannot call cookies(), headers() or read searchParams, and that restriction follows the call stack into helpers. Read the value outside and pass it in as an argument:
import { cookies } from "next/headers";
import { cacheLife } from "next/cache";
async function Recommendations() {
const region = (await cookies()).get("region")?.value ?? "us";
return <RegionalDeals region={region} />;
}
async function RegionalDeals({ region }: { region: string }) {
"use cache";
cacheLife("hours");
const res = await fetch(`https://api.example.com/deals?region=${region}`);
const deals: { id: string; title: string }[] = await res.json();
return (
<ul>
{deals.map((d) => (
<li key={d.id}>{d.title}</li>
))}
</ul>
);
}region becomes part of the cache key, so each region gets its own entry. Render <Recommendations /> inside a <Suspense> boundary.
For values like Date.now() or crypto.randomUUID() that should differ per request, call await connection() (from next/server) first and wrap the component in Suspense. To understand why these server/client boundaries matter, see React Server Components explained.
Caching fetch vs Database Queries
With Cache Components, the rule is the same for every data source: wrap it in a use cache function. Fetches inside a cached scope are cached automatically, so you no longer need cache: 'force-cache' or next: { revalidate, tags }.
For database queries (MongoDB, Prisma, Drizzle), watch the return value. Return values must be serializable. Plain objects, arrays, primitives and Date are fine, but class instances are not. A MongoDB ObjectId is a class instance, so convert it first:
// lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";
import { ObjectId } from "mongodb";
import { getDb } from "@/lib/mongo";
export async function getPostById(id: string) {
"use cache";
cacheLife("days");
cacheTag(`post-${id}`);
const db = await getDb();
const doc = await db.collection("posts").findOne({ _id: new ObjectId(id) });
if (!doc) return null;
return {
id: doc._id.toString(),
title: doc.title as string,
body: doc.body as string,
publishedAt: doc.publishedAt as Date,
};
}If you're modelling the collections themselves, the MongoDB schema design guide pairs well with this.
Where cached entries are stored
By default, use cache stores entries in memory. On a self-hosted Node.js server, they persist across requests. On serverless platforms, instances are short-lived, so runtime entries may not be reused between requests (build-time caching still works normally). Entries also don't carry over to a new deployment, because the build ID is part of the key.
If you need durable, shared storage, look at "use cache: remote" with a configured cache handler. There is also "use cache: private" for cases where you can't move cookies() or headers() out of the cached function. Its results are cached only in the browser's memory, never on the server.
Caching Without Cache Components (the Older Model)
Not every team will flip cacheComponents: true on day one, and that's fine. The previous model still works in Next.js 16.
The key points:
fetchis not cached by default. Opt in withcache: "force-cache".- Time-based revalidation uses
next: { revalidate: 3600 }orexport const revalidate = 3600in a page or layout. - Tag requests with
next: { tags: ["products"] }. - For non-
fetchwork like database queries, useunstable_cache.
// lib/data.ts (previous model)
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
export const getCachedProducts = unstable_cache(
async () => db.product.findMany(),
["products"], // cache key prefix
{ tags: ["products"], revalidate: 3600 }
);
export async function getPricing() {
const res = await fetch("https://api.example.com/pricing", {
cache: "force-cache",
next: { revalidate: 3600, tags: ["pricing"] },
});
return res.json();
}revalidateTag(tag, "max"), revalidatePath and updateTag all work in this model too. One genuine difference: the fetch Data Cache and unstable_cache persist across deployments and serverless instances, while use cache` does not by default.
When you do migrate, the mapping is straightforward: revalidate becomes cacheLife, next.tags becomes cacheTag, unstable_cache becomes "use cache", and dynamic = "force-dynamic" can simply be deleted because everything is dynamic by default.
Common Next.js 16 Caching Mistakes
- Forgetting to enable the flag.
use cache,cacheLifeandcacheTagneedcacheComponents: trueinnext.config.ts. - Calling
cookies()orheaders()insideuse cache. This throws. Read the value outside and pass it as an argument. - Returning class instances. MongoDB
ObjectId, ORM model instances andURLobjects aren't serializable. Map to plain objects. - Using single-argument
revalidateTag. It's deprecated. UseupdateTagin Server Actions orrevalidateTag(tag, "max")elsewhere. - Calling
updateTagin a Route Handler. It only works in Server Actions and throws anywhere else. - Leaving old segment config behind.
dynamic,revalidateandfetchCacheexports error once Cache Components is on. - Awaiting
paramsorcookies()high in a layout. This blocks the whole subtree from the static shell. Push dynamic access down into small components wrapped in<Suspense>. - Assuming in-memory cache survives on serverless. It often doesn't at runtime. Use
"use cache: remote"or a cache handler if hit rate matters.
When builds hang with "Filling a cache during prerender timed out", you are usually passing a promise for runtime or uncached data (like params or cookies()) into a cached function. For deeper debugging, run with NEXT_PRIVATE_DEBUG_CACHE=1 for verbose cache logs.
Next.js 16 Caching Checklist
cacheComponents: trueset innext.config.ts(or a deliberate decision to stay on the previous model)- Old
dynamic,revalidateandfetchCachesegment exports removed - Every
use cachescope has an explicitcacheLife - Shared data tagged with
cacheTagusing stable names likeproductsandproduct-${id} - Server Action mutations call
updateTagfor read-your-own-writes - Webhooks and Route Handlers use
revalidateTag(tag, "max")and verify a secret - Runtime APIs read outside cached scopes and passed in as arguments
- Dynamic sections wrapped in
<Suspense>with meaningful fallbacks - Database results mapped to plain, serializable objects
- Storage strategy chosen for your host (in-memory vs
use cache: remote)
My Recommendation
For new Next.js 16 projects, I enable Cache Components from the start. The explicit model is easier to reason about than the old heuristics, and the static shell plus streaming gives fast first loads without much extra work.
My default pattern is simple: cache at the data function level with use cache, cacheLife and cacheTag, keep pages thin, wrap anything user-specific in <Suspense>, and use updateTag after every mutation. That covers most dashboards and content sites I build, including SEO-focused platforms like the NutriLeft case study.
For an existing large app, I'd migrate route by route rather than all at once, following the dev overlay insights. Caching is only one piece of speed, so pair it with the techniques in Next.js 16 performance optimization.
Related Next.js Guides
- Next.js 16 Performance Optimization →Practical techniques for faster pages and better Core Web Vitals.
- React Server Components Explained →How server and client boundaries work in Next.js 16.
- Next.js 16 Authentication →Sessions, protected routes and server-side authorization.
- React 19 Hooks Explained →useActionState and useOptimistic for responsive forms after mutations.
- Secure Next.js Contact Form →Server Actions with Zod validation and safe error handling.
Frequently Asked Questions
How do I enable caching in Next.js 16?
Set cacheComponents: true in next.config.ts. Then add the "use cache" directive to the async functions, components or files you want cached, and set a lifetime with cacheLife.
What is the difference between updateTag and revalidateTag?
updateTag immediately expires tagged data so the next request waits for fresh content, and it only works in Server Actions. revalidateTag(tag, "max") serves stale content while refreshing in the background, and works in Server Actions and Route Handlers.
Is fetch cached by default in Next.js 16?
No. Without Cache Components you opt in with cache: "force-cache". With Cache Components, a fetch is cached only when it runs inside a use cache scope.
Can I use cookies() inside "use cache"?
No. Runtime APIs like cookies(), headers() and searchParams aren't allowed in cached scopes. Read them outside and pass the values as arguments, which also makes them part of the cache key.
What happens if I don't call cacheLife?
The default profile applies: 5 minutes stale, 15 minutes revalidate, and no expiry. It's still better to set it explicitly so the behavior is clear.
Do I have to migrate to Cache Components?
No. The previous model with fetch cache options, unstable_cache and route segment config still works in Next.js 16. Cache Components is opt-in.
Conclusion
Next.js 16 caching replaces guesswork with explicit intent. You decide what is cached with "use cache", how long it lives with cacheLife, and how it's invalidated with cacheTag, updateTag, revalidateTag and revalidatePath.
Combined with Suspense and Partial Prerendering, that gives you a fast static shell with fresh, personalised content streaming in, without making the whole route dynamic.
Start small: enable the flag, cache your most-read data functions, tag them, and let the dev overlay guide the rest.