Concept
The beginner framing: caching stores the result of expensive work, a database query, a slow API call, a whole rendered page, so a future request for the same thing can be served instantly instead of redoing that work.
The precise mental model: Cache Components (cacheComponents: true in next.config.ts) is the current caching model, built around one directive: "use cache", placed at the top of a file, a component, or a function, marking its return value as cacheable.
// Function-level, caches just this function's return value
import { cacheLife } from "next/cache";
export async function getProducts() {
"use cache";
cacheLife("hours"); // stale: 5m, revalidate: 1h, expire: 1d
return db.query("SELECT * FROM products");
}// Component-level (UI-level caching), caches the rendered output
export async function BlogPosts() {
"use cache";
const posts = await db.query("SELECT * FROM posts");
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}import { cacheLife } from 'next/cache';export async function getProducts() {'use cache';cacheLife('hours'); // stale: 5m, revalidate: 1h, expire: 1dreturn db.query('SELECT * FROM products');}
First request for getProducts(). No cache entry exists under this function's cache key (Build ID + Function ID + serialized arguments), so Next.js must actually execute the function body.
Cache keys: what actually makes two calls "the same"
A cache entry's key is built from: the Build ID (unique per build, a redeploy invalidates everything), the Function ID (a hash of the function's location and signature), its serialized arguments, and, this is the detail that surprises people, any variables captured from an outer scope, which are automatically bound in as if they were arguments too.
async function Component({ userId }: { userId: string }) {
const getData = async (filter: string) => {
"use cache";
// cache key includes BOTH userId (captured from closure) AND filter (an argument)
return fetch(`/api/users/${userId}/data?filter=${filter}`);
};
return getData("active");
}Revalidation: cacheLife, cacheTag, updateTag, revalidateTag
cacheLife sets how long a cached entry stays valid, via built-in profiles or a custom object:
| Profile | stale | revalidate | expire |
|---|---|---|---|
seconds | 0 | 1s | 60s |
minutes | 5m | 1m | 1h |
hours |
cacheTag lets you invalidate on demand instead of (or alongside) waiting for time to pass:
export async function getProducts() {'use cache'; cacheTag('products');return db.query('SELECT * FROM products');}export async function getFeaturedProducts() {'use cache'; cacheTag('products'); // SAME tagreturn db.query('...WHERE featured = true');}
Two different cached functions, tagged with the same 'products' tag. They're cached independently, but both can be invalidated together by that shared tag.
updateTag | revalidateTag | |
|---|---|---|
| Where | Server Actions only | Server Actions and Route Handlers |
| Behavior | Immediately expires the cache | Stale-while-revalidate (serves stale, refreshes in background) |
| Use case | Read-your-own-writes | Background refresh, slight delay acceptable |
revalidatePath invalidates everything cached for a specific route, useful when you don't know (or don't want to enumerate) every tag involved, though tag-based invalidation is generally more precise.
How rendering actually works: a static shell around dynamic holes
<Layout> {/* cached, part of the shell */}<ProductInfo /> {/* 'use cache', part of the shell */}<Suspense fallback={<Skeleton />}><LiveInventory /> {/* reads runtime data, a DYNAMIC HOLE */}</Suspense></Layout>
At build time (or first request), Next.js prerenders everything it safely can into a static shell. Anything reading runtime-only data (cookies, live stock counts) and wrapped in Suspense becomes a 'dynamic hole', deliberately excluded from the shell.
Cache Components' rendering model prerenders everything it safely can, layouts, "use cache" content, into a static shell, served instantly. Anything reading runtime APIs (cookies(), headers(), searchParams, params without generateStaticParams) must be wrapped in <Suspense>, becoming a dynamic hole that streams in separately, exactly as covered in Data Fetching and Suspense.
Non-deterministic operations (Math.random(), Date.now(), crypto.randomUUID()) need explicit handling, either await connection() before them plus a <Suspense> wrap (a fresh value every request), or deliberately cache the result (the same value for everyone, until revalidation).
The previous model, still asked about in interviews
// Request Memoization, same render passasync function Header() { return fetch('/api/user'); }async function Sidebar() { return fetch('/api/user'); } // SAME call
Layer 1, Request Memoization: identical fetch() calls within the SAME render pass are automatically deduplicated, calling it from multiple components costs one network request, not two.
Before Cache Components, caching was spread across four separate, implicit layers, the model most existing tutorials and interview questions still describe. It's worth fluency in both.