updateTag(tag)
Server Actions only. Expires the entry immediately, so the next read blocks and refetches. Use it when the user who triggered the change is about to see it. Takes a tag and nothing else.
Choose among Next.js's four cache invalidation calls with a two-question decision at any mutation site.
Last lesson you tagged every cached read, each one naming exactly what it holds: invoiceTags.list(orgId) for a list, invoiceTags.record(orgId, id) for a single record. That was the read side. Now for the write side: a mutation just landed, and something has to tell the cache which of those reads is now stale.
That mutation can come from anywhere you’ve built: a Server Action editing an invoice, a Stripe webhook flipping a plan, a nightly job rebuilding a summary. Each changes data, each must invalidate the cache, and each calls for a different invalidation call.
Next.js gives you four, and choosing between them trips people up far longer than it should. The calls are one-liners; the skill is the order of questions that makes the answer obvious. By the end of this lesson you’ll run a two-question decision on any mutation site and land on the right call without guessing.
The four calls, each with the situation it’s built for.
updateTag(tag)
Server Actions only. Expires the entry immediately, so the next read blocks and refetches. Use it when the user who triggered the change is about to see it. Takes a tag and nothing else.
revalidateTag(tag, profile)
Works anywhere on the server: actions, route handlers, background jobs. Marks the entry stale, so the next visitor to a page using that tag is served the stale value once, then it refreshes. profile is a cacheLife preset, defaulting to 'max'. The single-argument form is a type error in Next.js 16.
revalidatePath(path)
Invalidates by URL path or route pattern rather than by tag, which is coarser. Use it as the escape hatch when the affected surface has no clean tag to name it.
router.refresh()
Client-side, from useRouter(). Re-runs the current route’s server render in the browser. It does not invalidate cached reads, only re-renders the route. Use it for a fresh render after a non-action interaction on the client.
Two of these behaviors carry the decision. revalidateTag produces stale-while-revalidate : the cache hands out the old value one last time and refreshes underneath. updateTag produces read-your-writes : the user who made the change sees it on their next view, with no stale value in between. The whole decision turns on one question: does the next reader see one stale render, or not?
The four calls aren’t four things to memorize. They’re the product of two yes-or-no questions, so each call is just a pair of coordinates.
Axis one decides most cases: read-your-writes or eventual. Ask it about a person: is someone sitting on the screen right now, expecting to see this exact change?
When you edit your own profile and the form redirects, you are staring at the result, waiting for your new name. Someone is watching, so this is read-your-writes: updateTag or router.refresh().
Now flip it. A Stripe webhook marks an invoice paid, and nobody is waiting on that HTTP request; a nightly job rebuilds a summary at 3am with no audience. That’s eventual: revalidateTag or revalidatePath. The next reader sees one stale render, then fresh, and that’s fine because nobody was waiting. Roughly four out of five mutation sites end right here, on axis one.
Axis two is the tiebreaker: tag or path. A tag names one entity’s reads precisely; invoiceTags.record(orgId, id) is exactly the reads for that one invoice. A path names a whole route, coarsely. With a real lib/tags.ts the answer is almost always “tag”, since naming reads precisely is the point of the scheme; path is the fallback for when no tag fits the surface that changed.
updateTag(tag) expires now, next render is fresh router.refresh() no tag — re-renders the whole route revalidateTag(tag, 'max') marks stale, next reader refreshes revalidatePath(path) coarse fallback when no tag fits The rule: axis one decides the call, and you reach for axis two only to break the tag-versus-path tie. Often a shortcut answers axis one before you finish asking, and the shortcut is where am I? No specific person triggers a webhook interactively, so the moment you notice you’re in one, you know it’s revalidateTag. Where you are isn’t a constraint fighting you; it’s a signal that has already narrowed the choice.
updateTag is Server-Action-onlyupdateTag throws if you call it outside a Server Action, because that is the only place it can keep its promise.
Read-your-writes guarantees the user sees fresh data on the very next render, with no stale flash. To deliver that, the framework must sequence three steps inside one request: mutate the data, expire the tag, then render fresh. Only a Server Action can run that sequence: it mutates, calls updateTag, then redirect()s, and because the redirect renders in the same request after the tag expired, it reads fresh data. That loop is the in-band redirect .
A route handler, a background job, and a client callback don’t control a redirect that way, so updateTag can’t deliver read-your-writes there. The throw is the signal: read-your-writes isn’t available here, so switch to revalidateTag and accept eventual freshness.
The two snippets below are the same invalidation in two places. The left one works; the right one throws, with the fix right under it.
'use server';import { updateTag } from 'next/cache';import { invoiceTags } from '@/lib/tags';
export const updateInvoice = authedAction(async ({ orgId }, input) => { // ...validate, then write the row inside a transaction... updateTag(invoiceTags.record(orgId, input.id)); redirect(`/invoices/${input.id}`);});Read-your-writes, in band. updateTag expires the tag, and the redirect that follows renders fresh.
import { updateTag, revalidateTag } from 'next/cache';import { invoiceTags } from '@/lib/tags';
export async function POST(request: Request) { // ...verify signature, claim event, write the row... updateTag(invoiceTags.record(orgId, id)); // throws at runtime revalidateTag(invoiceTags.record(orgId, id), 'max'); // the fix}No in-band redirect, so updateTag throws. Nobody is waiting on this request, so revalidateTag(tag, 'max') is correct.
Both updateTag and revalidateTag import from next/cache. The rest of the lesson runs these two axes on real cases, starting with the simplest.
A user opens an invoice’s detail page, edits the amount, and saves. The Server Action writes the new amount and redirects to the list. Two cached reads are now stale: the list, tagged invoiceTags.list(orgId), and the detail read, tagged invoiceTags.record(orgId, id).
Run the axes. We’re in a Server Action, and the editor is the viewer: they’re about to land on the list expecting their new number. That’s read-your-writes with tags, so it’s updateTag, fired after the write and before the redirect.
Fire it twice, once per affected read. This is the fan-out, the discipline that separates a cache that works from one that goes silently stale. One mutation touched two cached reads, so two updateTag calls: the list so the new amount shows, the record so the back button to the detail page is fresh too. Both tag strings come from the same lib/tags.ts helper, so listing them is mechanical: you’re not inventing strings, you’re naming reads.
'use server';import { redirect } from 'next/navigation';import { updateTag } from 'next/cache';import { invoiceTags } from '@/lib/tags';import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) { const input = updateInvoiceSchema.parse(Object.fromEntries(formData)); const { orgId } = await requireOrgUser(); await tenantDb(orgId) .update(invoices) .set({ amount: input.amount }) .where(eq(invoices.id, input.id)); updateTag(invoiceTags.list(orgId)); updateTag(invoiceTags.record(orgId, input.id)); redirect(`/invoices/${input.id}`);}The 'use server' directive and the action signature. They’re what makes updateTag legal here; outside an action it throws.
'use server';import { redirect } from 'next/navigation';import { updateTag } from 'next/cache';import { invoiceTags } from '@/lib/tags';import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) { const input = updateInvoiceSchema.parse(Object.fromEntries(formData)); const { orgId } = await requireOrgUser(); await tenantDb(orgId) .update(invoices) .set({ amount: input.amount }) .where(eq(invoices.id, input.id)); updateTag(invoiceTags.list(orgId)); updateTag(invoiceTags.record(orgId, input.id)); redirect(`/invoices/${input.id}`);}Parse the input, authorize, then write the row. Nothing about the cache yet, just the change landing in the database.
'use server';import { redirect } from 'next/navigation';import { updateTag } from 'next/cache';import { invoiceTags } from '@/lib/tags';import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) { const input = updateInvoiceSchema.parse(Object.fromEntries(formData)); const { orgId } = await requireOrgUser(); await tenantDb(orgId) .update(invoices) .set({ amount: input.amount }) .where(eq(invoices.id, input.id)); updateTag(invoiceTags.list(orgId)); updateTag(invoiceTags.record(orgId, input.id)); redirect(`/invoices/${input.id}`);}After the write, before the redirect: the fan-out. One updateTag per affected read, the list and the record.
'use server';import { redirect } from 'next/navigation';import { updateTag } from 'next/cache';import { invoiceTags } from '@/lib/tags';import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) { const input = updateInvoiceSchema.parse(Object.fromEntries(formData)); const { orgId } = await requireOrgUser(); await tenantDb(orgId) .update(invoices) .set({ amount: input.amount }) .where(eq(invoices.id, input.id)); updateTag(invoiceTags.list(orgId)); updateTag(invoiceTags.record(orgId, input.id)); redirect(`/invoices/${input.id}`);}The redirect’s render runs in the same request, after the tags expired, so the list it renders reads fresh. Read-your-writes, delivered.
The next case keeps the same call but stretches the intuition behind it.
An admin demotes a teammate from admin to member. The Server Action runs in the admin’s session, but the change affects two different people.
Two cached reads go stale. The org’s membership read, tagged orgTags.all(orgId), is what the admin sees on the members page. The demoted member has their own cached read: the “orgs I’m in” data keyed to their user, tagged userTags.all(memberUserId).
These two readers sit on opposite sides of the read-your-writes line. The admin is watching the members list for the role to flip, so they need read-your-writes. The demoted member is not watching the redirect; they might be asleep. Whether their read is eventual doesn’t matter, because we are already in a Server Action firing updateTag. We fire it for the member’s tag too, which expires their entry. The next time they load any page, the cached read is gone and the new role is in effect. Correctness comes from being in an action, not from anyone watching.
So you have one action, two tags, each scoping a different person’s data. That’s the multi-recipient pattern: “who triggered it” and “whose data changed” are different questions, and the tags answer the second one.
// ...inside the demoteMember action, after the role write commits...updateTag(orgTags.all(orgId)); // the admin's members list, refreshed on redirectupdateTag(userTags.all(memberUserId)); // the member's own data, expired for their next loadredirect('/settings/members');One caveat: expiring the member’s cached data is not the same as revoking their session. Whether they can still act as an admin resolves separately, through the session and its short staleness window covered in the organizations and RBAC chapters. The cache tag handles their reads; the session handles their permissions.
Next, turn this reasoning into a decision rule you carry to every mutation site.
Walk this tree one question at a time. The order matters: the first question is “where does the mutation run?”, because the location pre-answers most of the choice.
Read-your-writes with tags. Fire one per affected read, after the commit, before the redirect. updateTag(invoiceTags.list(orgId)).
A coarse re-render of the current route when no tag fits. Treat this as a warning sign, since the work almost always wants a tag. useRouter().refresh().
Even inside an action, if nobody’s waiting, eventual is the right call. revalidateTag(orgTags.all(orgId), 'max').
updateTag would throw here. No specific user is waiting, so stale-while-revalidate is correct. revalidateTag(invoiceTags.record(orgId, id), 'max').
Same reasoning as a webhook. The job imports revalidateTag from next/cache and calls it directly. revalidateTag(orgTags.all(orgId), 'max').
Re-renders the current fully-dynamic route from the browser. It does not touch cached-with-TTL entries; it only re-runs the render. useRouter().refresh().
The affected surface is a route pattern, not an entity. Rare in a tagged codebase, usually a sign the tag scheme has a gap. revalidatePath('/(marketing)', 'layout').
Every worked case in this lesson is a path through this tree. The two you’ve seen both land on updateTag: Server Action, user watching, tag exists. The cases ahead reach the other leaves, one branch at a time.
Stripe sends invoice.payment_succeeded. The handler is the single writer for this invoice’s payment state: it verifies the signature, claims the event, updates the invoice row, then invalidates.
Walk the tree. We’re in a route handler, so the branch terminates immediately at revalidateTag, with no watcher question, because a route handler can never be read-your-writes for anyone. updateTag here would throw, exactly as the boundary section promised. The fan-out is the same as in the action: two affected reads, two calls, revalidateTag(invoiceTags.record(orgId, id), 'max') for the detail and revalidateTag(invoiceTags.list(orgId), 'max') for the list.
Here is what 'max' actually does: it marks the tag stale and walks away, with no eager refetch. The refresh happens on the next visit to a page using that tag, so the next reader is served the stale value once and their request recomputes it behind the scenes; everyone after them gets fresh. When nobody is waiting on the screen, and for a webhook nobody is, that is exactly the trade you want. A background job reaches for the same call: revalidateTag(tag, 'max') from Trigger.dev or a cron task expires the tag cross-process, since the framework routes invalidation through the deployment’s shared cache backend, so a tag expired by the job is also expired for web requests with no extra wiring.
// ...signature verified, event claimed in processed_events, invoice row updated...revalidateTag(invoiceTags.record(orgId, id), 'max');revalidateTag(invoiceTags.list(orgId), 'max');return new Response(null, { status: 200 });Invalidation is one more line on the webhook checklist from the billing chapters: verify, claim, write, invalidate, acknowledge. Forget it and the user pays their invoice but the UI shows “unpaid” until something else expires the read.
One escape hatch, then set it aside: revalidateTag(tag, { expire: 0 }) hard-expires a tag immediately from a route handler, for the rare case where even one stale render is unacceptable. But 'max' is the default, and true read-your-writes immediacy belongs in a Server Action with updateTag, not a webhook faking it.
A user clicks “Upgrade to Pro,” goes through Stripe Checkout, and lands on a success page that often still says “Free.” That is a race to design around, not a bug to fix.
The plan flip happens in the webhook, not in an action, because the webhook is the single writer for billing state. When it lands, it calls revalidateTag(orgTags.all(orgId), 'max') to expire the entitlement read. But the redirect to the success page and the webhook delivery are independent events racing each other, and the redirect usually wins: when the user reaches the success page, the cached entitlement still says “Free.”
So the success page is a client component that polls. It calls router.refresh() on an interval until the entitlement flips to “Pro,” then stops. Scrub through the sequence to watch the race resolve.
router.refresh() router.refresh(). The server render re-runs — but the cached read is still valid, so it still says Free. The refresh re-rendered; it did not invalidate.
revalidateTag(…, 'max') revalidateTag(orgTags.all(orgId), 'max'). Now the cached read is stale.
router.refresh() router.refresh() re-runs the server render. This time the stale read recomputes and the page shows Pro. The poll stops.
Steps 2 and 4 are the whole point: the same router.refresh() produces “Free” and then “Pro.” The refresh never expired anything. The webhook’s revalidateTag in step 3 made the read stale; the refresh only re-ran the render so the client could observe it. The two calls are complementary, not interchangeable: this is exactly where people assume router.refresh() refreshes the cache, and exactly where you can watch that it doesn’t.
The poll is not papering over a bug. The redirect-versus-webhook race is inherent, since the two systems share no transaction, and polling is the honest way to bridge it. But treat it as a warning sign: reach for router.refresh() outside a redirect race and the work almost certainly belongs in a Server Action, where updateTag is cleaner and instant. It is the right tool here only because there is no action to put the invalidation in.
The calls are right; the order is where people slip. Two ordering bugs turn correct calls into broken behavior, and both are common review catches.
The sequence is: the write lands, which for a multi-row change means the transaction commits, then the invalidation calls fire, then redirect.
Invalidating inside the transaction. A multi-row mutation runs inside a db.transaction. If you call updateTag before the transaction commits and it then rolls back, you’ve expired the cache for a change that never happened. The next read repopulates with the old value, so the invalidation was wasted; worse, for a moment the cache serves a view of state that doesn’t exist. Invalidation describes a committed fact, so it belongs after the commit.
Redirecting before invalidating, or trusting the redirect to refresh things on its own. The destination’s cached read still holds a valid tag, so it serves the old value and the user lands on stale data. The redirect is not an invalidation signal: invalidate first, then redirect.
This is the same discipline you applied to the notification dispatcher and the no-external-calls-in-a-transaction rule: effects that depend on a committed change run after the commit, never inside it.
Drag the steps below into the order a correct action runs them. One of them is a trap that wants to go too early.
Order the steps of a Server Action that edits an invoice and its line items, then sends the user to the detail page. Drag the items into the correct order, then press Check.
db.transaction updateTag(invoiceTags.list(orgId)) updateTag(invoiceTags.record(orgId, id)) redirect to the detail page The fan-out showed up in almost every case, so make it a habit. The rule is one sentence: before writing the invalidation tail, list every cached read whose underlying data this mutation changes, then fire the narrowest tag for each.
Editing one line item on an invoice changes the invoice record read and the org’s invoice list read: two reads, two tags. Miss one and that read stays stale until its TTL expires, but these reads carry the 'max' profile, so they effectively never expire on their own. “Silently stale” here means stale until someone happens to edit the same entity again, quietly serving wrong data for a long time.
This is the write-side half of a split from the previous lesson. Reads are generous: a cached read attaches the union of every tag that could apply to it. Writes are precise: a mutation fires the narrowest set of tags covering what it actually changed. Both sides import the same lib/tags.ts helper, so the write-site string is the same function call as the read-site string. A typo can’t drift them apart, because you’re matching function calls the compiler checks, not strings by eye.
Try the exercise below.
An admin removes a member from the org. Sort each cached read into the bucket for whether this one mutation must invalidate it. Drag each item into the bucket it belongs to, then press Check.
orgTags.all(orgId)userTags.all(removedUserId)invoiceTags.list(orgId)invoiceTags.record(orgId, someInvoiceId)userTags.all(adminUserId)The two on the left are the fan-out: the org’s membership read (orgTags.all(orgId)) and the removed member’s own “orgs I’m in” read (userTags.all(removedUserId)) both changed. The decoys are the trap. The invoice reads have nothing to do with membership, and the admin’s own membership didn’t change, so firing userTags.all(adminUserId) would expire a read this mutation never touched.
One thing to file away: when a list is also read on the client through TanStack Query, the action must invalidate that separate client cache too with queryClient.invalidateQueries(...), which a later chapter (Unit 15) owns. This lesson is about the Next.js server cache only.
The tree earns its keep on cases you haven’t seen. Here are four the lesson skipped; decide the call and the reasoning before you reveal each answer, since the reasoning is what transfers. Watch the first two: the same mutation, a profile change, resolves to different calls because the trigger differs.
A user opens their account settings, edits their own display name in a form backed by a Server Action, and the form re-renders the same settings page on success. Which call invalidates the name they’re staring at?
updateTag(userTags.all(userId));revalidateTag(userTags.all(userId), 'max');router.refresh();updateTag(userTags.all(userId)). Axis one: the editor is about to see the re-render expecting their new name, so this is read-your-writes. Axis two: a tag names the read, so updateTag, not revalidatePath. revalidateTag marks the entry stale-on-next-visit, flashing the old name for that render; router.refresh() re-runs the render but leaves the cached read untouched, so the name wouldn’t change at all.Your external identity provider pushes a display-name change for one of your users to a webhook route handler, which updates the user row. Which call invalidates that user’s cached read?
updateTag(userTags.all(userId));revalidateTag(userTags.all(userId), 'max');router.refresh();revalidateTag(userTags.all(userId), 'max'). Same data, different trigger. Axis one starts with “where am I?” — a route handler, where no specific person waits on a redirect, so it’s eventual. updateTag would throw here: with no in-band redirect, it can’t keep the read-your-writes promise. The single-argument form is a type error in Next.js 16, so the 'max' profile is required.An embedded third-party maps widget fires an onSave callback — plain client code, not a Server Action — after the user repositions a pin. The route is fully dynamic with no cached read, and you just need its server render re-run. Which call?
updateTag(/* ... */);revalidatePath('/map');router.refresh();router.refresh(). You’re on the client, outside any action, so updateTag isn’t available. The route is already fully dynamic, so there’s no cached-with-TTL read for revalidatePath to expire. All you need is a fresh server render in the browser, which is what router.refresh() does.A sitewide footer config row changes. The affected surface is every page under the (marketing) route group, and none of those pages carries a per-entity tag from lib/tags.ts. Which call?
revalidateTag('marketing', 'max');revalidatePath('/(marketing)', 'layout');router.refresh();revalidatePath('/(marketing)', 'layout'). Axis one: a config edit nobody is watching land — eventual. Axis two is the tiebreaker: no tag names a whole route tree, so you fall back to the path. Hand-inventing a 'marketing' tag string defeats the point of lib/tags.ts, and reaching for the path often would mean the tag scheme has a gap worth closing.Close on one habit: have every invalidation call emit a structured log line, something like { event: 'invalidate', call: 'updateTag', tag, source }.
When a change doesn’t show up, that line is the first place to look, since a flatline on an actively-edited tag means the write fired a different tag than the read carries.
A dashboard for this comes later, in the observability chapters; for now the log line is enough.
The two main calls each have a focused docs page, and both spell out the read-your-writes versus stale-while-revalidate distinction this lesson is built around.
The read-your-writes call. Why it's Server-Action-only, with a side-by-side comparison to revalidateTag.
The eventual, stale-while-revalidate call. Spells out the required profile argument and the deprecated single-argument form.
All four calls on one page, with the where/behavior/use-case table that mirrors this lesson's two axes.
The HTTP-level origin of the SWR pattern revalidateTag implements — serve stale once, refresh underneath.