Skip to content
Chapter 32Lesson 6

Invalidating after a mutation

The four Next.js cache invalidation tools, and the one question that picks the right one after a write.

A user opens invoice #42, flips it from draft to sent, clicks Save, and lands back on /invoices. That list page is cached: you marked it with 'use cache' in The use cache directive, so it serves instantly instead of re-querying on every visit. But invoice #42 is still sitting there as a draft. The user saved a second ago and the page is showing them the past, so they click Save again and start to wonder whether the app is broken.

The write succeeded. A cached read has no way to know the database changed underneath it, so it serves the snapshot it stored, and that snapshot predates the edit. In Lifetimes and tags you saw that cacheLife is a clock: the entry refreshes on its own once it times out. But “eventually” is the wrong answer when the user is staring at the result of their own action. You need to tell the cache that this exact thing changed the instant the write finishes.

That’s what the tags from Lifetimes and tags are for: they are the handles, and this lesson is the API that pulls them. Four tools do the pulling, updateTag, revalidateTag, revalidatePath, and router.refresh, and a single question decides which one you reach for. Watch the bug happen first.

user

Save invoice #42

draft → sent

submit

server

save handler

running…

updateTag

database

#42 draft

cached list

#42 draft

rendered list

#42 draft
The user submits the edit form. The save handler runs on the server.

user

Save invoice #42

draft → sent

server

save handler

writes row

updateTag write

database

#42 sent

draft → sent

cached list

#42 draft

rendered list

#42 draft
The handler writes the row. In the database, invoice #42 flips from draft to sent.

user

Save invoice #42

draft → sent

server

save handler

redirect('/invoices')

updateTag

database

#42 sent
serves

cached list

#42 draft

stored before edit

rendered list

#42 draft
The handler redirects to /invoices, a cached read, so it serves the entry it stored before the edit.

user

Save invoice #42

draft → sent

server

save handler

redirect('/invoices')

updateTag

database

#42 sent

cached list

#42 draft

rendered list

#42 draft

stale!

"did the save even work?"
The user lands on the list and sees #42 still as a draft. The save looks like it failed.

user

Save invoice #42

draft → sent

server

save handler

redirect('/invoices')

updateTag(…) strikes

database

#42 sent
refetch

cached list

#42 draft

struck — stale

rendered list

#42 sent

fresh

read-your-writes
Now the handler calls updateTag(invoiceTags.list(orgId)) before the redirect. The stale entry is struck, the list re-renders fresh, and the user sees #42 as sent: they read their own write.

There is an easy mistake to make here. You hit the stale-after-save bug, search “how to invalidate the cache in Next.js,” find four functions, and reach for whichever shows up first. All four invalidate something, so the page usually freshens and you move on, until the day you pick wrong and ship a subtly broken billing flow. The functions are not interchangeable, so the useful question is not “which function invalidates the cache?” but this:

Does the user expect to see their own change the instant this action finishes?

That question splits every mutation you will ever write into two buckets.

Yes, the user did the thing and is looking right at the result. This is a form submission in the current session: they edited the invoice, hit Save, and the next screen had better show their edit. Anything short of instant freshness reads as a bug, the “did it even work?” moment from the timeline. This case calls for read-your-writes .

No, the change came from somewhere the user isn’t watching. A Stripe webhook updates a subscription, a scheduled job re-syncs a catalog overnight, or an admin edits another tenant’s data. Nobody is staring at a result waiting for it to update, so a few seconds of staleness is invisible, and that buys you something cheap and fast: stale-while-revalidate , which you met in Lifetimes and tags. Serve the old value now, refresh quietly, and the next visitor gets fresh data.

Read-your-writes versus eventual freshness: that one split drives the rest of the lesson. The two main tools, updateTag and revalidateTag, differ on exactly this axis, when the fresh data shows up and whether stale content is served in the meantime. Get the question right and the tool follows. The other two, revalidatePath and router.refresh, are narrower instruments for specific situations we’ll reach once the main split is solid.

