What changed
Older App Router versions cached by default and made you opt out. Cache Components invert that: nothing is cached unless you say so, and you say so with a directive. Turn it on in next.config.ts:
const nextConfig = {
cacheComponents: true,
}
export default nextConfigFrom that point, three primitives do the work. 'use cache' marks a function or component as cacheable. cacheLife() says how long. cacheTag() names the entry so revalidateTag() can purge it on demand. Everything else is a consequence of those three.
This site runs on them: the blog data layer, the component registry and the landing-page content all sit behind use cache. Most of what follows was learned by breaking the build.
Where use cache goes
The directive goes at the top of an async function or a Server Component. The function's arguments become the cache key, and its return value must be serialisable, because it is being stored and replayed.
import { cacheLife } from 'next/cache'
export async function getAllPosts() {
'use cache'
cacheLife({ stale: 300, revalidate: 3600, expire: 86400 })
const files = fs.readdirSync(BLOG_DIR)
return files.map(parsePost)
}Put it in the data layer, not the page. A page that calls three cached functions can be prerendered as a whole; a page that is itself 'use cache' is a single entry that any argument change invalidates. Caching at the data function is finer-grained and composes better.
Do not put it on a function that reads request data. cookies(), headers() and searchParams are request-scoped, and a cached function cannot see them. The build tells you so, at length.
cacheLife, and what the three numbers mean
cacheLife accepts a named profile ('seconds', 'minutes', 'hours', 'days', 'weeks', 'max') or an object with three fields, all in seconds:
- stale: how long the client may use its copy without asking the server. This is the client-side router cache.
- revalidate: how long the server serves the cached entry before regenerating it in the background.
- expire: how long an entry may be served at all. After this, a request blocks on regeneration.
The profile above says: the browser can reuse for five minutes, the server refreshes in the background after an hour, and nothing older than a day is ever served. For content that changes when you deploy, 'max' is honest. For content that changes when an editor saves, a shorter revalidate or an explicit tag.
Custom profiles can be declared once in next.config.ts and referenced by name, which is tidier than repeating objects.
cacheTag and on-demand revalidation
Tags are how a save in a CMS purges exactly the entries that depend on it.
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheLife('max')
cacheTag('posts', `post:${slug}`)
return fetchPost(slug)
}Then, in a Server Action or route handler that runs when content changes:
'use server'
import { revalidateTag } from 'next/cache'
export async function onPostSaved(slug: string) {
revalidateTag(`post:${slug}`)
revalidateTag('posts')
}Tag the collection and the item. Purging the item without the collection leaves the index stale; purging only the collection regenerates lists but serves the old article. Both, every time.
The build errors, and what they mean
Cache Components are strict during prerender, and the errors are the useful part.
"Route used Date.now() before accessing uncached data." Something read the current time in a component that is being prerendered. The clock is not deterministic, so the prerender refuses. Fixes: move the time read into a 'use cache' function (this site's syntax highlighter needed exactly that), read it in a Client Component, or read request data first so the route is dynamic. Libraries do this internally more often than you would expect.
"Route segment config is not compatible with cacheComponents." export const dynamic, revalidate and friends are rejected. Caching is expressed with the directives now; delete the segment config.
"A cached function cannot access request data." A 'use cache' function called cookies() or headers(). Move the request read up into the page and pass the value in as an argument, which also makes it part of the cache key.
"Uncached data was accessed outside a Suspense boundary." Something dynamic rendered without a fallback. Wrap it in <Suspense> so the static shell can prerender and the dynamic part streams.
Patterns that hold up
Cache the data, stream the dynamic bits. Prerender everything that does not depend on the request, and put anything that does behind Suspense. That is what partial prerendering means in practice.
Cache at the boundary you can name. If you can say "this is invalidated when a post saves", it is a cache function with a tag. If you can only say "this is fine for an hour", it is a cacheLife profile.
Pass in what you read from the request. A cached function that takes locale as an argument is fine; one that reads it from a cookie is not.
Expect libraries to fail the prerender. Anything that reads time, randomness or environment at import can break it. The fix is a thin cached wrapper, not disabling the feature.
When not to use it
Very small sites can leave cacheComponents off and lose nothing. Dashboards where every view is personalised gain little. The feature earns its keep on sites with a lot of content that changes rarely and a few parts that change per request, which describes most marketing sites and this one.
If you are migrating an existing App Router project and the prerender errors are stacking up, we have done it on a 460-route site and would rather you did not repeat our mistakes. The Next.js work is here.