Next.js caching & revalidation: Cache Components
- Explain the Next.js 16 default (with
cacheComponents, nothing is cached untiluse cache) and what changed from Next 14 and 15 - Write a
use cachefunction withcacheLifeandcacheTag, readingcookies()/headers()outside the cached scope and passing values in as arguments - Choose
updateTag,revalidateTag(tag, "max"),revalidatePathorrefreshafter a write, from where it runs and who must see the change - Predict what a
cacheLifeprofile does over time:stale(client router),revalidate(background regeneration),expire(blocking), defaults and nesting - Explain why a local
next startdoesn't prove how production caches, and read cache behaviour on the real platform instead
Read
In Next.js 16, nothing is cached until you say so
FlagThis unit is about Next's own caches: results stored inside the Next.js server and the browser's router, keyed by function and arguments. The previous unit's HTTP headers are a separate layer that Next also writes.
Next.js 16 introduced Cache Components, switched on with one flag:
// next.config.ts
const nextConfig: NextConfig = { cacheComponents: true };
(Since Next 16.3 the docs also set partialPrefetching: true next to it. That option changes
what <Link> prefetches, not what the server caches, and leaving it unset logs a warning.)
With it on, data work in a page, layout or GET Route Handler (a database query, a fetch,
cookies()) runs at request time by default. Caching is opt-in, with a directive:
import { cacheLife } from "next/cache";
export async function getProducts() {
"use cache";
cacheLife("hours");
return db.product.findMany();
}
The first call runs the query and stores the result; later calls with the same arguments get
the stored result until its lifetime runs out. Arguments are part of the key, so a
getProduct(id) written the same way stores getProduct("a") and getProduct("b") as separate
entries.
If you learned Next on an older version, your habits point the other way:
| Next 14 | Next 15 | Next 16 + cacheComponents | |
|---|---|---|---|
fetch() in a Server Component | cached by default | not cached by default | not cached; wrap the work in use cache |
GET Route Handler | static by default | dynamic by default | request time, unless it touches nothing uncached (then prerendered) |
| How you opt in | fetch options, unstable_cache, export const revalidate | same, plus experimental use cache | use cache, cacheLife, cacheTag (stable) |
Two consequences:
- Old tutorials are wrong for new code.
export const revalidate = 3600andfetch(url, { next: { revalidate } })belong to the previous model. Under Cache Components the migration guide replaces segment configs likerevalidateanddynamicwithuse cachepluscacheLife. - The flag matters. A Next 16 app without
cacheComponentsstill runs the previous model (fetchnot cached by default,cache: 'force-cache',unstable_cache, segment config). Checknext.configbefore you reason about a codebase.
A cached product page with a personal cart, line by line
FlagA product page every visitor shares, a cart badge that is different for each of them, and an
admin edit that must show up at once. Next.js 16, cacheComponents: true.
// lib/products.ts
import { cacheLife, cacheTag } from "next/cache";
export async function getProduct(id: string) {
"use cache";
cacheLife("days");
cacheTag("products", `product-${id}`);
return db.product.findUnique({ where: { id } });
}
// app/products/[id]/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { getProduct, getBestSellerIds } from "@/lib/products";
export async function generateStaticParams() {
return (await getBestSellerIds()).map((id) => ({ id })); // prerender these at build
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
if (!product) notFound();
return (
<main>
<h1>{product.name}</h1>
<Suspense fallback={<span>Cart…</span>}>
<CartBadge />
</Suspense>
</main>
);
}
async function CartBadge() {
const cartId = (await cookies()).get("cart")?.value;
if (!cartId) return <span>Cart (0)</span>;
return <span>Cart ({await getCartCount(cartId)})</span>;
}
// app/admin/products/actions.ts
"use server";
import { updateTag } from "next/cache";
export async function renameProduct(id: string, name: string) {
await requireAdmin(); // actions are public endpoints
await db.product.update({ where: { id }, data: { name } });
updateTag(`product-${id}`);
}
Why each piece is the way it is:
generateStaticParams. Under Cache Components,paramscounts as request-time data unless at least one sample value is known at build. Listing the best sellers lets Next prerender those pages, and makesawait paramslegal outside<Suspense>."use cache"on the data function, not the page. The product is shared by everyone; the cart isn't. Caching at the data level lets one page mix both.idis an argument, so it's in the cache key. Each product gets its own entry. A value the function closes over from an outer scope would join the key too.cacheLife("days")makes the expected lifetime readable at the call site instead of inherited from thedefaultprofile (15-minute revalidate).- Two tags.
product-${id}invalidates one product;productsinvalidates every list and page that shows any product. cookies()lives inCartBadge, outside any cached scope, inside<Suspense>. Ause cachefunction can't callcookies()orheaders()(Next throws). Read them outside, and if a cached helper needs the value, pass it in as an argument, where it becomes part of the key.<Suspense>around the badge lets the shared part render ahead of time while the badge waits for the request. Without it, Next 16's build fails with a blocking-route error: runtime data (cookies()) was accessed outside of<Suspense>.updateTagin the Server Action, notrevalidateTag: the admin expects to see the new name on the next render.updateTagexpires the entry so the next read waits for fresh data.
What goes into the cache key, and what can't go into a cached scope
FlagA use cache entry's key is built from the build ID, a function ID (a hash of where the
function lives), and its serialisable inputs: arguments for a function, props for a component,
plus any variable it closes over from an outer scope. Most cache bugs are a wrong guess about
that last part.
Closures are keys.
async function Orders({ userId }: { userId: string }) {
const load = async (status: string) => {
"use cache";
return db.order.findMany({ where: { userId, status } }); // userId is captured
};
return <List orders={await load("open")} />;
}
userId comes from the closure, so it's part of load's key: user A and user B get separate
entries. That is how you cache per-user data safely. The dangerous version is a value the key
can't see. React.cache storage set outside the scope isn't visible inside it (a use cache scope
has its own), and a module-level Map shared between cached and uncached code can hand the cached
code a promise that never resolves during the build (the docs show this hanging the build until
it times out).
Request data stays outside. cookies(), headers() and searchParams are per request; a
cached result is shared. Calling cookies() inside a use cache scope throws.
('use cache: private' does allow it, because its results are never kept in a server cache
across requests, only in the browser's router cache; Next 16.2 still marks it experimental.)
Passing the cookies() promise in as a prop is worse: during next build the cached function
waits on request data that doesn't exist, and the build hangs until "Filling a cache during
prerender timed out". Await it outside and pass the value:
async function Prices() {
const currency = (await cookies()).get("currency")?.value ?? "USD"; // outside: per request
return <PriceList currency={currency} />; // a string, in the key
}
async function PriceList({ currency }: { currency: string }) {
"use cache";
cacheLife("hours");
return renderPrices(await getPrices(currency));
}
Only serialisable things cross in. Arguments follow React's Server Component rules (no class
instances, no functions you call). children and Server Actions may be passed through a cached
component as long as it doesn't look inside them, which is how a cached shell can wrap a dynamic
part.
Nested lifetimes. An outer use cache without its own cacheLife runs on default (15
minutes), and an inner cache with a shorter revalidate silently shortens it; a longer one can't
lengthen it. With an explicit cacheLife on the outer scope, the outer value wins either way. The
docs recommend always writing it, so a cache's lifetime can be read where it's declared.
Quiz
Next.js 16 with cacheComponents: true. A Server Component awaits a database query and has no use cache anywhere above it. When does the query run?
A page written for Next 14 reads searchParams (so it renders on every request) and calls fetch(url) with no options, relying on Next 14 serving that fetch from its Data Cache. After upgrading to Next 15 or 16 (without Cache Components), what happens?
A use cache function needs the visitor's currency, stored in a cookie. What's the right shape?
Inside async function Orders({ userId }), a nested const load = async (status) => { "use cache"; return db.orders(userId, status) } is called. Do two users share one cached result?
In a Server Action, a user saves their display name, then redirect("/profile"). The profile reads data tagged user-42. Which call guarantees the redirected page shows the new name?
A CMS calls your Route Handler POST /api/cms-webhook when an article is published. How should it invalidate post-<slug>?
/blog and /dashboard both show posts fetched with the posts tag. A Server Action calls revalidatePath("/blog"). What does /dashboard show on its next visit?
getProducts() uses cacheLife("hours") (revalidate 1 hour, expire 1 day) and was cached at 09:00. Traffic is steady. What does a request at 10:10 get?
The build output lists /sitemap.xml with a 4-hour revalidate, and on next start it regenerates on schedule. Someone reports production serving yesterday's sitemap. What settles it?
Fill in the blank
Turn on Next.js 16's opt-in caching model.
Flagconst nextConfig: NextConfig = { : true, };
Cache this query for hours and tag it so a write can invalidate it.
Flagimport { cacheLife, cacheTag } from "next/cache"; export async function getProducts() { cacheLife("hours"); ("products"); return db.product.findMany(); }
Invalidate the same tag from a Server Action (the writer must see it at once) and from a CMS webhook Route Handler.
Flag// app/admin/actions.ts — Server Action ("products"); // app/api/cms-webhook/route.ts — Route Handler revalidateTag("products", "");
Read the cookie outside the cache and hand the cached component a plain value.
Flagasync function Prices() { const currency = ( cookies()).get("currency")?.value ?? "USD"; return <PriceList currency={currency} />; } async function PriceList({ currency }: { currency: string }) { return renderPrices(await getPrices(currency)); }
Predict the output
"use server";
export async function createPost(formData: FormData) {
await db.post.create({ data: { title: String(formData.get("title")) } });
revalidateTag("posts", "max");
redirect("/posts"); // /posts lists posts read with cacheTag("posts")
}async function Widget() {
"use cache";
cacheLife("hours"); // revalidate: 1 hour
return <Stats data={await getStats()} />;
}
export default async function Dashboard() {
"use cache"; // no cacheLife here
return <main><h1>Dashboard</h1><Widget /></main>;
}// app/feed/page.tsx
export default async function Feed() {
"use cache";
cacheLife({ stale: 300, revalidate: 60, expire: 3600 });
return <FeedList items={await getFeed()} />;
}
// The user opens /feed, clicks a <Link> to /about,
// and 2 minutes later clicks a <Link> back to /feed.Further reading
- Next.js docs: Caching (Cache Components)
- Next.js docs: Revalidating
- Next.js docs: use cache
- Next.js docs: cacheLife
- Next.js docs: updateTag
- Next.js docs: revalidateTag
- Next.js docs: revalidatePath
- Next.js docs: Caching and Revalidating (Previous Model)
- Next.js docs: Upgrading to version 15
- Next.js blog: Next.js 16
- Next.js docs: Self-Hosting (Caching and ISR)
- Next.js docs: How revalidation works
- Vercel docs: Incremental Static Regeneration