Try the question on a scenario that isn’t a form.

An overnight cron job bulk-imports 5,000 products from a supplier feed and writes them into your catalog. The catalog pages are cached. No human triggered the import and nobody is watching it run. Which invalidation behavior fits?

Read-your-writes — the next read must block until fresh data is ready, no matter what.
Eventual — serve the cached catalog now, let the next visitor’s read pull the refreshed data.
Neither — a cron job can’t invalidate a cache at all, only a form submission can.

updateTag: read-your-writes from a Server Action

Section titled “updateTag: read-your-writes from a Server Action”

This is the “yes, refresh now” case, the bug from the top of the lesson. The tool is updateTag, new in Next.js 16, imported from next/cache. Its signature is smaller than you’d guess.

import { updateTag } from 'next/cache';
updateTag(tag: string): void;

The tag string, nothing else. The intuitive guess is that you’d hand it the new value, the fresh invoice to swap in, but that is not how it works. updateTag doesn’t replace the entry, it expires it: every cached entry carrying that tag is marked stale, and the next request for that data blocks to fetch fresh rather than serve the old snapshot.

That block on the next read is the whole of read-your-writes. When your save handler redirects to /invoices, the redirect is the next request: it hits the now-expired list entry, waits a beat for fresh data, and renders the invoice exactly as the user just saved it. You never pass a value because the next render re-derives it from the source of truth.

updateTag throws anywhere outside a 'use server' function: not from a Route Handler , not from a Client Component, not from a plain server utility. The restriction follows from the model. Read-your-writes only means something when there’s a next render the user is about to see, and a Server Action has one, in the redirect or re-render right after it. A webhook has no one waiting on the other end, so the API is restricted to the one place where blocking for fresh data buys you something.

app/api/sync/route.ts
export async function POST() {
updateTag(invoiceTags.list(orgId));
return Response.json({ ok: true });
}

updateTag throws on the highlighted line. No user is staring at a next render here, so read-your-writes means nothing. The framework rejects the call rather than let it quietly do the wrong thing.

You’re not writing full Server Actions yet, that’s a later chapter. But every action follows the same five steps: parse the input, authorize the user, mutate the database, revalidate the cache, and return a result. This lesson covers one of them, revalidate; the rest is scaffolding you’ll fill in later. Here is where the invalidation lands.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function editInvoice(formData: FormData) {
// parse → authorize → mutate: Chapter 043
const { orgId, id } = await saveInvoice(formData);
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, id));
redirect(`/invoices/${id}`);
}

'use server' marks the whole module as a Server Action: code the client invokes and the framework runs on the server. The one context updateTag is allowed in.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function editInvoice(formData: FormData) {
// parse → authorize → mutate: Chapter 043
const { orgId, id } = await saveInvoice(formData);
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, id));
redirect(`/invoices/${id}`);
}

Validating the form, checking permission, and writing the row, the parse, authorize, and mutate work, all happen here in a later chapter. Treat saveInvoice as a stand-in: by this line the database is updated.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function editInvoice(formData: FormData) {
// parse → authorize → mutate: Chapter 043
const { orgId, id } = await saveInvoice(formData);
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, id));
redirect(`/invoices/${id}`);
}

The revalidate seam, this lesson’s part. Two cached surfaces just went stale, the detail page for this invoice and any list that renders it, so each gets a call to updateTag.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function editInvoice(formData: FormData) {
// parse → authorize → mutate: Chapter 043
const { orgId, id } = await saveInvoice(formData);
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, id));
redirect(`/invoices/${id}`);
}

Tags come from tags.ts, never inline strings. The read side tagged the entry with the exact same call, so the write side can’t drift from it.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function editInvoice(formData: FormData) {
// parse → authorize → mutate: Chapter 043
const { orgId, id } = await saveInvoice(formData);
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, id));
redirect(`/invoices/${id}`);
}

redirect fires after the invalidation. Its target render is the read that blocks for fresh data and delivers the user their own write. Invalidate, then redirect.

1 / 1

