
Next.js revalidateTag
Caching only earns its keep if you have a reliable way to say "this specific piece of data is now wrong, go get it again." revalidateTag is that mechanism for anything cached under a tag in a Next.js App Router application — a blog post that just got edited, a product whose price changed, a documentation page that was republished. Instead of tearing down your entire cache or waiting for a timer to expire, you invalidate exactly the tagged data that's now stale and let everything else keep serving instantly.
What makes revalidateTag worth a dedicated look, rather than treating it as a one-line utility, is that its signature actually changed in recent Next.js releases, and the semantics behind that change matter a lot for how your app behaves under load. Get it wrong and you'll either serve stale content indefinitely or turn every revalidation into a blocking request that stalls real users. This article walks through the current API in full: how tags get attached to cached data in the first place, exactly what the second argument controls, where you're allowed to call it from, and how it relates to its siblings revalidatePath and updateTag.
What Problem revalidateTag Actually Solves
Think about a product catalog page cached with 'use cache' or a tagged fetch call. Under normal circumstances, that cache might live for hours or days, because product data doesn't change every minute. But the moment an admin updates a price, that cached page becomes wrong. You have two bad options if you don't have on-demand invalidation: rebuild the whole cache on a tight timer (wasteful, and still stale between updates), or don't cache the page at all (slow, for no benefit most of the time).
revalidateTag gives you a third option: cache aggressively, and invalidate precisely, exactly when something changes. You tag the cached data with a string identifier when it's created, and later, whenever the underlying data actually changes, you call revalidateTag with that same string. Only data carrying that tag gets marked stale — everything else in your cache stays untouched.
Where You Can Call It
revalidateTag only works in server environments, and specifically inside Server Functions (Server Actions) and Route Handlers. You cannot call it from a Client Component, and you cannot call it from Proxy (the file that replaced Middleware) — Proxy runs in an environment that isn't wired into the App Router's cache invalidation system the same way.
This restriction usually isn't a problem in practice. The two places you're allowed to call it map neatly onto the two realistic triggers for invalidation:
- Server Actions — a user submits a form, mutates data, and you revalidate the tags tied to whatever they just changed, all in one server-side round trip.
- Route Handlers — an external system (a CMS webhook, a payment provider, a cron job) calls an endpoint you expose specifically to trigger revalidation from outside your app's own request/response cycle.
Assigning Tags in the First Place
revalidateTag is useless without something to revalidate — tags have to be attached to cached data before you can invalidate them. There are two ways to do that.
Tagging a fetch call, when you're caching an external API request:
fetch(url, { next: { tags: ["posts"] } });
Tagging a cached function or component using cacheTag inside a block marked with the 'use cache' directive:
import { cacheTag } from "next/cache";
async function getData() {
"use cache";
cacheTag("posts");
// ...fetch or compute data here
}
Either approach attaches the string "posts" to whatever gets cached. Later, calling revalidateTag("posts", ...) invalidates every piece of cached data anywhere in your app carrying that tag — not just one page, but every route, layout, or fetch call that happened to use it. That's the whole point: tags let you invalidate by meaning ("all posts data") rather than by location ("this specific URL").
One practical constraint worth internalizing early: tags are case-sensitive strings, capped at 256 characters. A tag longer than that limit silently never gets assigned to cached data in the first place — which means calling revalidateTag with it later does nothing, with no error to tell you why. If a revalidation call seems to have no effect, check the tag length before you check anything else.
The Signature: tag and profile
revalidateTag(tag: string, profile: string | { expire?: number }): void;
The tag parameter is the string you assigned earlier. The profile parameter is the part that's easy to get wrong, because it controls something subtler than "revalidate now" — it controls how stale content is allowed to be served while the revalidation is in flight.
profile="max" (the recommended default): this is a stale-while-revalidate window of about a year — in practice, "always serve stale content while the fresh version loads in the background." The request that triggers the revalidation still gets a response instantly (the stale one), and the next request after the revalidation completes gets the fresh version. Nobody blocks.
A named or custom cacheLife profile: you can pass any profile defined in your cacheLife configuration, and only its expire value is read — it sets a specific window instead of the effectively-unlimited one that "max" gives you.
An object with expire, e.g. { expire: 0 }: this means stale content is never served. The very next request for that tag becomes a blocking cache miss — it waits for the revalidation to finish before responding. This is the option you reach for when correctness matters more than speed for that specific invalidation, or when you have no other way to guarantee freshness (see the webhook example below).
Omitting the second argument entirely: this is deprecated. It behaves the same as { expire: 0 } — a hard, blocking invalidation — but currently only works if you suppress a TypeScript error to call it. If you see old code calling revalidateTag(tag) with one argument, that's legacy usage; it should be migrated to either revalidateTag(tag, "max") or, if the goal was actually "revalidate and reflect the change to the current user immediately," to updateTag instead.
The mental model worth keeping: profile is where you decide the tradeoff between always fast and always correct for this particular invalidation. Most content — blog posts, product listings, documentation — tolerates a brief window of staleness far better than it tolerates every visitor blocking on a fresh fetch, which is exactly why "max" is the recommended default rather than an edge case.
What revalidateTag Actually Does When Called
Calling revalidateTag doesn't immediately regenerate anything. It marks the tagged data as stale. The actual work of fetching fresh data happens on the next request that touches that data — not synchronously inside your revalidateTag call, and not proactively for every page that used the tag.
That has a concrete implication worth calling out because it surprises people: if you have a thousand pages tagged "posts", calling revalidateTag("posts", "max") doesn't regenerate all thousand pages at once. It marks all of them stale, and each one individually revalidates the next time someone actually visits it. Pages nobody visits stay stale (serving the old cached copy) indefinitely — which is usually fine, since nobody's looking at them, but it does mean "I revalidated the tag" is not the same claim as "all affected pages are now freshly rendered."
revalidateTag vs. revalidatePath
Both functions invalidate cached data, but they operate on different axes. revalidateTag invalidates by tag, reaching every piece of cached data anywhere in the app that carries that tag, regardless of which route rendered it. revalidatePath invalidates by path, targeting a specific page or layout URL directly.
In practice these solve different shapes of problem. If a single product's price changes and only one page shows that product, revalidatePath for that one URL is simpler. If a blog post is updated and that post's content is rendered on the post page, the homepage's recent-posts list, an RSS-adjacent Route Handler, and a sitemap generator — all four tagged "posts" — revalidateTag("posts", ...) clears all four in one call without you needing to know or enumerate every URL that happened to reference that tag. Tags decouple "what changed" from "which URLs show it," which is exactly the coupling that gets brittle to maintain by hand as an app grows.
They're not mutually exclusive, either — a mutation that affects both a specific page's own path and a broader category tag may legitimately call both functions.
revalidateTag vs. updateTag
The other function worth distinguishing is updateTag, which exists specifically for the Server Action case. updateTag revalidates a tag and makes the current Server Action's response reflect the updated data immediately, in the same round trip — no stale-serving window, no separate follow-up request needed to see the fresh result. revalidateTag, by contrast, always leaves at least one request cycle between "call this function" and "see the fresh data," governed by whatever profile you passed.
If you're inside a Server Action and want the user who just triggered the change to see the result of their own action immediately, updateTag is usually the better fit. revalidateTag is the right tool when the caller doesn't need (or can't get) an immediate reflection of the change — most commonly because the invalidation isn't happening inside a Server Action at all.
Worked Example: Server Action
The common case — a user submits something, and you want future visitors (not necessarily this one, immediately) to see the update, tolerating brief staleness in exchange for speed:
// app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export default async function submit() {
await addPost();
revalidateTag("posts", "max");
}
Because "max" is used, this call returns fast, the currently-cached (now-marked-stale) version keeps serving for up to about a year of headroom, and the actual regeneration happens transparently on the next real request.
Worked Example: Route Handler Triggered Externally
The other realistic trigger is something outside your Server Action lifecycle entirely — a CMS webhook firing when an editor publishes a change, for instance. Here you expose a Route Handler specifically for this purpose:
// app/api/revalidate/route.ts
import type { NextRequest } from "next/server";
import { revalidateTag } from "next/cache";
export async function GET(request: NextRequest) {
const tag = request.nextUrl.searchParams.get("tag");
if (tag) {
revalidateTag(tag, "max");
return Response.json({ revalidated: true, now: Date.now() });
}
return Response.json({
revalidated: false,
now: Date.now(),
message: "Missing tag to revalidate",
});
}
This is also the situation where updateTag genuinely isn't an option — there's no Server Action response to attach an immediate update to, since the caller is a webhook, not a user's browser. If the source of the invalidation needs the data gone immediately, with no stale-serving window at all, pass { expire: 0 } instead of "max":
revalidateTag(tag, { expire: 0 });
That trades "always fast" for "always correct" for this specific call — the next request after this one blocks until fresh data is ready, rather than serving a stale copy while it loads.
Common Mistakes
Forgetting to tag the data in the first place. revalidateTag("posts", "max") does nothing if nothing cached actually carries the "posts" tag — there's no error, just silent no-op behavior. Always confirm the tag exists on the fetch call or cacheTag invocation before assuming the invalidation function is broken.
Using an unsuppressed single-argument call and assuming it behaves like "max". The deprecated single-argument form is a hard, blocking invalidation (equivalent to { expire: 0 }), not a lazy stale-while-revalidate one. If you copy old example code that only passes a tag, you'll get blocking behavior you probably didn't intend.
Expecting all affected pages to regenerate the instant revalidateTag returns. It only marks data stale; regeneration happens per-page, on that page's next visit. If you need to verify a change took effect, visit the actual page — don't just trust that the function call itself did the rendering work.
Reaching for revalidateTag inside a Server Action when you actually wanted the current user to see the fresh result immediately. That's what updateTag is for. Using revalidateTag there works, but the acting user may see their own stale data for one more request cycle, which usually reads as a bug to them ("I just changed this and it didn't update").
Tag strings over 256 characters. They're silently never assigned, so revalidating them is a no-op. Keep tags short and semantic ("posts", "product-42") rather than encoding long structured data into the tag string itself.
Key Takeaways
| Question | Answer |
|---|---|
| What does it invalidate? | Every piece of cached data anywhere in the app carrying the given tag |
| Where can you call it? | Server Functions (Server Actions) and Route Handlers only — not Client Components, not Proxy |
| How do you assign tags? | fetch(url, { next: { tags: [...] } }), or cacheTag(...) inside a 'use cache' block |
| Recommended second argument | "max" — stale-while-revalidate, effectively always fast |
Use { expire: 0 } when | You need correctness over speed, and can't use updateTag (e.g. webhook-triggered) |
| Does it regenerate pages immediately? | No — it marks data stale; regeneration happens on that page's next request |
vs. revalidatePath | Tag-based (by data identity) vs. path-based (by URL) |
vs. updateTag | No immediate reflection in the current response vs. immediate reflection within a Server Action |
revalidateTag is a small function with a genuinely deep set of tradeoffs packed into one parameter. Getting the profile argument right — reaching for "max" by default and { expire: 0 } only when correctness truly can't wait — is most of what separates a cache invalidation strategy that scales gracefully from one that quietly turns every mutation into a blocking request under load.


