skip to content

In the Next.js App Router, what does `next: { tags: ['products'] }` on a `fetch` actually do, and what must be true of that fetch for the tag to have any effect?

level: middleimportance: should knowfreq 48%

answer

  1. a label, not a behaviour
  2. addressing for later invalidation
  3. nothing stored, nothing labelled
  4. exact string match, no wildcard
  5. pair it with force-cache or revalidate

basics

~20 s

The tag is a label written onto the Data Cache entry that fetch creates, so the entry can later be invalidated by name. It does nothing unless the fetch is actually cached — an uncached fetch stores no entry to label.

solid answer

~50 s

`next.tags` attaches one or more string labels to the **Data Cache entry** that fetch produces. The tag does not change what is fetched, when it is fetched, or how long it lives; it exists purely so something can later say "invalidate everything labelled `products`" — that is what `revalidateTag` from `next/cache` does — without knowing the URLs involved. The precondition is the part people miss: a tag labels an entry, so the fetch has to *create* one. Under Next 15 and 16 defaults a bare `fetch` is uncached, and `cache: 'no-store'` is explicitly uncached, so tagging either of those writes the label onto nothing at all. Pair `tags` with `cache: 'force-cache'` or `next: { revalidate: N }`. Tags are compared as plain strings — there is no wildcard — so `products` never matches `products:42` unless you attach both.

go deeper

for a junior

Know that a tag is just a name attached to cached data so it can be cleared later by that name, and that it only matters if the fetch is actually being cached.

for a middle

Be ready to separate the three fetch options: cache decides whether anything is stored, revalidate decides how long it stays current, and tags only give the stored entry a name. Explain why a tagged uncached fetch is inert.

for a senior

Show that you would diagnose 'invalidation does nothing' by checking whether an entry exists at all before checking the tag strings, and that you recognise the post-upgrade version of that failure.

for a principal

Own the tag vocabulary: decide the granularity scheme across the codebase and keep the names in one place, so invalidation blast radius is a designed property rather than whatever string each author happened to type.

## What a tag is A tag is a name you write onto a cache entry so that something else can find it later. That is all. `next: { tags: ['products'] }` does not fetch differently, does not change the entry's lifetime, and does not make anything happen on its own. It is the addressing scheme for invalidation. The motivation is decoupling. Without tags, the code that knows *the products changed* would have to know every URL that had ever been fetched to render products — including URLs assembled from parameters it has never seen. With tags, the fetch declares its subject, and the mutation names the same subject. ```ts await fetch('https://api.example.com/products?page=3', { next: { tags: ['products'], revalidate: 3600 }, }) ``` ## The precondition everyone trips over A label needs something to be attached to. If the fetch does not write a Data Cache entry, the tag is inert. - `cache: 'no-store'` plus `tags` — nothing is stored, so nothing is tagged. - A bare `fetch` plus `tags` under Next 15 and 16 defaults — the default is uncached, so again nothing is stored. On Next 14's cached-by-default behaviour the same code *did* work, which is why this shows up as a mysterious regression after an upgrade rather than as new code that never worked. The symptom is specific and worth recognising: invalidation "does nothing." Data does not update after a mutation, no error appears anywhere, and the tag string matches perfectly on both sides. The bug is upstream of the matching — there was never an entry. The fix is to make the fetch cached and tagged together: ```ts // inert: nothing stored, so nothing labelled await fetch(url, { cache: 'no-store', next: { tags: ['products'] } }) // effective: an entry exists, and it carries the label await fetch(url, { cache: 'force-cache', next: { tags: ['products'] } }) ``` ## Granularity: tags are strings, matched exactly Tags are compared by equality. There is no prefix matching and no wildcard, so a tag of `products` will not be reached by a request to invalidate `products:42`, and vice versa. That pushes a design decision onto you at fetch time, and it is worth making deliberately. A common shape is to attach several tags of different granularity to the same fetch: ```ts await fetch(`https://api.example.com/products/${id}`, { next: { tags: ['products', `product:${id}`], revalidate: 3600 }, }) ``` Now a single-item edit can target `product:42`, and a bulk import can target `products`, and both find this entry. The cost of an extra tag is small; the cost of discovering later that your only tag was too coarse is a full-catalogue invalidation on every single edit. The opposite failure is worth naming too: a tag that is too fine, derived from something like a request ID or a timestamp, is unique per entry and therefore matches nothing anyone would ever ask for. ## Where tags live relative to the other options It helps to see the three fetch options as answering three different questions: - `cache` — should this response be stored at all? - `next.revalidate` — for how long should the stored copy be considered current? - `next.tags` — under what name can the stored copy be found and invalidated? They are independent, and only the first two can opt a fetch into caching. `tags` is a passenger: useful only once one of the other two has done its job. Reading the option list this way makes the precondition obvious rather than surprising, and it is a clean way to present the answer in an interview. ## Practical notes - Tags are per *entry*, not per route or per component. Two fetches in the same file carry whatever tags each one declares. - Because entries are keyed from URL plus options, one tag legitimately covers many entries — every page of a paginated list, every locale variant — which is precisely the fan-out that makes tags worth using. - Keep the vocabulary of tag names in one module rather than typing string literals at each call site; a typo produces no error, just an entry that invalidation never reaches.

  • Why attach two tags of different granularity to the same fetch?
    So the entry can be reached from more than one kind of change. A tag like `product:42` lets a single-item edit invalidate only what that edit affected, while a broader `products` tag lets a bulk import clear the lot. Since matching is exact, an entry carrying only the coarse tag cannot be targeted precisely, and one carrying only the fine tag is missed by bulk operations.
  • A team reports that tagging worked before their Next upgrade and silently stopped afterwards. What changed?
    Almost certainly the fetch caching default. On Next 13.4–14 a bare `fetch` was cached, so a tagged fetch had an entry to label without anyone opting in. From Next 15 the default is uncached, so the same code stores nothing and the tag has no target. The fix is to add `cache: 'force-cache'` or a `revalidate` interval alongside the tags.
  • Does adding `next.tags` on its own ever change how often data is fetched?
    No. Tags are inert with respect to fetching and expiry — they only give an existing entry a name. Freshness comes from `revalidate` or from an explicit invalidation targeting the tag. If data is refreshing more or less often than you expect, the tag is not the variable to change.

saying these in an interview costs you the question

  • Believing a tag opts the fetch into caching
  • Tagging a no-store fetch and expecting invalidation to work
  • Expecting prefix or wildcard matching between tag names
  • Thinking tags are attached to the route rather than the entry
  • Deriving tags from request-unique values like a trace id

context