Caching in Next 16
Cache Components, 'use cache', revalidateTag, updateTag.
Next.js used to cache everything by default. Then nothing by default. Then it tried to thread a middle path with implicit rules nobody could remember. Next 16 finally fixes it with Cache Components: caching is opt-in, expressed with a directive next to your code, and refreshed by tags or explicit updates. If you've been confused by Next caching in the last two years, this is the lesson where it stops being confusing.
The mental model: cached or dynamic, you choose
In Cache Components, every async function or component is either cached or dynamic. Cached means the result is stored and reused across requests. Dynamic means it runs fresh on every request. You opt into cached behavior with the "use cache" directive. Without it, the default is dynamic.
async function getPosts() {
"use cache"; // this function's result is cached
const res = await fetch("https://api.example.com/posts");
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts(); // hits cache; refetches when invalidated
return <PostList posts={posts} />;
}"use cache" can sit at the top of a file, the top of a function, or even inline in an arrow function body. Pick the smallest scope you actually want cached.cacheLife: how long is it good for?
Caches need a freshness policy. cacheLife sets two things: how often Next refreshes in the background, and how long stale content is allowed to be served while the refresh runs. Use one of the built-in profiles, or pass a config.
import { unstable_cacheLife as cacheLife } from "next/cache";
async function getPrices() {
"use cache";
cacheLife("hours"); // built-in profile
// built-in names: seconds, minutes, hours, days, weeks, max
return fetchPrices();
}You can also configure custom profiles in next.config.ts:
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
cacheLife: {
pricing: {
stale: 60, // serve cached for 60s without revalidating
revalidate: 300, // refresh every 5 min in background
expire: 3600, // hard expiry after 1 hour
},
},
},
};
export default config;cacheTag: invalidate by name
Tag a cached function with strings, and later you can blow away every cache entry sharing that tag from anywhere in the app. This is the killer feature: you don't have to know URLs to invalidate.
import { unstable_cacheTag as cacheTag } from "next/cache";
async function getPost(id: string) {
"use cache";
cacheTag("post", "post:" + id);
return db.post.findUnique({ where: { id } });
}When a mutation runs (Server Action, webhook, anything), you invalidate by tag:
"use server";
import { revalidateTag } from "next/cache";
export async function editPost(id: string, data: PostInput) {
await db.post.update({ where: { id }, data });
revalidateTag("post:" + id); // invalidate just this post
// revalidateTag("post"); // ... or every post-tagged cache
}updateTag: read-your-writes inside actions
revalidateTagmarks the cache stale for the next request. But what if the user expects to see their own write immediately, in the same request that mutated? That's what updateTag is for. Inside a Server Action, updateTag evicts the cached entry and forces the following await on the same tag to recompute.
"use server";
import { updateTag, revalidateTag } from "next/cache";
export async function addComment(postId: string, body: string) {
await db.comment.create({ data: { postId, body } });
// For the current action's response: read-your-writes guarantee.
updateTag("comments:" + postId);
// For everyone else: invalidate the cache so future requests refetch.
revalidateTag("comments:" + postId);
}updateTagas "refresh now for the person who clicked the button" and revalidateTag as "refresh later for everyone else." Most mutating actions call both.Cached components, not just functions
A whole component can be cached. Drop the directive at the top of the component body and Next caches its JSX output, including every server-only async call inside it.
async function FeaturedProducts() {
"use cache";
cacheTag("featured");
cacheLife("hours");
const products = await db.product.findMany({ where: { featured: true } });
return (
<ul>{products.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
);
}Partial Prerendering: static shell, dynamic islands
Cache Components are the engine behind Partial Prerendering (PPR). Your page can be mostly cached HTML (header, product grid, footer) with a couple of dynamic holes (current user's cart count, personalized banner). The static parts ship instantly from the edge; the dynamic parts stream in. You opt into dynamism with <Suspense> boundaries wrapping the dynamic pieces.
import { Suspense } from "react";
export default function Home() {
return (
<>
<StaticHero /> // cached, ships as HTML
<Suspense fallback={<CartSkeleton />}>
<CartCount /> // dynamic, streamed
</Suspense>
<Footer /> // cached
</>
);
}Migrating from unstable_cache
If your code uses unstable_cache(the older API), here's the swap. The new directive is simpler and composable.
import { unstable_cache } from "next/cache";
const getUser = unstable_cache(
async (id: string) => db.user.findUnique({ where: { id } }),
["user"],
{ tags: ["user"], revalidate: 60 },
);import { unstable_cacheTag as cacheTag, unstable_cacheLife as cacheLife } from "next/cache";
async function getUser(id: string) {
"use cache";
cacheTag("user", "user:" + id);
cacheLife("minutes");
return db.user.findUnique({ where: { id } });
}fetch is no longer magically cached
In old Next, fetch() was cached by default. That behavior is gone. Now fetch() is just fetch. If you want the result cached, wrap the call in a function that opts into "use cache". Less magic, more clarity.
Quiz
How do you cache a function in Next 16 Cache Components?
Recap
- Cache Components are opt-in. Add
"use cache"where you want caching. cacheLifesets freshness; built-in profiles areseconds,minutes,hours,days,weeks,max.cacheTaglabels entries;revalidateTaginvalidates them across requests.updateTaggives read-your-writes inside the action that mutated.- Partial Prerendering ships the cached shell instantly and streams dynamic
<Suspense>holes. fetch()is no longer magically cached; opt in explicitly.