Home / Articles / Understanding Next.js 16's "use cache" Directive and Tag-Based Revalidation

This article is published in English.

Understanding Next.js 16's "use cache" Directive and Tag-Based Revalidation

Learn how the "use cache" directive works in Next.js 16, its companion revalidation functions, and how to apply tenant-aware caching in multi-tenant apps.

1215 words

Caching in Next.js has traditionally felt like a bit of a black box — a patchwork of file-level settings, options passed to fetch, and framework defaults that shifted enough between versions that even seasoned developers kept the documentation open just to be safe. The "use cache" directive introduced in Next.js 16, part of the broader Cache Components model, changes that by letting you declare caching behavior explicitly at the level of a component or function, rather than inheriting it implicitly from defaults you're expected to remember.

Below is a breakdown of what the directive actually does and the situations where it makes sense to use it.

What the directive actually does

Placing "use cache" inside a function or component tells Next.js to cache whatever that function returns. Conceptually it plays a role similar to "use client", except instead of marking a client-side boundary, it marks a caching boundary:

async function getDashboardStats(tenantId: string) {
  "use cache";
  const stats = await db.query.stats.findMany({ where: { tenantId } });
  return stats;
}

The output of the function is cached and keyed based on the arguments passed in, then reused across subsequent requests until something invalidates it. That's a real departure from the older approach of caching an individual fetch call or an entire route segment: now you can cache at whatever level of granularity fits your data, right down to one specific function.

The three companion functions

Alongside the directive, Cache Components introduce a small set of APIs for managing cached data on purpose, instead of just waiting for a timer to expire:

  • revalidateTag(tag) — clears every cache entry associated with a given tag. This is handy when a single mutation touches data that several different cached functions rely on.
  • updateTag(tag) — a narrower version of the same idea, aimed at updating specific cache entries tied to one tag rather than everything under it.
  • refresh() — refreshes cached data scoped to the current request.

Here's the pattern that makes the whole system click: attach a meaningful tag to each cached function — something like tenant-stats or invoice-list — and whenever a mutation happens that affects that underlying data, call revalidateTag with the matching tag instead of trying to pick a time-based expiry window that's either too short (defeating the purpose of caching) or too long (serving stale data).

async function getInvoices(tenantId: string) {
  "use cache";
  cacheTag(`invoices-${tenantId}`);
  return db.query.invoices.findMany({ where: { tenantId } });
}
// After creating an invoice:
async function createInvoice(data: InvoiceInput) {
  await db.insert(invoices).values(data);
  revalidateTag(`invoices-${data.tenantId}`);
}

That combination — tagging on the read side, revalidating on the write side — is really the whole idea in miniature. Nearly everything else you'll do with this system is a variation of that same pairing.

Common points of confusion

The mistake teams run into most often is assuming "use cache" is a straightforward substitute for the next: { revalidate } option on fetch(). They actually address different, if related, problems. The fetch-level option governs a single network request. "use cache" caches the result of an entire function or component, which could be doing far more than one thing internally — hitting a database, running some computation, maybe even calling fetch itself along the way. If all you need is to cache one external API call, sticking with fetch-level caching is usually the simpler choice. "use cache" starts to pay off once you want to cache a whole computed output, not just a single request feeding into it.

The second frequent mistake is skipping the tagging step entirely, then being confused later when a mutation fails to clear the cached data it should have. Without a tag attached, the only invalidation mechanism you have is time, which undercuts much of the reason for adopting this model in the first place — you've taken on the extra complexity of explicit caching without gaining explicit control over invalidation.

Tenant-aware tags for multi-tenant apps

If you're working on anything multi-tenant, there's a detail worth flagging directly: tags need to encode the tenant, not just the type of data being cached. A generic tag like invoices shared across all tenants means that invalidating one tenant's data invalidates it for everyone — which either introduces a correctness issue (one tenant seeing stale results because another tenant's mutation triggered a shared revalidation) or a performance issue (the cache getting cleared far more often than necessary). Something like invoices-${tenantId}, as used in the example above, isn't a stylistic preference — it's what separates a working invalidation strategy from a broken one.

Why it pays to set this up correctly from the start

A caching layer with a subtle flaw rarely breaks in an obvious way. Instead, it tends to surface as vague support tickets along the lines of "why does this dashboard still show last week's numbers" — bugs that are genuinely painful to trace back, because the faulty invalidation path is often somewhere nobody has touched in months. Nailing down a consistent tagging strategy early, applied uniformly across every cached function in the codebase, is one of those infrastructure choices that's inexpensive to do properly at the outset and considerably more costly to fix later.

Some dashboard starter kits build their data layer around exactly this discipline. As one example, a template such as Ovyqen's dashboard template for Next.js and SaaS projects applies the tag-on-read, revalidate-on-write pairing throughout, folding tenant identifiers into every tag from the outset rather than patching them in after a cross-tenant caching mishap. If you're weighing dashboard templates and cache invalidation is the piece of your existing app nobody quite trusts, that's a reasonable reason to start from a foundation that already treats this correctly.

Frequently asked questions

Should every fetch() call be migrated to "use cache"? Not necessarily — the two tools overlap but aren't interchangeable. Fetch-level caching is still fine for simple, single-request scenarios. Reach for "use cache" when you need to cache a computed result or the output of an entire component.

Is "use cache" mature enough for production dashboards? Approach it the same way you would any relatively young caching mechanism — thoroughly exercise your invalidation paths, particularly for multi-tenant data, before depending on it for anything customer-facing where staleness matters.

What happens if a cached function is left untagged? Caching still happens, but you lose the ability to invalidate it deliberately in response to a specific event. You're left depending solely on time-based expiry, which is rarely the behavior you actually want.

Explicit caching demands more upfront effort than simply trusting a framework's defaults. But for dashboard data where showing stale information has a real cost, that extra effort is almost always the right trade to make.