Migrating to Cache Components
This article is part of a series on Next.js Cache Components. See all articles: Cache Components in Next.js · Data-Level vs. UI-Level Caching · How Revalidation Works with "use cache" · Migrating to Cache Components
This article covers the mental model and common pitfalls based on my experience. The Next.js migration guide should be your primary reference for the full migration path.
What changes
The previous App Router caching model had multiple overlapping mechanisms: the fetch cache, unstable_cache, route segment config options like revalidate and dynamic, and the implicit caching behavior that changed between Next.js versions. Cache Components replace all of this with a single opt-in system.
With cacheComponents: true, caching becomes fully explicit. You mark what should be cached with "use cache", wrap dynamic content in <Suspense>, and everything else runs at request time. The implicit caching layers of the previous model (fetch cache, full route cache) are gone.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
The straightforward migrations
In the first projects we migrated, the changes were mostly mechanical.
unstable_cache → "use cache": The wrapper function becomes a directive. Arguments that were part of the cache key are now inferred automatically from the function's parameters and closures. For a deeper look at why this moved from a wrapper to a directive, see the first article in this series.
// Before
import { unstable_cache } from 'next/cache'
async function getCachedProduct(id: string) {
return unstable_cache(async (id: string) =>
db.products.findUnique({ where: { id } }), [`product-${id}`], {
tags: [`product-${id}`],
revalidate: 3600,
})()
}
// After
import { cacheTag, cacheLife } from 'next/cache'
async function getProduct(id: string) {
'use cache'
cacheTag(`product-${id}`)
cacheLife('hours')
return db.products.findUnique({ where: { id } })
}
Route segment config → cacheLife: If you used export const revalidate = 3600 on a page, that becomes cacheLife('hours') inside a "use cache" scope. The segment config options dynamic and fetchCache are no longer needed because the caching behavior is explicit.
fetch cache → "use cache" on data functions: The old fetch('...', { cache: 'force-cache', next: { tags: ['product'] } }) pattern is replaced by putting "use cache" on the function that calls fetch. The tags move to cacheTag().
Where it gets harder: component composition
The mechanical API changes are not where the effort is. The real work starts when Cache Components require you to restructure your component tree.
With Cache Components, every component has to fit into one of the three categories noticed in the Data-Level vs. UI-Level Caching article in this series:
- Cached with
"use cache": static or infrequently changing content - Streamed with
<Suspense>: dynamic content that needs fresh data per request - Deterministic: pure computation, no async, automatically included in the static shell
Components that mix cached and uncached data in the same scope need to be split.
If a page component fetches both public product data and user-specific cart data in the same function body, you can't just add "use cache" to it. The cart depends on cookies, which aren't available in a cached scope. You need to separate the cached product data from the dynamic cart, wrap the cart in <Suspense>, and potentially create new intermediate components to hold the boundary.
For smaller projects, this restructuring is manageable. For larger codebases with deeply nested data dependencies, it can mean refactoring significant parts of the component tree.
The new Date() problem
The migration that cost us the most time was for the waste management portal for the city of Düsseldorf (see the Data-Level vs. UI-Level Caching article for the project background). The waste calendar depends heavily on the current date: which collection dates to show, whether to display past dates, when to start showing next year's schedule, all of this was driven by Date.now() and new Date() in the rendering logic.
Cache Components treat Date.now(), new Date(), and Date() (correctly!) as non-deterministic operations. If your component uses them during prerendering, Next.js throws an error. The framework can't include time-dependent content in the static shell because the prerender might happen hours or days before the user sees the page.
You have 3+1 options when you hit this:
- Move the date into a
"use cache"scope. The timestamp gets cached along with the rest of the function's output. This works when the date is used to compute something that's fine being slightly stale, like a "last updated" label. - Move the date into a Client Component. If the date is used for display (showing the copyright footer or calculating a relative time), it belongs on the client where the browser provides the actual current time.
- Use
await connection()+<Suspense>. This defers the component to request time, soDate.now()returns the actual request timestamp. But it means the component can't be part of the static shell. - Restructure the business logic. This is what I ended up doing for AWISTA.
The waste calendar had date-dependent filtering baked into the data layer. We were fetching only the "relevant" dates from the database, filtering out past entries at query time based on today's date. That worked fine when the page rendered dynamically on every request. With Cache Components, the "today" in the cached version could be days old.
I rewrote the data layer to fetch collection dates more generously, without date-based filtering. The database returns a broader set of dates, and the date-dependent display logic moved into components that can handle it appropriately, either through short cacheLife profiles or through client-side rendering where actual "now" matters. The migration took several days, most of it spent rethinking which parts of the business logic truly depend on the current timestamp.
Third-party libraries
Keep in mind that third-party code can also trigger this error. Libraries that internally call new Date() or Date.now() during rendering will cause the same build failure. While you can't easily change the library code, you can apply the same strategies in your own component tree: wrap the component in Suspense, move it to a Client Component, or restructure to avoid the problematic call path.
Consult the docs
This article covers the patterns and pitfalls I encountered in our own migrations. The Next.js docs cover the full scope, including nesting rules for cache directives, serialization constraints, and the complete migration guide. For the current state of the API, always check the well-maintained docs first.
Looking back at the series
Next.js caching has been a moving target for years. From Incremental Static Regeneration (ISR) with manual path revalidation, through the implicit caching of the early App Router, to the explicit opt-in model of Cache Components, each iteration gave the framework more knowledge about what data goes into what output. The direction is clear even if the journey has been bumpy.
I should be honest about the cost of that journey. Caching has been the most frustrating part of Next.js for many developers, and each model change means relearning how a core part of the framework works. Some developers have turned away from Next.js over this, and I understand why. Cache Components are the third caching paradigm in as many major versions. That's a steep learning curve, again.
What keeps me on board is the granularity. Being able to cache a single data function with "use cache", tag it with a specific entity ID, and have the framework propagate invalidation across every page that uses it, that's genuinely powerful. When something is cached, the directive is right there in the code, visible to every developer on the team.
The migration isn't free, as the AWISTA Date.now() story shows. But for the projects where we've completed it, the result is a caching model I can actually explain to a new developer in five minutes over a coffee.