Skip to content

Next.js revalidateTag: invalidate only what changed in your ISR cache

Time-based revalidate is a floor, not a strategy: tags plus on-demand revalidation refresh only what changed. A working flow, reference code and the traps.

BySergio Jáuregui

7 min read

Cover on Next.js revalidateTag: a cyan node diagram where a single red node sends a pulse to a page while the rest stay dark.

Next.js revalidateTag invalidates the ISR cache entries marked with a tag, without waiting for the time-based revalidate to expire. It is what lets a page update as soon as its data changes, instead of relying on an interval. Time-based revalidate stays as a safety net, not as the strategy.

This guide walks through the full pattern: tagging fetches, protecting the revalidation endpoint, firing invalidation from the backend and checking that it works. The code fragments are a reference implementation written for Next.js 14 with the App Router; on newer versions, check the signature of revalidateTag in the documentation.

Why time-based revalidate falls short

Time-based revalidate regenerates an entry when it expires: between an edit and the expiry, the page serves the previous HTML. A shorter interval refreshes sooner but hits the backend harder; a longer one spares the backend but stretches staleness. It is a floor, not a strategy. The strategy is to invalidate exactly what changed, at the moment it changes.

A sensible default is a moderate revalidate for almost everything, with no-store reserved for what must never be cached, such as a draft or a preview. On that base, tags take care of the rest.

How to tag each fetch for Next.js revalidateTag

Tagging means binding each fetch to one or more names so you can invalidate it later by name. The revalidateTag documentation describes the semantics: you call the function with a tag, and the entries carrying it are invalidated on demand.

A useful pattern pairs a coarse tag for the collection with a fine tag for the single item:

const res = await fetch(`${API_URL}/api/products/${encodeURIComponent(slug)}`, {
  next: { revalidate: 300, tags: ['products', `product:${slug}`] },
});

The coarse tag covers listings and related pages; the fine tag covers the single detail page. Over-invalidating has a reasonable cost: one extra regeneration. Under-invalidating leaves stale content on screen, so the rule is to tag generously and never skip a fetch that shows editable data. Ephemeral content gets no tag at all: it is marked no-store.

One detail that often goes unnoticed: a fetch without a tag is cached by time, but on-demand revalidation cannot reach it. If the data must refresh when it is edited, the tag belongs there from day one.

The revalidation endpoint is protected by a secret

Each frontend exposes a POST route that takes tags and invalidates them. Unprotected, anyone could flush your cache at will and aim traffic at the backend. So the endpoint requires a header carrying a secret shared only between backend and frontend, and it returns 401 when the header is missing or wrong. If no secret is configured, the endpoint stays disabled.

import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const expected = process.env.REVALIDATE_SECRET;
  const provided = request.headers.get('x-revalidate-secret');

  if (!expected || provided !== expected) {
    return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
  }

  const body = await request.json().catch(() => ({}));
  const tags: string[] =
    Array.isArray(body?.tags) && body.tags.length > 0 ? body.tags : ['products'];

  tags.forEach((tag) => revalidateTag(tag));
  return NextResponse.json({ success: true, revalidated: tags });
}

Accepting a list of tags keeps the endpoint from being tied to a single one. The response echoes the invalidated tags, which is the first thing to check when you test it by hand. Never accept invalidations over an open GET, and never expose the secret to the client.

Fire revalidation from the backend and move on

After a mutation that changes what the page shows, the backend notifies the frontend. The call should be fire-and-forget: it does not wait for the response, it never throws, and it does nothing when configuration is missing. That way the administrator's response does not depend on the frontend being reachable at that instant.

export function revalidateFrontend(url: string | undefined, tags: string[]): void {
  const secret = process.env.REVALIDATE_SECRET;
  if (!url || !secret) return;

  fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-revalidate-secret': secret },
    body: JSON.stringify({ tags }),
  })
    .then((res) => {
      if (!res.ok) console.warn('Revalidate request failed', res.status);
    })
    .catch((err) => console.warn('Revalidate request error', err));
}

// After each mutation:
await prisma.product.update({ where: { id }, data });
revalidateFrontend(process.env.FRONTEND_REVALIDATE_URL, ['products']);

A useful complement is keeping a cache version on the backend, a counter or timestamp that changes on every mutation and travels alongside the tags. It helps when debugging: if the version did not move, the mutation never reached the trigger.

The point most often forgotten is coverage. Creating, editing, deleting and publishing are the obvious cases. Bulk imports, changes to auxiliary tables such as categories, and redirects tend to slip through. Before calling the pattern finished, walk through every write path and ask whether it changes something the frontend displays.

The traps worth knowing

The first is the silent no-op. Without environment variables, or with the frontend down at the moment of the call, the edit saves but the page keeps serving old HTML until the interval lapses. The API answers with success regardless, so only the log raises a flag. That warning must be recorded and actually reviewed.

The second is the missing tag. A fetch that does not declare the tag the route invalidates never refreshes through that path, and nothing fails loudly. Check that every invalidated tag exists on some fetch, and the reverse.

The third is one instance versus several. Next's data cache lives in each server instance. With a single container, one POST is enough. If you scale to several replicas behind a load balancer, one POST reaches just one of them while the others keep serving stale content until their entries expire. That calls for sending the invalidation to every instance, or accepting the interval as the limit.

The fourth is deployment order. If the backend starts firing invalidations at a frontend route that does not exist yet, the POSTs fail quietly. Deploy the frontend with the revalidation route first, then the backend that calls it.

How to check that revalidation works

Do it in two steps. First the endpoint: a POST without the header must return 401, and one with the correct secret must return the list of invalidated tags. That confirms the route exists, the secret matches and revalidateTag ran.

Then the full flow: edit the data, check the warning or the version in the log, and reload the page before the interval expires. If the change already shows, the pattern works; if it appears only after the interval lapses, something failed silently. The test is behavioural, before or after the interval, and needs no special tooling.

When not to use on-demand revalidation

Drafts and previews should never be cached, so there is nothing to invalidate: no-store is the answer. Pages that change on every visit gain nothing either, because caching data that is never reused only adds useless invalidations.

If you cannot protect the endpoint, do not build it: an open POST invites anyone to aim traffic at your own backend. And if the content changes rarely and a short interval already feels fresh, plain time-based revalidate means fewer moving parts and fewer silent failures.

A practical rule: on-demand revalidation for published content that people edit by hand and many read; a plain interval for the rest. Both coexist in the App Router, and mixing them per route is common.

For reference, the blog at sergioja.com follows this same idea: its fetches are tagged and a protected endpoint receives invalidations from the backend. Its general architecture is described in the reference implementation.

Tag your fetches and wire the trigger today

Add tags to every fetch that must refresh when edited, build the route with a secret check, call invalidation from each mutation without blocking the response, and test the 401 and the full flow. Keep the interval as your net: when something fails silently, the page heals on expiry.

Caching is only one performance front. The guide on WebP thumbnails with sharp shows how to serve small images from an LRU cache instead of giant base64 payloads, and the Next.js hreflang guide covers the less visible half: helping Google understand a bilingual site. If you want this pattern on your site, write to me through the contact page.

Have a project in mind?

I'm Sergio Jáuregui, a Full Stack developer. If you enjoyed this article, take a look at my work or drop me a line.

Keep reading

Updated on Oct 15, 2026

Back to top