Three things in that shape are load-bearing across every action you write.

First, the invalidation lives after the database write and before the redirect, and never inside a database transaction. A transaction can roll back; invalidate inside one that then fails and you’ve thrown away a good cache entry for a write that never happened. Invalidate once the write is committed.

Second, the tags come from tags.ts, never a string typed inline. Because the read side and the write side call the same function, they produce the identical string by construction, so a typo is a compile error instead of a page that stays silently stale.

Third, editing one invoice fires two tags. The detail page for #42 is now wrong (invoiceTags.record(orgId, id)), and so is every list that renders the collection (invoiceTags.list(orgId)): the dashboard, the search results, the admin view. This is the payoff of the two-level scheme from Lifetimes and tags, entity and entity:id. Firing both costs almost nothing, since invalidation only marks handles stale; a cache pays for what you store, not what you invalidate. Granularity is free here, so invalidate every surface the change could have touched.

revalidateTag: eventual freshness for webhooks and jobs

Section titled “revalidateTag: eventual freshness for webhooks and jobs”

Now take the “no, eventual is fine” branch. The tool is revalidateTag, also from next/cache. Its signature carries one more piece than updateTag:

import { revalidateTag } from 'next/cache';
revalidateTag(tag: string, profile: string | { expire?: number }): void;

That second argument, the profile, is required, and it is where a lot of older code and tutorials diverge. The single-argument revalidateTag(tag) form is deprecated in Next.js 16: the type checker rejects it, and the immediate-expiry behavior it carried is exactly what updateTag does better. So you always pass a profile, and the default reach is 'max':

revalidateTag(invoiceTags.list(orgId), 'max');

'max' gives stale-while-revalidate, the behavior you want for eventual freshness. Here is the precise mechanic, because it is what makes the tool safe at scale: revalidateTag(tag, 'max') marks the tagged entries stale and stops there. It does not fetch fresh data, and it does not fan out a wave of revalidations across every entry sharing the tag. The fresh fetch happens lazily, only when a page using that tag is next visited.

That laziness is why revalidateTag exists alongside updateTag. Picture the cron job from the multiple-choice question: it just wrote 5,000 products. An eager refresh would kick off thousands of renders the instant the job finishes, saturating the server with work no one asked for. With revalidateTag(productTags.list(orgId), 'max'), the job marks the tag stale and moves on in microseconds, and the pages refresh one at a time as real shoppers load them.

revalidateTag runs anywhere on the server: Server Actions, Route Handlers, and background jobs alike. That is the other half of why it is a separate tool. Webhooks and cron jobs live in route handlers, the one place updateTag is forbidden, so revalidateTag is what you reach for there.

The webhook is where this matters most, and eventual freshness is not a compromise there but exactly right. The defining feature of a webhook is that the user is not in the loop. Stripe calls your server when a subscription renews, but the customer who triggered it was redirected to a Stripe-hosted page or got an email, so they are nowhere near your app. There is no next render they’re staring at, so blocking for fresh data would buy nothing. You mark the subscription’s tag stale and return fast. The next time that customer opens their billing page, they see the fresh state, and the stale window in between is invisible because no one was looking.

app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
// verify signature + decode the event: Chapter 063
const { orgId } = await handleSubscriptionEvent(request);
revalidateTag(orgTags.all(orgId), 'max');
return Response.json({ received: true });
}

Notice the coarse orgTags.all(orgId) tag from Lifetimes and tags. A subscription change ripples across many cached surfaces at once: the billing page, plan-gated features, seat counts. Rather than enumerate each one, you strike the whole org’s cache in a single call and let each page refresh when it’s visited. That is the scope orgTags.all was built for.

One escape hatch is worth naming. A webhook that genuinely needs the next read to block for fresh data, usually because an external system demands it, can pass revalidateTag(tag, { expire: 0 }), which expires immediately instead of going stale-while-revalidate. You will rarely need it, since the docs steer nearly every such case toward updateTag in a Server Action.

