Caching and Revalidation · Lesson 12 of 24
Caching in Next.js 16 with "use cache"
Fetching fresh data on every request is simple but wasteful: the course catalogue doesn't change between two visitors a second apart. Caching stores a result so later requests reuse it. In Next.js 16 with Cache Components, caching is explicit: nothing is cached unless you mark it with the "use cache" directive, and you choose how long it lives with cacheLife.
In this lesson
"use cache"on functions and components- Static, cached and dynamic content on one page – measured
cacheLifeprofiles and cache keys- What can't go inside a cache
Three kinds of content
| Kind | Example | How you write it |
|---|---|---|
| Static | headings, marketing text | nothing special – prerendered automatically |
| Cached | course catalogue, blog posts, prices | "use cache" + cacheLife |
| Dynamic | the user's cart, live stock, anything using cookies | inside <Suspense>, runs per request |
"use cache" in action
This page has a cached catalogue and a per-request view counter. Each function counts how often its body really runs:
// lib/catalog.ts
import { cacheLife } from "next/cache";
let runs = 0;
export async function getCatalog() {
"use cache"; // cache this function's result
cacheLife("hours"); // fresh for an hour, then revalidated
runs++;
console.log(`getCatalog() body ran (run #${runs})`);
await new Promise((r) => setTimeout(r, 300)); // an expensive query
return ["React.js", "Next.js", "TypeScript"];
}
// app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { getCatalog } from "@/lib/catalog";
let views = 0;
async function ViewCounter() {
await connection(); // dynamic: runs on every request
views++;
return <p>You are visitor #{views} since the server started</p>;
}
export default async function Home() {
const courses = await getCatalog(); // cached: part of the static shell
return (
<main>
<h1>Course catalogue</h1>
<ul>{courses.map((c) => <li key={c}>{c}</li>)}</ul>
<Suspense fallback={<p>Counting…</p>}>
<ViewCounter />
</Suspense>
</main>
);
}
Route (app) Revalidate Expire ┌ ◐ / 1h 1d ○ (Static) prerendered as static content ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content Course catalogue • React.js • Next.js • TypeScript You are visitor #1 since the server started › reload the page Course catalogue • React.js • Next.js • TypeScript You are visitor #2 since the server started › reload the page Course catalogue • React.js • Next.js • TypeScript You are visitor #3 since the server started
What happened:
- At build time,
getCatalog()ran and its result was baked into the static shell – the server log never shows it at runtime, and all three visits get the list instantly. - The visitor counter is inside
Suspensewithconnection(), so it's a dynamic hole that runs for every request: #1, #2, #3. - The route is ◐ Partial Prerender: static and cached parts are sent at once; dynamic parts stream in. The build table also shows when the cached shell will be revalidated (1h) and expire (1d), taken from
cacheLife("hours").
Caching a component
"use cache" also works at the top of an async component (or a whole page or layout) to cache its rendered output:
import { cacheLife, cacheTag } from "next/cache";
async function PopularCourses() {
"use cache";
cacheLife("days");
cacheTag("courses"); // a label for on-demand revalidation (next lesson)
const courses = await db.course.findMany({ orderBy: { enrolments: "desc" }, take: 5 });
return <ul>{courses.map((c) => <li key={c.id}>{c.title}</li>)}</ul>;
}
Putting "use cache" at the very top of a file caches every exported async function in it.
cacheLife profiles
| Profile | stale (browser) | revalidate (server) | expire |
|---|---|---|---|
default | 5 min | 15 min | never |
seconds | 30 s | 1 s | 1 min |
minutes | 5 min | 1 min | 1 hour |
hours | 5 min | 1 hour | 1 day |
days | 5 min | 1 day | 1 week |
weeks | 5 min | 1 week | 30 days |
max | 5 min | 30 days | 1 year |
- stale – how long the browser may reuse the result without asking the server.
- revalidate – after this, the next request still gets the cached result instantly while a fresh one is generated in the background (stale-while-revalidate).
- expire – after this, requests wait for fresh data.
You can also pass your own values: cacheLife({ stale: 60, revalidate: 300, expire: 3600 }) (seconds). Very short lifetimes – the seconds profile or an expiry under five minutes – aren't worth prerendering, so Next.js treats them as dynamic holes instead.
Cache keys
Arguments are part of the cache key, so each distinct input gets its own entry:
// lib/courses.ts
import { cacheLife } from "next/cache";
const data: Record<string, number> = { react: 31, nextjs: 24 };
let runs = 0;
export async function getLessonCount(slug: string) {
"use cache";
cacheLife("hours");
runs++;
console.log(`getLessonCount("${slug}") ran – total runs: ${runs}`);
return data[slug] ?? 0;
}
// app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { getLessonCount } from "@/lib/courses";
async function Counts() {
await connection(); // look the counts up at request time…
const react = await getLessonCount("react"); // …through the cache
const next = await getLessonCount("nextjs");
const reactAgain = await getLessonCount("react");
return <p>React {react}, Next.js {next}, React again {reactAgain}</p>;
}
export default function Home() {
return (
<Suspense fallback={<p>Loading…</p>}>
<Counts />
</Suspense>
);
}
React 31, Next.js 24, React again 31
server: getLessonCount("react") ran – total runs: 1
server: getLessonCount("nextjs") ran – total runs: 2
› reload the page
React 31, Next.js 24, React again 31Two different arguments, two runs – and on the second visit, none at all: both results came from the server's in-memory cache. (On serverless platforms each instance has its own memory, so runtime cache hits are less predictable; "use cache: remote" plugs in a shared store such as Redis.)
What can't go inside "use cache"
cookies(),headers()andsearchParamscan't be read inside a cached scope – a shared cache entry must not depend on one user's request. Read them outside and pass the value in as an argument (it then becomes part of the key).- Arguments and return values must be serializable.
- Never cache per-user secrets in a shared cache by accident: if the result depends on the user, the user's ID must be an argument.
Practice
Try it yourself
Cache a
getPrice(sku)function for minutes, and show prices for two products on a static page.Show solution
// lib/prices.ts import { cacheLife } from "next/cache"; const prices: Record<string, number> = { "KB-01": 3499, "MS-02": 899 }; export async function getPrice(sku: string) { "use cache"; cacheLife("minutes"); return prices[sku]; }// app/page.tsx import { getPrice } from "@/lib/prices"; export default async function Shop() { const [keyboard, mouse] = await Promise.all([getPrice("KB-01"), getPrice("MS-02")]); return ( <ul> <li>Keyboard ₹{keyboard}</li> <li>Mouse ₹{mouse}</li> </ul> ); }Route (app) Revalidate Expire ┌ ○ / 1m 1h ○ (Static) prerendered as static content • Keyboard ₹3499 • Mouse ₹899
Quick check
Is data cached by default in Next.js 16 with Cache Components?
No – only what you mark with "use cache".
What becomes part of a cached function's key?
Its arguments (and values captured from outer scopes).
Can a cached function call cookies()?
No – read cookies outside and pass the value in.
Summary
- "use cache" caches a function's or component's result; cacheLife sets how long it stays fresh.
- Static and cached content forms the prerendered shell; dynamic parts stream inside Suspense.
- Arguments form the cache key; request APIs can't be read inside a cache.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.