Every version of Next.js since 13 has had a caching story somebody was angry about. First fetch caching was on by default and surprised people. Then there were the route segment configs, dynamic, revalidate and fetchCache, exported at the top of a file and applied to the whole route whether the whole route wanted them or not. The 15 release turned most of the defaults off. That fixed the surprise, and left you with pages that were fully dynamic unless you opted every piece back in.
Next.js 16 replaced all of it with Cache Components. There's one directive, use cache, which goes on a file, a component or a function, and two helpers, cacheLife and cacheTag, which say for how long and under what name. Once it's on, the old route level exports stop working.1
I migrated two applications, and they went differently enough that I learned something about what I'd been doing before.
The mental model that changed
The unit of caching used to be the route. You decided /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 renders once, its output is stored, and the store serves it until its lifetime runs out or its tag is invalidated. Anything not marked renders on every request. You can nest them either way round: a cached layout can hold a dynamic sidebar, and a dynamic page can hold a cached list of related posts.
A build time check keeps this honest. An uncached component can read whatever is request specific, a cookie, a header, a search parameter, and it's simply dynamic. A cached component that tries the same thing fails the build, since by definition it can't depend on the request. Those failures are the migration. Every one points at a dynamic dependency you hadn't noticed.
The content site
This one is this site: blog posts from MDX, a home page with a few live widgets, an experiments gallery. Almost all of it is the same for every visitor and only changes when I push a commit.
It took an afternoon. First the flag goes on in the config:
// next.config.ts
export default {
cacheComponents: true,
};
Then I went route by route. The post page ended up like this:
// 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 stays cached until I invalidate post:the-slug, which the deploy hook does for changed files. ViewCounter isn't cached. It hits Redis on every request, and it renders inside the cached shell without making the shell dynamic. Under the old model that one counter made the whole page dynamic. I'd worked around it with a client component that fetched on mount, so every load flashed an empty number first. Now it's a server component that streams in after the cached part. There's no flash, because the store serves the cached part in a couple of milliseconds and the counter arrives in the same response.
The build check fired twice. One was in the header, where a component I'd marked cached read the theme preference from a cookie. The other was in the post list, where the page number came from search parameters. Both failures were right. For the header I read the cookie one level up and passed the value down as a prop, which is what the docs tell you to do and what I should have done anyway. For the list I left the list itself dynamic and cached the per post cards inside it.2
For a site like this, that's all there was to it. It got faster, the code got simpler once the fetch-on-mount workarounds went, and you can see the caching on the exact component that has it, instead of in an export at the top of a file that controls things you can't see from there.
The dashboard
The second app 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 every route had dynamic = "force-dynamic" at the top, because everything depended on the user, and each page was as fast as its slowest query.
Turning Cache Components on didn't make anything faster by itself, because nothing was marked cached, and the build check found nothing for the same reason. That was the first thing I learned. The directive is opt in, and moving a dynamic app over is a design exercise that a flag won't do for you.
So I went through the widgets one at a time and asked what each one actually depended on. The answers were more interesting than I expected.
The account header depends on the user, so it stays dynamic.
The list of the account's projects depends on the account and changes when someone creates a project, which is rare. It's cacheable, tagged by account id and invalidated when a project changes.3
The activity feed depends on the account and changes all the time. It stays dynamic, but it can render after everything else, so it needs a Suspense boundary and no cache.
The plan and billing summary depends on the account and changes when billing runs, once a day. It's cacheable with a one day lifetime.
The metrics chart depends on the account and on the date range in the URL. The range is a search parameter, which makes it dynamic, and there's nothing to be done about that. But the chart for one account and one fixed range, "last 30 days" as of a given day, is the same for everyone in the account who opens it that day. So it's 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. For the typical account the main view's server time dropped from around 900 ms to around 180 ms, and most of what's left is the activity feed, which streams in after the rest.
What the dashboard taught me
This is what I had to admit. force-dynamic had been hiding that five of the twelve widgets didn't depend on the request at all. They depended on the account. The account happened to be in the request, and I'd let the framework flatten that into "the page is dynamic". For years that data was recomputed on every load, and nobody could see it didn't need to be, because the caching decision was made at the route, three levels above where the dependency actually lived.
Cache Components won't let you do that. You mark the subtree, the build tells you what it reads, and you either move the dependency out or accept that the subtree is dynamic. The decision gets made where the data is, by someone who can see what the data is.
I think that's the better model. It's also more work up front for an app that's been dynamic everywhere, because now you have the design conversation you skipped. The content site hadn't skipped anything, so it took an afternoon. The dashboard had skipped all of it, so it took a week.
The client side, since 16.3
The part that makes the demos look good is that use cache output is cached in the browser's router too. Going from the post list to a post and back doesn't refetch the list, because its cached subtree is still valid in the client cache, and the lifetime you set on the server applies there as well. Prefetching on hover fills that cache before the click. That's what "instant navigations" means: the framework serves the cached parts of the next page from memory and streams only the dynamic ones.
In practice this makes cacheLife a decision about what users see, on top of what the server costs. A five minute stale window on a list means someone can navigate back and see a list that's five minutes old. For most lists that's fine. For a few it isn't, and you have to know which ones.
Migrating, in order
Turn the flag on. Fix every route that used dynamic, revalidate or fetchCache. They aren't honoured any more, and the build will tell you where they are.
Mark what's obviously static: layouts, navigation, marketing pages, content from files. Let the build check find the hidden request dependencies, and move them up.
Then take the dynamic pages one component at a time and write down what each actually depends on. A component that depends on an entity can be cached by that entity's id. One that depends on the request is dynamic and wants a Suspense boundary so it doesn't hold up the rest.
Set lifetimes with the client cache in mind, and invalidate by tag from wherever the entity gets changed.
Doing it in that order took me from "everything is dynamic and slow" to "the slow parts stream in after the fast parts", and I didn't change a single query. The queries had been fine all along. The decision about when to run them had just been made in the wrong place.
Originally published at zeybek.dev.
-
Since 16.3 the same directive drives client side caching as well, and that's what makes the "instant navigations" thing real. ↩
-
That works for any paginated or filtered view: the frame is dynamic and the items are cached by id. ↩
-
Under the old model it was fetched on every page load, and it was the second slowest query on the page. ↩
Top comments (0)