Walk the decision yourself: pick a starting answer and follow it to the tool. The order of the questions is what matters, not any single endpoint.

Which invalidation tool?

Keep this table nearby as a reference; the walk above is where the reasoning lives.

updateTag(tag)revalidateTag(tag, 'max')
Timingnext read blocks for freshserve stale, refresh in background
User-facing waitone rendernone
Where it runsServer Action onlyServer Action + Route Handler + jobs
Reach for it whenthe user awaits their own writewebhook / cron / out-of-band change
updateTag and revalidateTag differ on one axis: when fresh data reaches the user.

The third tool is narrower, and the cleanest way to place it is against tags. Tags name entities; paths name URLs. One invoiceTags.list(orgId) invalidates every page that renders that data, the dashboard, the search page, and the admin view, without you listing a single URL. That is almost always what you want, which is why tags are the default reach.

revalidatePath invalidates by URL instead:

revalidatePath('/invoices/42'); // a literal URL
revalidatePath('/invoices/[id]', 'page'); // a dynamic route pattern needs the type

A literal path stands on its own. A route pattern with a dynamic segment, /invoices/[id] rather than a specific id, needs the second argument, 'page' (or 'layout'), so the framework knows which level of the route tree you mean.

The URL is genuinely the unit only when there is no entity behind it to tag: a generated sitemap, an Open Graph image route, a static export page, or a route handler whose output is itself the cached thing. There is no invoiceTags.record to attach, because the URL is the resource. That is the path-as-resource case, and it is what revalidatePath is for.

For ordinary entity data, prefer tags, for two reasons beyond precision. First, revalidatePath is coupled to the exact route: it refreshes only entries rendered by that path, so if the same data also appears elsewhere, that other page stays stale. Second, it carries a documented over-invalidation quirk: called from a Server Action, it also refreshes previously visited paths on the next navigation. The quirk is temporary, but it is one more reason to keep revalidatePath out of your default reach. Here is the same goal, refreshing the invoice list, done both ways:

revalidateTag(invoiceTags.list(orgId), 'max');

The default reach. Invalidates the invoice-list data wherever it renders, on the dashboard, search, and admin views, from one call. No URLs enumerated, nothing left stale because you forgot a page.

Path invalidation was the common pattern before tags matured, so you’ll see it everywhere in pre-16 codebases; the modern approach is to tag the data at the cache site and reserve revalidatePath for the genuine path-as-resource case.

router.refresh: re-pull server components, but not the cache

Section titled “router.refresh: re-pull server components, but not the cache”

The fourth tool lives on the client. router.refresh() comes from useRouter() (in next/navigation), and you call it inside a Client Component. It re-requests the current route’s Server Components and merges the fresh RSC payload back into the page without a full reload, so your useState, the input focus, and the scroll position all survive. Reach for it when a client interaction should re-pull server-rendered content: a manual “Refresh” button, or a polling indicator checking for new rows.

One detail here fails quietly, with no error to point at.

router.refresh() does not invalidate the 'use cache' store. It clears the client’s router cache and re-renders the Server Components, but if the route’s data is cached on the server, that re-render hits the same cached entry and gets the same value back. The render reruns; the data doesn’t change. The page flickers, nothing updates, and the user concludes the button is broken.

The fix is to invalidate on the server first, then refresh on the client to pull the now-fresh result.

'use client';
export function RefreshButton() {
const router = useRouter();
return <button onClick={() => router.refresh()}>Refresh</button>;
}

A no-op over a cached read. The re-render reads the same 'use cache' entry, so the list never changes and the button does nothing visible.

That pairing is the point: router.refresh() tells the route to re-pull itself, not the cache that it’s stale. Two notes round it out. On a fully dynamic route with nothing cached, router.refresh() is just a plain re-fetch, which is fine. And it’s debounced: fire it twice in quick succession and it runs once.

Next.js 16 also ships a refresh() from next/cache, the server-side twin you call inside a Server Action; you’ll meet it when you build forms.

