Next.js 16 updateTag vs revalidateTag: What Changed
Next.js 16 deprecated revalidateTag's single-argument call and added updateTag. What the new profile argument changes, plus a copy-paste cheat sheet.

Your team upgrades to Next.js 16, the build passes, and nothing in the diff touches caching. A week later, publishing a blog post starts taking noticeably longer — not to save, but for the next reader to see it. Somewhere in a Server Action sits a line nobody touched: revalidateTag('posts'). It still runs. It just does something different now.
That line used to mark the posts tag stale and move on — the next visitor got the old page instantly while Next.js refreshed it in the background. In Next.js 16, the same one-argument call is deprecated, and its behavior changed to match an immediate, blocking cache miss: the next request to anything tagged posts now waits for fresh data before it renders. No error, no red build — just a slower app and a cache-invalidation story that quietly inverted.
By the end of this article you'll be able to:
- Explain exactly what changed in
revalidateTag's signature in Next.js 16, and why old, un-migrated calls now behave differently than they did in 15 - Use the new
profileargument ("max", a customcacheLifename, or{ expire }) to choose stale-while-revalidate on purpose instead of by accident - Reach for
updateTagcorrectly — and know the one restriction that makes it unusable outside a Server Action - Pick the right tool for three real situations: a Server Action after a write, a webhook Route Handler, and "the reader must see their own change immediately"
- Read the TypeScript error a bare
revalidateTag(tag)now produces, and migrate it correctly
You've used revalidateTag or cacheTag in an App Router project before — maybe to invalidate a list page after a mutation. You don't need to have used Cache Components; this article explains the pieces it touches as it goes.
This article is written against Next.js 16.4 (verified against the framework's own next/cache API documentation and the v16.0.0 release notes from October 2025, where both the new updateTag function and revalidateTag's new signature shipped). Where the pre-16 behavior differs, it's stated explicitly rather than assumed.
- The problem: a call that still compiles, now does less for free
- The mental model: two knobs, not one
- Stage 1: what the deprecated call actually does now
- Stage 2:
revalidateTag(tag, "max")— keeping the old UX, on purpose - Stage 3:
updateTag— read-your-own-write, from a Server Action only - Stage 4: the webhook case, where
updateTagisn't an option - Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
Here's a Server Action that was correct in Next.js 15 and is still, technically, legal code in 16:
In Next.js 15, this marked every cache entry tagged posts as stale. It didn't refresh anything immediately. The next request that touched a posts-tagged entry got served the stale copy instantly, while Next.js regenerated it in the background — the standard stale-while-revalidate shape, and the reason revalidation never caused a visible slowdown.
In Next.js 16, the same call is deprecated, and its documented behavior now matches passing { expire: 0 } explicitly: the tag's cached entries lose their stale grace period entirely, so the next request becomes a blocking cache miss — it waits for the data to be refetched before it can render anything. If you're on TypeScript with the current next types, you'll actually see this before it ships: the one-argument call no longer type-checks, because revalidateTag's signature now requires a second argument. A plain JavaScript project, or one with type errors suppressed, won't see anything — just a page that got slower for no reason the diff explains.
The mental model: before Next.js 16, "revalidate this tag" was one decision — stale now, refreshed lazily, always. Next.js 16 splits that into two independent knobs, and makes you set both:
- Which tag is now out of date. Unchanged — you still assign tags with
cacheTag('posts')inside a'use cache'function, or thenext: { tags: [...] }option onfetch. - How long is a stale copy still acceptable to serve while fresh data loads. This is the new
profileargument."max"means "basically forever" (a one-year window) — in practice, the next visitor always gets the stale copy, and it's a backstage reader who ever sees a cache miss.{ expire: 0 }means "never" — the very next request must wait. Anything in between is a namedcacheLifeprofile ("hours","days", …) or a custom{ expire: n }in seconds.
updateTag is the same second knob pinned permanently to { expire: 0 }, plus one restriction that revalidateTag doesn't have: it only runs from inside a Server Action. That restriction is the point — it exists for the one case where "the next request should see this" actually means "the request I'm about to send back to the browser that just wrote this should see this," which is a guarantee only a Server Action's own round trip can make.
Don't write the bare call going forward — but it's worth seeing exactly what it degrades to, since it's the code every pre-16 App Router project already has:
Both forms tell Next.js: forget the stale window entirely, and make the next request pay for a fresh fetch. The difference from 15 isn't the tag, isn't the function name — it's that this was never the default behavior before, and now it quietly is.
Most of the time, the 15-era experience — instant stale response, quiet background refresh — is still what you want. In 16 you ask for it explicitly:
Key concept: "max" isn't a special case bolted onto revalidateTag — it's the same named profile cacheLife('max') uses elsewhere (a 30-day background-refresh window, one-year outer expiry). The profile argument accepts any cacheLife profile name or a { expire } object; "max" is simply the one that reproduces the old always-stale-while-revalidating default most closely.
This one-line migration is also the fix for the TypeScript error from Stage 1 — it's the smallest change that both satisfies the new signature and keeps the behavior your app already shipped with.
Runs right in your browser — poke at it and watch the concept react live.
Some flows need the opposite of stale-while-revalidate: the person who just wrote the data must see it on their very next page load, not "eventually." That's what updateTag is for:
updateTag(tag) takes no second argument — it's always an immediate expiry, by design, because "read your own write" has exactly one acceptable staleness window: none. Call it from anywhere else — a Route Handler, a Client Component, proxy.ts — and it throws. That's not a bug to work around; it's the signal that you're in the wrong place for a read-your-own-write guarantee and should reach for revalidateTag instead.
Key concept: updateTag and revalidateTag(tag, { expire: 0 }) end up invalidating the same cache entries the same way. The difference is entirely about where you're allowed to call them and what that implies: updateTag's restriction to Server Actions is what lets Next.js guarantee the very next render — the one already in flight back to that same browser — sees the fresh data.
A CMS webhook that fires when an editor publishes from a completely separate system has no Server Action to call updateTag from — it's a Route Handler, reached by an HTTP request from somewhere else entirely:
revalidateTag works in both Server Actions and Route Handlers; updateTag works in neither except the former. For a webhook, 'max' is almost always right — the editor's CMS doesn't need to see the update instantly, your readers do, and stale-while-revalidate means nobody's request ever blocks on it. Reach for { expire: 0 } only if the content being invalidated must never be served stale again even once — a takedown, a price correction, something wrong enough that one more stale read is a real cost.
- The deprecated one-argument call "works only if TypeScript errors are suppressed." That's the documentation's own description of its current status — the type signature now requires
profile, so a strict TypeScript build already fails on it. If your build is green on a barerevalidateTag(tag)today, either you're on plain JavaScript or something in your config is swallowing the type error; either way, the runtime behavior already changed. - Tags are case-sensitive and capped at 256 characters. A tag longer than that is never actually attached to anything, so revalidating it does nothing — and nothing tells you it silently no-op'd.
- A tag has to exist before you can invalidate it.
cacheTag('posts')inside a'use cache'function, orfetch(url, { next: { tags: ['posts'] } }), is what attaches the tag in the first place. CallingrevalidateTag('posts')for a tag nothing ever assigned is a legal, silent no-op. - The revalidation runs on the next request, not on the call itself.
revalidateTag/updateTagmark data stale or expired; they don't reach out and regenerate it synchronously at call time. A script that callsrevalidateTagfor a thousand tags in a loop doesn't do a thousand regenerations right then — it marks a thousand tags, and each one regenerates whenever its own next request arrives. revalidateTaginvalidates by tag across every page that uses it;revalidatePathinvalidates one path. They're not competitors — a mutation that affects a shared list (tag-based) and a specific detail page (path-based) commonly needs both.- None of these run in
proxy.tsor a Client Component. Cache invalidation is a server-side, request-scoped operation; both functions throw outside a Server Action or Route Handler.
- Default to
revalidateTag(tag, 'max')for anything a normal visitor reads. It reproduces the stale-while-revalidate experience every pre-16 app already had, and it's almost never the wrong call for a list page, a feed, or anything else where "a few seconds old" is indistinguishable from fresh. - Reserve
updateTagfor the exact read-your-own-write moment — right after a Server Action's own mutation, on the page that same action is about to redirect to. Don't reach for it as a general-purpose "make this fresher" tool; that's what theprofileargument onrevalidateTagis for. - Migrate the bare
revalidateTag(tag)calls first, deliberately, not by auto-fixing them to{ expire: 0 }. The codemod or a quick find-and-replace will make the type error go away either way, but silently keeping the new blocking behavior everywhere is exactly the slowdown this article opened with. Decide'max'vs{ expire: 0 }per call site. - Keep webhook-triggered invalidation on
revalidateTag, always.updateTag's Server-Action-only restriction isn't a gap to route around with a trick — treat it as confirmation you're using the right tool for an external trigger. - Pair tag-based and path-based invalidation when a mutation really touches both. A single
revalidateTagcall doesn't help a page that was only ever cached by path.
It didn't get slower by accident — its documented behavior changed. The one-argument form is deprecated and now matches { expire: 0 }: an immediate expiry instead of the old lazy, stale-while-revalidate refresh. Add 'max' as a second argument to restore the pre-16 behavior on purpose.
Not entirely. The one-argument call is deprecated and documented as something that "may be removed in a future version," but revalidateTag(tag, profile) with an explicit profile is the current, supported API — it isn't going anywhere.
No. It only runs inside a Server Action, and calling it anywhere else — a Route Handler, a Client Component, proxy.ts — throws. Use revalidateTag(tag, { expire: 0 }) there instead if you need the same immediate-expiry behavior outside a Server Action.
'max' almost always. The system calling the webhook (a CMS, a headless commerce backend) doesn't need the new data to appear instantly from its own point of view — your readers do, and 'max' means they always get an instant, cached response while Next.js refreshes it behind the scenes.
No. cacheTag() inside a 'use cache' function and the next: { tags: [...] } option on fetch work exactly as before. What changed is only the second half of the pair — how you tell Next.js to treat a tag's existing cached data as stale.
| Situation | Call | Where it runs |
|---|---|---|
| Keep pre-16 behavior (stale-while-revalidate) | revalidateTag(tag, 'max') | Server Action or Route Handler |
| Custom staleness window | revalidateTag(tag, 'hours') or revalidateTag(tag, { expire: 3600 }) | Server Action or Route Handler |
| Reader must see their own write immediately | updateTag(tag) | Server Action only — throws elsewhere |
| Immediate expiry, outside a Server Action (e.g. a webhook) | revalidateTag(tag, { expire: 0 }) | Route Handler |
| ⚠️ Old one-argument call | revalidateTag(tag) | Deprecated — now behaves like { expire: 0 }, and fails to type-check under current next types |
| Invalidate a specific route instead of a tag | revalidatePath(path) | Server Action or Route Handler |
- Next.js 16 split "revalidate this tag" into two decisions: which tag is stale, and how long a stale copy may still be served. The second one is a required argument now, not an implicit default.
- The bare
revalidateTag(tag)call is deprecated and now behaves like{ expire: 0 }— an immediate, blocking cache miss — which is the opposite of the lazy stale-while-revalidate behavior it had in 15. revalidateTag(tag, 'max')is the one-line migration that restores the old, reader-friendly default on purpose.updateTagexists for exactly one job — the writer seeing their own change on the very next render — and that job is why it's restricted to Server Actions only.- Webhooks and anything outside a Server Action stay on
revalidateTag; there's no version ofupdateTagfor that context, by design.
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The team in the opening didn't ship a bug — they shipped code that was correct for the Next.js version it was written against, and the ground moved under one function call during an upgrade that otherwise looked clean. The fix was one argument: revalidateTag('posts', 'max') to get the old behavior back, and updateTag reserved for the one page where "eventually" was never good enough.
If cacheTag, cacheLife, and the static-shell-vs-streaming split underneath all of this are new to you, Next.js Cache Components Explained builds that model from the ground up. For the mutation side of this same flow — validating the FormData, the CSRF guarantees a Server Action gives you for free, and the redirect() gotcha — see Next.js Server Actions: Mutations & Security. And if your cache entries are built from a Route Handler's own GET rather than a Server Component, Next.js Route Handlers: GET Stopped Caching in 15 covers the other half of the 15-to-16 caching story.
Did your own revalidateTag calls survive the upgrade with the behavior you expected — or did you find this one the hard way?
- Next.js proxy.ts Explained (with Cheat Sheet)
- Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16
- Next.js Cache Components Explained (with Cheat Sheet)
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.