Caching strategy
The three shapes a read can take, when to cache, and how to invalidate
Next.js 16 runs here with cacheComponents: true. Every component is one of three things — static (sync, no data), cached ("use cache"), or dynamic (session, cookies, params — streamed behind <Suspense>). This page is about the reads underneath them.
Three shapes
Pick by what the data is, not by habit.
| The data | The shape |
|---|---|
| Shared and public — categories, colours, a listing | "use cache" + cacheTag + cacheLife |
| Must be fresh every request — a cart total, an order's status | A plain async read behind <Suspense>. Not "use cache" |
| Unbounded key space — free-text search, offset pagination | A plain read wrapped in React cache() for per-request dedup |
A plain dynamic per-user read is not technical debt. It is the default.
What "use cache" actually buys
Not what most people assume. On serverless, runtime cache entries typically don't persist across requests. "use cache" earns its place by putting shared data into the static shell, into ISR upgrades, and into per-link prefetches — not by deduplicating runtime reads between visitors.
Deduplicating across instances is "use cache: remote", which costs a network round trip. We don't use it.
// packages/features/categories/queries/get-category-tree.query.ts
import "server-only";
export async function getCategoryTree() {
"use cache";
cacheTag("categories");
cacheLife("reference");
// getTranslations() works in here — the locale resolves on its own
// and becomes part of the cache key. Never pass it as a parameter.
}Tag both broad and scoped — a broad tag plus a per-row one like brand:<id>, written as a template literal in real code. New scoped tags use : as the separator.
cacheLife profiles are named, defined once in packages/next-config/index.ts, and assigned per app. In use: listings, categories, cart, plus the generic frequent, hours, days, reference, max. Add a profile there rather than inlining a duration.
Why a big key space is a problem
Cached entries are keyed by arguments. At build time, Next prerenders pages concurrently and fills the entries they touch — each fill taking a database connection. A query keyed by something with hundreds of values, times four locales, is hundreds of concurrent connections against a pool that holds tens.
That is why staticGenerationMaxConcurrency is 3 in web and 1 in admin, and why an unbounded key space uses React cache() instead. get-brand-by-id.query.ts carries the comment explaining it: 200+ brands × locales is 600+ entries for no benefit.
"use cache: private" is not a server cache
It is not a smaller "use cache". Results are never stored on the server — they live in the browser, and the function still runs on every server render.
Its one real use here: letting the app shell carry a session-dependent section ahead of the click on a route that has opted out of validation, and only with a stale time of at least five minutes. A private cache with a short stale, or with no cacheTag an action can invalidate, is worse than no cache at all.
Per-user data is a plain dynamic read behind <Suspense> unless you can say specifically why not. Where a per-user read is cached, it is an ordinary shared "use cache" keyed by the user id and tagged with it — see listings/queries/get-listings-user-active.query.ts.
Invalidation
Three tools, three jobs.
| Call | Use it when |
|---|---|
updateTag(tag) | Inside a server action, when the user must see their own write on the very next response. The default in an action. |
revalidateTag(tag, profile | { expire }) | Route handlers, webhooks, cron — no reader to satisfy. Never the single-argument form; it's deprecated in 16.3 |
revalidatePath(path, type) | A route whose reads are React cache() or plain dynamic and would otherwise serve a stale payload. Wrong for tag-cached data, and wrong in admin |
The tag string is the query's own cacheTag, exported as a constant from the same feature. An admin edit to a catalogue the storefront caches — categories, colours, conditions, brands — must updateTag that tag. A revalidatePath on the admin route reaches no shopper.
Locale
Locale is never a parameter and never a prop. It resolves from the [locale] root param and is automatically part of the cache key, so getTranslations() works inside "use cache" with no wiring. See Locale resolution.
Things that will bite
dynamic = 'force-dynamic'does not exist under cacheComponents. The fix for a blocking-prerender error is a<Suspense>boundary, or"use cache"— neverconnection().- Don't
"use cache"a component whose only job is calling an already-cached query. You have added a second cache around one result. - A cached read reachable from a prerendered admin route is filled during
next build, against the production database, and can time out the deploy. Guard it withawait io()fromnext/cachebefore the first cached read — it suspends only during prerender and keeps prefetching alive. - Never read the clock with
new Date()on a render path. Read it in SQL instead.
Related
The full page/query/action/navigation rules live in the cute-app-architecture skill (.claude/skills/cute-app-architecture/SKILL.md). Load it before adding or refactoring a page.