Here is the full Server Action with its five seams labeled, so you can see where the invalidation slot sits.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function archiveInvoice(formData: FormData) {
const input = parseArchiveInvoice(formData); // parse: Chapter 043
const { orgId } = await requireOrgUser(); // authorize: Chapter 043
await archiveInvoiceRow(orgId, input.id); // mutate: Chapter 043
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect('/invoices'); // return: Chapter 043
}

Parse. Validate the raw form input. Read parseArchiveInvoice as a stand-in for now.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function archiveInvoice(formData: FormData) {
const input = parseArchiveInvoice(formData); // parse: Chapter 043
const { orgId } = await requireOrgUser(); // authorize: Chapter 043
await archiveInvoiceRow(orgId, input.id); // mutate: Chapter 043
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect('/invoices'); // return: Chapter 043
}

Authorize. Confirm the user may do this and resolve their org.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function archiveInvoice(formData: FormData) {
const input = parseArchiveInvoice(formData); // parse: Chapter 043
const { orgId } = await requireOrgUser(); // authorize: Chapter 043
await archiveInvoiceRow(orgId, input.id); // mutate: Chapter 043
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect('/invoices'); // return: Chapter 043
}

Mutate. The database write. After this line the row is changed and the cache is wrong.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function archiveInvoice(formData: FormData) {
const input = parseArchiveInvoice(formData); // parse: Chapter 043
const { orgId } = await requireOrgUser(); // authorize: Chapter 043
await archiveInvoiceRow(orgId, input.id); // mutate: Chapter 043
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect('/invoices'); // return: Chapter 043
}

Revalidate, the only seam this lesson covers. The user submitted this and awaits the result, so it is read-your-writes: updateTag, on both the list and the record.

'use server';
import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { invoiceTags } from '@/lib/tags';
export async function archiveInvoice(formData: FormData) {
const input = parseArchiveInvoice(formData); // parse: Chapter 043
const { orgId } = await requireOrgUser(); // authorize: Chapter 043
await archiveInvoiceRow(orgId, input.id); // mutate: Chapter 043
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect('/invoices'); // return: Chapter 043
}

Return. Redirect to the list. The cache was struck a line earlier, so this render is the blocking-fresh read and the user lands on fresh data.

1 / 1

This is the rule of thumb to carry out of the lesson: every mutation that touches a cached entity ends with an invalidation call at the revalidate seam; you pick the call with the read-your-writes question; and the tags always come from tags.ts.

Now review a teammate’s version. The action does the right work but gets the invalidation wrong in four ways, each a failure class this lesson named. Leave a comment on every line you’d flag.

You're reviewing this Server Action PR. The mutation logic is fine — focus on the revalidate seam. Click any line to leave a review comment, then press Submit review.

src/app/invoices/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function editInvoice(formData: FormData) {
const { orgId, id } = parseEditInvoice(formData);
await db.transaction(async (tx) => {
await updateInvoiceRow(tx, orgId, id, formData);
revalidateTag('invoices');
});
redirect(`/invoices/${id}`);
}

One last classification. Sort each mutation into the tool it calls for.

Match each mutation to the invalidation tool it should use. Drag each item into the bucket it belongs to, then press Check.

updateTag Read-your-writes from a Server Action
revalidateTag(…, 'max') Eventual, out-of-band changes
revalidatePath The URL is the cached unit
router.refresh + server invalidate Client re-pull after a state change
A user edits an invoice in a form and is redirected to the detail page
A user archives a record and lands back on the list expecting it gone
A Stripe webhook reports a subscription renewal
An overnight cron job re-syncs the product catalog
A regenerated sitemap.xml at /sitemap.xml
A “Refresh” button in a Client Component over a cached list

The next lesson closes the chapter by awaiting params, searchParams, cookies(), and headers() as Promises, and retiring the legacy dynamic, revalidate, and fetchCache segment exports they replace.

The four function reference pages are linked throughout the lesson. These three go a level up and down: the guide framing all four tools together, the internals behind tags, and a walkthrough of the whole model.