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 dataThe shape
Shared and public — categories, colours, a listing"use cache" + cacheTag + cacheLife
Must be fresh every request — a cart total, an order's statusA plain async read behind <Suspense>. Not "use cache"
Unbounded key space — free-text search, offset paginationA 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.

CallUse 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" — never connection().
  • 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 with await io() from next/cache before 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.

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.