← blog
NextjsOctober 27, 2026 · 14 min read

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.

Parsa Jiravand · Frontend engineer · building bestpractic
Next.js 16 updateTag vs revalidateTag: What Changed

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 profile argument ("max", a custom cacheLife name, or { expire }) to choose stale-while-revalidate on purpose instead of by accident
  • Reach for updateTag correctly — 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.

Here's a Server Action that was correct in Next.js 15 and is still, technically, legal code in 16:

TypeScript
1
2
3
4
5
6
7
8
9
// app/actions.ts — written for Next.js 15 'use server'; import { revalidateTag } from 'next/cache'; export async function publishPost(formData: FormData) { await db.posts.create({ title: formData.get('title') }); revalidateTag('posts'); // one argument — this was the whole API }

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 the next: { tags: [...] } option on fetch.
  • How long is a stale copy still acceptable to serve while fresh data loads. This is the new profile argument. "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 named cacheLife profile ("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:

TypeScript
1
2
3
4
// Deprecated as of Next.js 16 — still "works," but not the way it used to revalidateTag('posts'); // ...is now read as: revalidateTag('posts', { expire: 0 });

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:

TypeScript
1
2
3
4
5
6
7
8
9
// app/actions.ts 'use server'; import { revalidateTag } from 'next/cache'; export async function publishPost(formData: FormData) { await db.posts.create({ title: formData.get('title') }); revalidateTag('posts', 'max'); // stale-while-revalidate, same shape as pre-16 default }

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:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
// app/actions.ts 'use server'; import { updateTag } from 'next/cache'; export async function publishPost(formData: FormData) { const post = await db.posts.create({ title: formData.get('title') }); updateTag('posts'); // the list page the author is about to land on updateTag(`post-${post.id}`); // the detail page too, if it's tagged separately redirect(`/posts/${post.id}`); }

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:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
// app/api/revalidate/route.ts import type { NextRequest } from 'next/server'; import { revalidateTag } from 'next/cache'; export async function POST(request: NextRequest) { const { tag, secret } = await request.json(); if (secret !== process.env.REVALIDATE_SECRET) { return Response.json({ revalidated: false }, { status: 401 }); } revalidateTag(tag, 'max'); return Response.json({ revalidated: true, now: Date.now() }); }

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 bare revalidateTag(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, or fetch(url, { next: { tags: ['posts'] } }), is what attaches the tag in the first place. Calling revalidateTag('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/updateTag mark data stale or expired; they don't reach out and regenerate it synchronously at call time. A script that calls revalidateTag for 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.
  • revalidateTag invalidates by tag across every page that uses it; revalidatePath invalidates 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.ts or 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 updateTag for 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 the profile argument on revalidateTag is 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 revalidateTag call 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.

SituationCallWhere it runs
Keep pre-16 behavior (stale-while-revalidate)revalidateTag(tag, 'max')Server Action or Route Handler
Custom staleness windowrevalidateTag(tag, 'hours') or revalidateTag(tag, { expire: 3600 })Server Action or Route Handler
Reader must see their own write immediatelyupdateTag(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 callrevalidateTag(tag)Deprecated — now behaves like { expire: 0 }, and fails to type-check under current next types
Invalidate a specific route instead of a tagrevalidatePath(path)Server Action or Route Handler
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// The decision, as code 'use server'; import { revalidateTag, updateTag } from 'next/cache'; export async function publishPost(formData: FormData) { const post = await db.posts.create({ title: formData.get('title') }); // Everyone else: stale is fine, never block a request for it. revalidateTag('posts', 'max'); // The author, about to be redirected to the post they just wrote: // they must see it now, not the pre-write cached version. updateTag(`post-${post.id}`); redirect(`/posts/${post.id}`); }

  • 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.
  • updateTag exists 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 of updateTag for that context, by design.

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?


🚀 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:

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.