EMZETT.
Login

Cache Tags and revalidateTag (Next.js)

In short: Next.js can cache server data under a named “tag” (instead of only by time) — a targeted call to revalidateTag() then invalidates ONLY the cached data carrying that tag, without touching the rest of the cache.

In more detail: Allows a combination of TTL (time-to-live, as a safety net) and targeted, immediate invalidation on exactly those actions that really change the underlying data. Since Next.js 16, revalidateTag() requires a second argument (a cache profile, e.g. "max") — it controls stale-while-revalidate behaviour: the tag is marked as stale, but no longer necessarily purged immediately and hard for all clients, as in older Next.js versions without this second argument.

Our context: At Emzett, app/utils/homeContentCache.ts caches homepage posts/featured reviews with unstable_cache, a 60s TTL + targeted invalidation via revalidateTag(HOME_CONTENT_CACHE_TAG, "max") on every post change. Deliberately NOT used for the shop’s product list — live stock for limited drops has to be exact; a cached “only 3 left” could lead to overselling.

In Depth

Basic pattern

A function is wrapped with unstable_cache() and is given one or more tags along the way:

const getHomeContent = unstable_cache(
  async () => db.query.posts.findMany({ where: eq(posts.featured, true) }),
  ["home-content"],
  { tags: [HOME_CONTENT_CACHE_TAG], revalidate: 60 },
);

When a post changes, the responsible server action calls revalidateTag(HOME_CONTENT_CACHE_TAG, "max") — the cache entry is declared invalid immediately, regardless of the still-running 60-second TTL. This combines the advantages of both worlds: TTL as a safety net (if an invalidation is ever forgotten, the cache is fresh again after the TTL at the latest), targeted invalidation for immediate consistency on known changes.

Why not simply one global tag for everything?

Important with several independent cache areas: use tags that are as specific and distinct as possible (instead of one single global tag for everything) — otherwise a small change in one place needlessly invalidates completely different, unchanged caches too, which undermines the point of caching (less database load). A common convention is to name tags hierarchically (e.g. posts, posts:featured, posts:${postId}) and on a change invalidate only the affected level — a single changed post doesn’t necessarily have to invalidate the entire featured list if it isn’t featured itself.

Difference from time-based caching alone

Pure TTL-based caching (revalidate: 60 without tags) is simpler, but has a structural drawback: between a real data change and the next TTL expiry, users see stale data — with a 60-second TTL, in the worst case for almost a whole minute. Tag-based invalidation practically closes this window to zero for KNOWN changes, while the TTL still acts as a safety net for UNKNOWN or forgotten change paths (e.g. a direct database intervention outside the regular code path).

The second argument since Next.js 16

The second argument of revalidateTag(), mandatory since Next.js 16 (a cache profile such as "max"), controls how aggressive the invalidation is. Earlier Next.js versions only knew a hard, immediate purge for all clients at once — the new, more granular model instead allows stale-while-revalidate-like behaviour, where responses that have already been delivered and are still held in the browser/CDN don’t necessarily have to be discarded instantly but can be refreshed in the background. This is one of the Next.js 16 pitfalls where older training knowledge can mislead AI assistants, since revalidateTag() used to accept only a single argument.

See also: Next.js App Router, React.cache(), TTL