04 · Caching & Revalidation¶
Caching is where Next.js has changed most, and where most confusion comes from. Advice written for version 14 is actively wrong for version 15, and version 16 introduced an opt-in model that works differently again. This lesson gives you a mental model that survives those changes, then the concrete APIs for Next.js 16.
The durable mental model¶
Whatever the version, there are only a few questions:
- What is cached? A data result (one query or
fetch), or rendered UI (a page, a component). - Where? On the server (shared by all users), in the browser's router cache (per tab), or in a CDN in front of your server.
- For how long? A lifetime, or until explicitly invalidated.
- Who invalidates it after a write? Your code — with a path, a tag, or by time.
If you can answer these for each piece of data on a page, you understand its caching, whatever the API names.
A short history (so old tutorials make sense)¶
| Version | Default for fetch |
Default for pages |
|---|---|---|
| 13–14 | Cached indefinitely (force-cache) unless opted out |
Static when possible |
| 15 | Not cached by default | Static when possible; GET Route Handlers no longer cached by default |
| 16, default config | Same as 15 ("previous model" in the docs) | Same as 15 |
16 with cacheComponents: true |
Nothing cached unless marked "use cache" |
Partial prerendering: static shell + dynamic holes |
The official docs for version 16 now describe the Cache Components model first and keep a separate guide, "Caching and Revalidating (Previous Model)", for apps that haven't enabled it. Both are supported in 16; expect Cache Components to become the recommended default over time.
Model A: the default (previous) model¶
Without cacheComponents, the unit of caching is mostly the route:
- A route with no request-time APIs is prerendered at build time (
○). Anyfetchor DB call it makes runs during the build and the result is baked in. - Time-based refresh (Incremental Static Regeneration): export
revalidatefrom a page or layout, or set it per fetch.
export const revalidate = 300; // re-generate at most every 5 minutes
export default async function Prices() {
const res = await fetch("https://api.example.com/prices");
const prices: { sku: string; amount: number }[] = await res.json();
return <ul>{prices.map((p) => <li key={p.sku}>{p.sku}: {p.amount}</li>)}</ul>;
}
- Per-request caching of a
fetchresult:fetch(url, { cache: "force-cache" })or{ next: { revalidate: 60, tags: ["prices"] } }. - Non-
fetchfunctions can be cached withunstable_cache(still prefixedunstable_in 16). - Opting out:
export const dynamic = "force-dynamic",await connection(), or any request-time API.
Model B: Cache Components (cacheComponents: true)¶
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true };
export default nextConfig;
Now caching is explicit and granular:
- Nothing async is cached by default.
"use cache"at the top of an async function or component caches its result. Its arguments (and captured variables) form the cache key.cacheLife("hours")(or"minutes","days","max", a custom profile…) sets its lifetime.cacheTag("products")labels it for invalidation.- Anything uncached or request-dependent must be inside a
<Suspense>boundary, so the rest of the page can be prerendered as a static shell (this is Partial Prerendering, which Cache Components turns on).
Here is a page we built and compiled on Next.js 16.3.6:
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts() {
"use cache";
cacheLife("hours");
cacheTag("products");
return [
{ id: 1, name: "Mug", price: 12 },
{ id: 2, name: "Shirt", price: 25 },
]; // imagine a database query here
}
import { Suspense } from "react";
import { cookies } from "next/headers";
import { getProducts } from "@/lib/catalog";
async function Greeting() {
const name = (await cookies()).get("name")?.value ?? "guest";
return <p>Welcome back, {name}</p>;
}
export default async function Home() {
const products = await getProducts();
return (
<main>
<h1>Shop</h1>
<Suspense fallback={<p>…</p>}>
<Greeting />
</Suspense>
<ul>{products.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</main>
);
}
The build's route table:
Route (app) Revalidate Expire
┌ ◐ / 1h 1d
├ ○ /_not-found
└ ○ /products
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
/ is ◐: the heading and cached product list are in the static shell (revalidated
per the hours profile), while the cookie-dependent greeting streams per request.
We then removed the <Suspense> around <Greeting /> and rebuilt. The build failed,
with a message listing three possible fixes: wrap the access in
<Suspense>, cache it with "use cache", or set export const instant = false to
allow a blocking route. Cache Components turns "this page accidentally became dynamic"
from a silent performance bug into a build error.
Invalidating after a write¶
Both models share these functions from next/cache, called in Server Actions (and,
except updateTag, in Route Handlers):
| Function | Use when |
|---|---|
revalidatePath("/posts") |
You know which page(s) show the changed data |
revalidateTag("products", "max") |
Many pages share tagged data; stale-while-revalidate is fine |
updateTag("products") |
Server Actions only: the user who made the change must see it immediately ("read your own writes") |
refresh() |
Refresh the client router's view from a Server Action without invalidating a cache |
"use server";
import { revalidateTag, updateTag } from "next/cache";
export async function editPrice(id: number, amount: number) {
// ...write to the database
updateTag("products"); // the editor sees the new price on the next render
}
export async function nightlyImport() {
// ...bulk import
revalidateTag("products", "max"); // visitors get fresh data on their next visit, served stale meanwhile
}
In Next.js 16 revalidateTag takes a second argument (a cache-life profile such as
"max", or { expire: seconds }); the one-argument form is deprecated. With "max",
the next request is served the stale version while regeneration happens in the
background.
The browser's router cache¶
Separately from the server, the client router keeps RSC payloads of visited and
prefetched routes so back/forward navigation is instant. A Server Action that
revalidates also refreshes this cache for the current user; router.refresh() forces
it from a Client Component. Other users' tabs are not notified — they see new data on
their next navigation that misses the cache, or you add real-time updates (Level 3 · 09).
How It Actually Works¶
Previous model: during the build, rendering a static route stores its HTML and
RSC payload in the server's cache with a revalidate time. When a request arrives after
that time, the server returns the stored copy and triggers a background re-render
(stale-while-revalidate); the next visitor gets the new version. revalidatePath marks
a path's entry stale, so regeneration happens on the next request — nothing is rebuilt
at the moment you call it.
Cache Components: the compiler turns each "use cache" function into a wrapper
that serialises its arguments into a key, looks the key up in a cache handler
(in-memory by default for self-hosting; configurable), and on a miss runs the function
and stores the serialised result with its lifetime and tags. During the build Next.js
renders each route, letting cached and pure work complete, and stops at <Suspense>
boundaries whose children touch request data or uncached I/O; what completed becomes
the static shell. At request time, the shell is sent immediately and the holes are
rendered and streamed. Tags are just labels stored with entries — revalidateTag
marks every entry carrying the tag stale.
Common mistakes¶
- Copying version-14 advice ("fetch is cached by default") into a 15/16 app.
- "My data never updates" — the page is static. Check the route table; add revalidation or make it dynamic deliberately.
- Caching user-specific data under a shared key. A
"use cache"function reading a cookie is not allowed for this reason ("use cache: private"exists for per-user cases); in the previous model, never cache personalised fetches withforce-cache. - Forgetting to revalidate after writes.
- Using the deprecated one-argument
revalidateTag(tag)in new code. - Mixing models: segment configs like
export const dynamicare not used with Cache Components; the migration guide maps each one to its replacement.
Exercise¶
- In a fresh app with the default model, build a page that shows
new Date().toISOString()withexport const revalidate = 10. Build, start, and reload repeatedly for 30 seconds. Describe exactly when the timestamp changes and why the first reload after 10 seconds still shows the old value. - In a second app, enable
cacheComponents, reproduce the shop example, and observe the◐symbol. Remove the<Suspense>and read the build error in full. - Add a Server Action that calls
updateTag("products")after changing a product, and verify the change is visible immediately to the user who made it.