Headline: Cache Components turn caching into a component-level concern you can read off the source code. No more "is this route static or dynamic?" guessing. After migrating two production apps, here's what actually changed.
If you spent the last two years writing export const dynamic = 'force-static' at the top of route files and grepping for revalidate = 60, Cache Components are the cleanup you've been waiting for.
The mental model
There are three primitives:
'use cache' // mark a component/function as cacheable
cacheLife('hours') // how long can it serve stale (named profile)
cacheTag('product-42') // a tag you can invalidate later
That's it. Everything else falls out of those three.
Two rules govern the system:
- A component renders dynamically unless it's marked
'use cache'. - A component marked
'use cache'cannot read dynamic data (cookies, headers, search params) directly. If it needs them, it accepts them as props.
The second rule is the unlock. Caching boundaries are now visible in the function signature.
A realistic example
Take a product page with a personalized "recently viewed" rail.
// app/products/[slug]/page.tsx
import { ProductDetails } from './product-details';
import { RecentlyViewed } from './recently-viewed';
export default async function ProductPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ ref?: string }>;
}) {
const { slug } = await params;
const { ref } = await searchParams;
return (
<main>
<ProductDetails slug={slug} />
<RecentlyViewed referrer={ref} />
</main>
);
}
// product-details.tsx — cached per slug
'use cache';
import { cacheLife, cacheTag } from 'next/cache';
export async function ProductDetails({ slug }: { slug: string }) {
cacheLife('hours');
cacheTag(`product-${slug}`);
const product = await db.product.findUnique({ where: { slug } });
return <article>...</article>;
}
// recently-viewed.tsx — dynamic
import { cookies } from 'next/headers';
export async function RecentlyViewed({ referrer }: { referrer?: string }) {
const userId = (await cookies()).get('uid')?.value;
const items = await db.recentlyViewed.findMany({ where: { userId } });
return <ul>...</ul>;
}
Read the file. The caching strategy is immediately legible. No dynamic = 'force-static'. No revalidate = 3600. No mystery about which fetch is cached.
The gotchas
1. 'use cache' is contagious
If ProductDetails calls a helper that internally calls cookies(), the build fails. The cached scope is enforced. This sounds annoying for a day, then becomes the feature you can't live without.
2. cacheLife profiles, not literals
You name profiles in next.config.ts:
// next.config.ts
export default {
experimental: {
cacheLife: {
hours: { stale: 60 * 60, revalidate: 60 * 60, expire: 60 * 60 * 24 },
day: { stale: 60 * 60 * 24, revalidate: 60 * 60 * 24, expire: 60 * 60 * 24 * 7 },
},
},
};
This forces you to decide what "hours" means once, instead of sprinkling revalidate: 3600 everywhere. Caching becomes config, not code.
3. Tag invalidation is the new mutation pattern
// app/api/products/[id]/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request, { params }) {
const { id } = await params;
await db.product.update({ where: { id }, data: await req.json() });
revalidateTag(`product-${id}`);
return Response.json({ ok: true });
}
revalidateTag is sync, idempotent, and cheap. Use it on every write that touches cached data.
4. Streaming + cached children = the new fast page
Wrap your dynamic children in <Suspense>, let cached children stream immediately:
<main>
<ProductDetails slug={slug} /> {/* cached, ships fast */}
<Suspense fallback={<RailSkeleton />}>
<RecentlyViewed referrer={ref} /> {/* dynamic, streams in */}
</Suspense>
</main>
TTFB drops to the cached portion. The page becomes interactive while the dynamic rail is still resolving on the server.
Migration playbook
-
Audit
dynamicexports. Every file withexport const dynamic = 'force-static'becomes a candidate for'use cache'. -
Define your
cacheLifeprofiles once. Three or four named profiles cover 95% of cases (minutes,hours,day,week). - Tag aggressively. Every cached unit of content gets a tag. Future-you needs them for surgical invalidation.
- Convert one route at a time. Don't big-bang. The new model can coexist with the old.
-
Delete dead
revalidate = Nexports as you go. They're now noise.
I migrated eng-ahmed.com (blog + portfolio) and a client commerce site over a weekend. Bundle size unchanged; TTFB dropped 35% on cached routes; and — the real win — onboarding new engineers got faster because the cache strategy is in the file they're reading.
When not to use it
Pages that are 100% personalized (logged-in dashboards) get no benefit. Don't force 'use cache' for the sake of it. Leave them dynamic.
Closing
This is the first Next.js caching model that I've found teachable to a junior in under twenty minutes. The bar for caching primitives shouldn't be lower than that. We finally have it.
Building on Next.js and want a second opinion on your caching topology? Find me at eng-ahmed.com or working with clients via Devya Solutions.
Top comments (0)