The model, in one paragraph
With Cache Components enabled, nothing is cached unless you say so, and everything you do not mark as cached is treated as dynamic and rendered per request. You opt components, functions or whole pages into caching with the use cache directive, and you say how long they live with cacheLife. Dynamic parts, anything reading cookies, headers or the request, must sit behind a Suspense boundary so the static shell can be prerendered around them. That is the whole shift: from "the framework guesses what is static" to "you declare what is cacheable and everything else is live".
For a business website, which is mostly static content with a few live edges, this is a good fit, once you know where the edges are.
What a business site looks like under it
Most pages on a services site are entirely cacheable: home, services, about, landing pages, blog posts. Their content comes from files or a CMS and changes when someone publishes, not per visitor.
A few things are dynamic: a contact form's server action, a booking widget that reads availability, anything that shows a logged-in state, and any personalisation from cookies.
The pattern is: cache the page, isolate the dynamic bits behind Suspense.
// app/services/[slug]/page.tsx
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
export default async function ServicePage({ params }) {
'use cache'
cacheLife('days')
const { slug } = await params
const service = await getService(slug)
return (
<article>
<ServiceContent service={service} />
<Suspense fallback={<AvailabilitySkeleton />}>
<LiveAvailability serviceId={service.id} />
</Suspense>
</article>
)
}The page is cached for days. LiveAvailability reads live data and renders per request inside the boundary; the shell around it is served instantly.
Choosing cacheLife profiles
cacheLife takes a named profile or a custom object of stale, revalidate and expire. For a typical site:
'max'for content that only changes on deploy: the design system, legal pages, static landing copy shipped in the repository. Revalidates on redeploy.'days'for CMS-driven pages that change occasionally: services, about, team. Pair with on-demand revalidation from the CMS webhook so edits show up immediately anyway.'hours'for listings that change often: a blog index, a news feed pulled from an API.'minutes'or'seconds'for things like a stock count or a price feed where slightly stale is acceptable but very stale is not.- No cache for anything per user.
Define custom profiles in next.config when the presets do not fit, and name them for what they are ("catalog", "pricing") so the intent is readable.
Caching below the page
use cache works at function level too, which is where it earns its keep. Cache the expensive parts and leave the cheap parts live:
export async function getServices() {
'use cache'
cacheLife('days')
cacheTag('services')
return cms.fetch('services')
}The cacheTag lets a CMS webhook call revalidateTag('services') and refresh every page that used that function, without redeploying and without knowing which pages those are.
Where prerendering breaks
The build prerenders everything cacheable. It fails, loudly, when something inside a cached scope does something that cannot be cached. The usual culprits on a marketing site:
Date.now()ornew Date()inside a cached component for "published X days ago" or a copyright year. Either compute it in a client component, pass the timestamp as data, or accept that the rendered string is cached with the page.Math.random()for a testimonial rotator or an A/B split. Move it client-side or seed it from a cached value.- Reading
cookies()orheaders()inside a cached scope. That is dynamic by definition; it needs to be outside the cache, behind Suspense. - A third-party library doing any of the above internally. Syntax highlighters, date formatters and analytics helpers are common. Wrap the call in its own
use cachefunction so its output is cached and its internals run once at build. - Unstable arguments. A cached function's arguments form its cache key; passing a new object each time defeats the cache silently.
The error messages name the offending call. Read them; they are accurate.
Forms and actions
Server actions are dynamic and are unaffected by page caching: a cached contact page can submit to a live action. The page's shell is cached; the submission runs per request. Nothing to configure.
Development versus production
In development the cache is short-lived and visible behaviour is close to production. Check the build output: it lists which routes were prerendered as static and which have dynamic holes. A page you expected to be fully static showing as dynamic means something in it is not cacheable, and the build log points at it.
Is it worth enabling on an existing site?
For a new site, yes, from the start: the model is simpler once learned and the performance ceiling is higher. For an existing App Router site, the migration is mostly adding use cache where you previously relied on implicit static rendering and wrapping dynamic reads in Suspense. It is a day or two on a typical business site and a good moment to audit what was accidentally dynamic before.
This is how our own site runs, and it is the caching setup we use for Next.js client work: declared caching, tagged revalidation from the CMS, and dynamic edges behind Suspense.