Cache Components changed how I think about a page
Every version of Next.js since 13 has had a caching story that somebody was angry about. Fetch caching that was on by default and surprised people. Route segment configs, dynamic, revalidate, fetchCache, that lived in an export at the top of a file and applied to the whole route whether the whole route wanted it or not. The 15 release turned most of the defaults off, which fixed the surprise and left you with a page that was fully dynamic unless you opted every piece back in.
Next.js 16 replaced all of that with Cache Components. One directive, use cache, that you put in a file, a component or a function, and two helpers, cacheLife and cacheTag, that say for how long and under what name. When it is on, the old route level exports stop working. Since 16.3 the same directive drives client side caching too, which is what makes the "instant navigations" thing real.
I migrated two applications. The experience was different enough between them that it taught me something about what I had been doing before.
The mental model that changed
Before, the unit of caching was the route. You decided that /blog/[slug] was static, or revalidated every hour, or dynamic, and the whole tree under it followed. If one component on the page needed the current user, the page was dynamic, and everything else on it paid for that.
With Cache Components the unit is the subtree. A component marked use cache is rendered once, its output is stored, and it is served from the store until its lifetime expires or its tag is invalidated. Everything not marked is rendered on every request. The two can be nested in either direction. A cached layout can contain a dynamic sidebar. A dynamic page can contain a cached list of related posts.
The runtime enforces this with a build time check. If a component that is not cached reads something request specific, a cookie, a header, a search parameter, that is fine, it is dynamic. If a component that is cached tries to do that, the build fails, because a cached component cannot depend on the request by definition. That failure is the migration. Every place it fires is a place where you had a dynamic dependency you had not noticed.
The content site
This site. Blog posts from MDX, a home page with a few live widgets, an experiments gallery. Almost everything is the same for every visitor and changes when I push a commit.
The migration took an afternoon. Turn the flag on in the config:
// next.config.ts
export default {
cacheComponents: true,
};Then go route by route. The post page became:
// app/(blog)/blog/[slug]/page.tsx
import { cacheLife, cacheTag } from "next/cache";
export default async function PostPage({ params }) {
const { slug } = await params;
return (
<>
<Post slug={slug} />
<ViewCounter slug={slug} />
</>
);
}
async function Post({ slug }: { slug: string }) {
"use cache";
cacheLife("max");
cacheTag(`post:${slug}`);
const post = await getPost(slug);
return <Article post={post} />;
}Post is cached until I invalidate post:the-slug, which the deploy hook does for changed files. ViewCounter is not cached, it hits Redis on every request, and it renders inside the cached shell without making the shell dynamic. Under the old model that view counter made the whole page dynamic, and I had worked around it with a client component and a fetch on mount, which meant a flash of an empty number on every load. Now it is a server component that streams in after the cached part, and there is no flash, because the cached part is served from the store in a couple of milliseconds and the counter arrives in the same response.
The build check fired twice. Once in the header, where a theme preference was read from a cookie inside a component I had marked cached, and once in the post list, where the page number came from search parameters. Both were correct failures. The header fix was to read the cookie one level up and pass the value down as a prop, which is what the docs tell you to do and what I should have done anyway. The list fix was to leave the list dynamic and cache the per post cards inside it, which is the pattern for any paginated or filtered view: the frame is dynamic, the items are cached by id.
That is the whole story for a site like this. It got faster, the code got simpler because the fetch-on-mount workarounds went away, and the caching is now visible at the exact component that has it, rather than in an export at the top of a file that governs things you cannot see from there.
The dashboard
The second application is an internal dashboard. Logged in users, per account data, a dozen widgets on the main view, filters in the URL. Under the old model it was dynamic = "force-dynamic" at the top of every route, because everything depended on the user, and the pages were as fast as the slowest query on them.
Turning on Cache Components made nothing faster on its own, because nothing was marked cached, and the build check found nothing, because nothing was cached. That was the first lesson: the directive is opt in and the migration of a dynamic app is a design exercise, not a flag.
So I went widget by widget and asked, for each one, what does this actually depend on. The answers were more interesting than I expected.
The account header depends on the user. Dynamic.
The list of the account's projects depends on the account, and changes when a project is created, which is rare. Cacheable, tagged by account id, invalidated on project mutation. Under the old model it was fetched on every page load and it was the second slowest query on the page.
The activity feed depends on the account and changes constantly. Dynamic, but it can render after everything else. That is a Suspense boundary, not a cache.
The plan and billing summary depends on the account and changes when billing runs, which is once a day. Cacheable with a one day lifetime.
The metrics chart depends on the account and the date range in the URL. The date range is a search parameter, which makes it dynamic, and there is nothing to do about that. But the chart for a given account and a given fixed range, "last 30 days" as of a given day, is the same for everyone in the account who opens it that day. Cacheable, with the range and the day in the key.
async function MetricsChart({ accountId, range }: Props) {
"use cache";
cacheLife({ stale: 300, revalidate: 900, expire: 3600 });
cacheTag(`metrics:${accountId}`);
const day = new Date().toISOString().slice(0, 10);
const series = await loadSeries(accountId, range, day);
return <Chart series={series} />;
}After that pass, five of the twelve widgets were cached and the main view's server time went from around 900 ms to around 180 ms for the typical account, with the remaining time dominated by the activity feed, which streams in after the rest.
What the dashboard taught me
Here is the thing I had to admit. Under force-dynamic, the page was hiding that five of its twelve widgets did not depend on the request at all. They depended on the account, and the account was in the request, and I had let the framework flatten that into "the page is dynamic". The data was being recomputed on every load for years and nobody could see that it did not need to be, because the caching decision was being made at the route, three levels above where the actual dependency lived.
Cache Components does not let you do that. You mark the subtree, the build tells you what the subtree reads, and you either move the dependency out or accept that the subtree is dynamic. The decision is made at the place where the data is, by someone who can see what the data is.
That is a better model. It is also more work up front for an app that has been dynamic everywhere, because you have to have the design conversation you skipped. The content site skipped nothing, so it was an afternoon. The dashboard had skipped everything, so it was a week.
The client side, since 16.3
The part that makes the demos look good is that use cache output is also cached in the browser's router. Navigating from the post list to a post and back does not refetch the list, because its cached subtree is still valid in the client cache, and the lifetime you gave it on the server applies there too. Prefetching on hover fills that cache before the click. That is what "instant navigations" means: the framework can serve the cached parts of the next page from memory and stream only the dynamic parts.
The practical consequence is that cacheLife is now a user facing decision as well as a server cost one. A five minute stale window on a list means a user can see a five minute old list after navigating back, and for most lists that is fine, and for a few it is not, and you have to know which.
Migrating, in order
Turn the flag on. Fix every route that used dynamic, revalidate or fetchCache, because they are no longer honoured and the build will tell you.
Mark the things that are obviously static: layouts, navigation, marketing pages, content from files. Let the build check find the hidden request dependencies and move them up.
Then, for the dynamic pages, go component by component and write down what each one actually depends on. The ones that depend on an entity rather than on the request are cacheable by that entity's id. The ones that depend on the request are dynamic and want a Suspense boundary so they do not hold up the rest.
Set lifetimes with the client cache in mind. Invalidate by tag from the places that mutate the entity.
That sequence took me from "everything is dynamic and slow" to "the slow parts stream in after the fast parts" without changing a single query. The queries were always fine. It was the decision about when to run them that had been made in the wrong place.