Invalidating after a mutation
The four Next.js cache invalidation tools that close the loop after a write, updateTag, revalidateTag, revalidatePath, and router.refresh, and the one question that picks the right one.
A user opens invoice #42, flips it from draft to sent, and clicks Save. The save handler writes the new status to the database and sends them back to /invoices. That list page is cached: you marked it with 'use cache' back in The use cache directive so it serves instantly without re-querying on every visit. The user lands on the list and sees invoice #42 still sitting there as a draft, even though they saved it a second ago. The page is showing them the past. They click Save again, harder this time, and start wondering whether your app is broken.
Nothing is broken, and the write succeeded. The problem is that 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 learned that cacheLife is a clock: the entry eventually times out and refreshes on its own. But “eventually” is the wrong answer when the user is staring at the result of their own action. You need a way to tell the cache that this exact thing changed, right now, the instant the mutation finishes. That push is what this lesson is about. By the end you’ll pick the right invalidation tool for any mutation, call it in the right place, and make the stale-after-save bug impossible.
The tags you attached in the last lesson are the handles; this lesson is the API that pulls them. There are four tools, 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
server
save handler
running…
database
cached list
rendered list
user
Save invoice #42
draft → sent
server
save handler
writes row
database
draft → sent
cached list
rendered list
user
Save invoice #42
draft → sent
server
save handler
redirect('/invoices')
database
cached list
stored before edit
rendered list
user
Save invoice #42
draft → sent
server
save handler
redirect('/invoices')
database
cached list
rendered list
stale!
user
Save invoice #42
draft → sent
server
save handler
redirect('/invoices')
database
cached list
struck — stale
rendered list
fresh
The one question that picks the tool
Section titled “The one question that picks the tool”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 one shows up first. All four do invalidate something, so the page usually freshens up and you move on, until the day you pick the wrong one and ship a subtly broken billing flow. The functions are not interchangeable. They differ on a single axis, 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, which is exactly the “did it even work?” moment from the timeline. This case calls for read-your-writes : the write happens, and the actor’s next read is guaranteed to reflect it.
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. In these cases nobody is staring at a result waiting for it to update. A few seconds of staleness is invisible, and that buys you something cheap and fast: stale-while-revalidate , which you met in the last lesson. Serve the old value now, refresh quietly, and the next visitor gets fresh data.
That is the whole model: read-your-writes versus eventual freshness. Everything else in this lesson builds on that one split, because the two main tools, updateTag and revalidateTag, answer exactly this question. They differ on a single 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 tools, revalidatePath and router.refresh, are narrower instruments for two specific situations we’ll get to 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?
revalidateTag. Reaching for read-your-writes here would make the import block for freshness that benefits nobody.updateTag: read-your-writes from a Server Action
Section titled “updateTag: read-your-writes from a Server Action”Start with the “yes, immediately” branch, since that’s 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 might guess:
import { updateTag } from 'next/cache';
updateTag(tag: string): void;It takes the tag string and nothing else. That is worth pausing on, because the intuitive guess is that you’d hand it the new value: here’s the fresh invoice, swap it in. That is not how it works. updateTag doesn’t replace the entry, it expires it. The cached entries carrying that tag are marked stale immediately, and the next request for that data blocks: it waits to fetch fresh data rather than serving the old snapshot.
That blocking on the next read is the entire mechanism behind read-your-writes. When your save handler redirects to /invoices, that redirect is the next request. It hits the now-expired list entry, blocks for a beat to fetch the fresh data, and renders the invoice exactly as the user just saved it. The user reads their own write because the framework refused to serve them the stale copy. You never pass a value because you never need to: the next render re-derives it from the source of truth.
It only works inside a Server Action
Section titled “It only works inside a Server Action”updateTag throws if you call it anywhere except a 'use server' function: not from a Route Handler , not from a Client Component, not from a plain server utility. That restriction is deliberate, and it follows from the model. Read-your-writes is a same-request-session concept, so it only means something when there’s a next render the user is about to see. A Server Action has that next render, in the redirect or the re-render right after it. A webhook does not, because nobody is waiting on the other end. So the API is restricted to the one place where blocking for fresh data actually buys something.
export async function POST() { updateTag(invoiceTags.list(orgId)); return Response.json({ ok: true });}updateTag throws on the highlighted line. A route handler has no next render the user is staring at, so read-your-writes means nothing here. The framework rejects the call rather than letting it quietly do the wrong thing.
export async function POST() { revalidateTag(invoiceTags.list(orgId), 'max'); return Response.json({ ok: true });}revalidateTag is the route-handler tool. Same tags, eventual freshness instead of blocking for fresh data. We’ll unpack it in the next section; for now, note that it’s the answer whenever updateTag is off the table.
Where the call goes: the revalidate seam
Section titled “Where the call goes: the revalidate seam”You’re not writing full Server Actions yet; that’s a whole chapter later in the course. But you can see the shape, because 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 exactly one of those steps, revalidate, and the rest is scaffolding you’ll fill in later. Here is where the invalidation call 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 this whole module as a Server Action: code the client can invoke and the framework runs on the server. That’s 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}`);}Everything up to here, validating the form, checking the user’s permission, and writing the row, is the parse, authorize, and mutate work you’ll build in a later chapter. Treat saveInvoice as a stand-in: by this line the database is already 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}`);}This is the revalidate seam, the part this lesson covers. Two cached surfaces just went stale, the detail page for this invoice and any list that renders it, so each one 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}`);}The tags come from tags.ts, the helper you built last lesson, 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, never before. The redirect’s target render is the read that blocks for fresh data, and it’s what delivers the user their own write. Invalidate, then redirect.
Three things in that shape are load-bearing, and they’ll carry through every action you write.
First, the invalidation lives after the database write and before the return or redirect, and never inside a database transaction. A transaction can still roll back. If you invalidate inside it and the transaction then fails, you’ve thrown away a perfectly good cache entry for a write that never happened. Invalidate once the write is committed and certain.
Second, the tags come from tags.ts, the helper you built in the last lesson, never a string typed inline at the call site. The read side attached invoiceTags.list(orgId) to the cache entry, and the write side invalidates invoiceTags.list(orgId). Because both call the same function, they produce the identical string by construction, so a typo becomes a compile error instead of a page that stays silently stale forever.
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. So you invalidate both. This is why the last lesson taught a two-level tag scheme, entity and entity:id; it pays off right here. Firing both costs almost nothing, because invalidation only marks handles stale. The expense in a cache is what you store and tag, not what you invalidate. Granularity is free at this layer, so be generous and invalidate every surface the change could have touched.
Now fill in the revalidate seam yourself. Same editInvoice action, blanks where the invalidation goes:
The form edit is done and the row is written. Complete the revalidate seam so the user reads their own write on the redirect. Pick the right option from each dropdown, then press Check.
'use server';
export async function editInvoice(formData: FormData) { const { orgId, id } = await saveInvoice(formData);
___(invoiceTags.___(orgId)); ___(invoiceTags.___(orgId, id));
redirect(`/invoices/${id}`);}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, and its signature has one more piece than updateTag:
import { revalidateTag } from 'next/cache';
revalidateTag(tag: string, profile: string | { expire?: number }): void;That second argument is required, and it is the part that diverges from a lot of older code and tutorials you’ll run into. The single-argument revalidateTag(tag) form is deprecated in Next.js 16: the type checker rejects it, and the legacy behavior it carried, expire immediately, is exactly what updateTag already does better. So you always pass a profile, and the default reach is 'max':
revalidateTag(invoiceTags.list(orgId), 'max');'max' is the stale-while-revalidate behavior you want for eventual freshness. Here is the precise mechanic, because it is what makes this tool safe at scale: revalidateTag(tag, 'max') marks the tagged entries stale, and that is all it does at call time. It does not go fetch fresh data, and it does not fan out a wave of revalidations across every entry that shares the tag. The fresh fetch happens lazily, only when a page using that tag is next visited. Nothing eager, nothing expensive at the moment you call it.
That laziness is the whole reason revalidateTag exists alongside updateTag. Picture the cron job from the multiple-choice question: it just wrote 5,000 products. If invalidation triggered an immediate refresh of every affected page, you’d kick off thousands of renders the instant the job finishes, and the server would be saturated doing work no one asked for. With revalidateTag(productTags.list(orgId), 'max'), the job marks the tag stale and moves on in microseconds. The pages refresh one at a time, only as real shoppers actually load them.
Where it’s allowed, and the asymmetric webhook
Section titled “Where it’s allowed, and the asymmetric webhook”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 shape this matters for most is the webhook, and the eventual behavior 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, which means 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.
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 that this reaches for orgTags.all(orgId), the coarse, whole-org tag from the last lesson. A subscription change can ripple across a lot of cached surfaces at once: the billing page, plan-gated features, seat counts. Rather than enumerate every one, you strike the whole org’s cache in a single call and let each page refresh as it’s visited. That is the scope orgTags.all was built for.
There is one escape hatch worth knowing by name. The rare webhook that genuinely needs the next read to block for fresh data, usually because some external system demands it, can pass revalidateTag(tag, { expire: 0 }), which expires immediately instead of going stale-while-revalidate. You will reach for it almost never, since the docs themselves steer nearly every case toward updateTag in a Server Action instead. Know it exists, but don’t dwell on it.
Choosing between the two
Section titled “Choosing between the two”You’ve now seen both tools and the question that separates them. Walk the decision yourself: pick a starting answer and follow it to the tool. What matters here is the order of the questions, not any one endpoint.
Read-your-writes. The next read, your redirect’s target render, blocks for fresh data, so the user sees their own write. Server Actions only.
Stale-while-revalidate. Marks the tag stale, and the next visitor’s read pulls fresh data, with no fan-out at call time. Works in route handlers and jobs.
Immediate expire from a context where updateTag is forbidden. This is the rare case; prefer moving the mutation into a Server Action and using updateTag if you can.
When the URL is the unit and there’s no entity to tag. Covered next.
The endpoints fall into a compact contrast. Keep this nearby as a reference; the walk above is where the reasoning lives.
| | updateTag(tag) | revalidateTag(tag, 'max') |
| --- | --- | --- |
| Timing | next read blocks for fresh | serve stale, refresh in background |
| User-facing wait | one render | none |
| Where it runs | Server Action only | Server Action + Route Handler + jobs |
| Reach for it when | the user awaits their own write | webhook / cron / out-of-band change |
revalidatePath: when the URL is the unit
Section titled “revalidatePath: when the URL is the unit”The third tool is narrower, and the clearest way to understand it is to compare it against tags. Tags name entities; paths name URLs. When you tag the data, one invoiceTags.list(orgId) invalidates every page that renders that data, the dashboard, the search page, and the admin view, without you ever listing those URLs. That is almost always what you want, and it is why tags are the default reach.
revalidatePath invalidates by URL instead:
revalidatePath('/invoices/42'); // a literal URLrevalidatePath('/invoices/[id]', 'page'); // a dynamic route pattern needs the typeA 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.
So when is the URL genuinely the unit? 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. In those cases 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 concrete reasons beyond precision. First, revalidatePath is coupled to the exact route: it only refreshes entries rendered by that path, so if the same cached data also appears somewhere else, that other page stays stale. Second, it currently carries a documented over-invalidation quirk: called from a Server Action, it also refreshes previously visited paths on the next navigation. That 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’s rendered, on the dashboard, search, and admin views, from one call. No URLs enumerated, and nothing left stale because you forgot a page.
revalidatePath('/invoices');Only when the URL is the unit. Refreshes just the entries rendered by /invoices. Any other page showing the same list stays stale. Right for path-as-resource cases such as a sitemap or a feed, not for ordinary entity data.
If you ever open a pre-16 codebase, you’ll see path invalidation everywhere, because it was the common pattern before tags matured. The modern approach is to tag the data once at the cache site and invalidate by tag from then on. revalidatePath stays in the toolbox, but for the genuine path-as-resource case only.
router.refresh: the client re-pull, and its one catch
Section titled “router.refresh: the client re-pull, and its one catch”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. You reach for it when a client interaction should re-pull server-rendered content: a manual “Refresh” button, or a polling indicator that re-checks for new rows.
There is a detail here that trips up nearly everyone the first time, because it 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 data behind the route is cached on the server, that re-render hits the same cached entry and gets the same value back. The render reruns, but the data doesn’t change. The user clicks Refresh, the page flickers, nothing updates, and they conclude 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. router.refresh() re-renders the Server Components, but the 'use cache' entry behind the list is untouched, so the re-render gets the identical cached value. The list never changes and the button does nothing visible.
'use client';
export function RefreshButton() { const router = useRouter(); const onRefresh = async () => { await refreshInvoices(); // Server Action: revalidateTag(..., 'max') router.refresh(); }; return <button onClick={onRefresh}>Refresh</button>;}Server first, then client. The Server Action invalidates the cache, and router.refresh() then re-pulls so the route picks up the now-fresh entry.
That pairing is the whole point of this tool: invalidate on the server, then refresh on the client. router.refresh() tells the route to re-pull itself; it is not how you tell the cache it’s stale. Two smaller notes round it out. On a fully dynamic route with nothing cached, router.refresh() is just a plain re-fetch, because there’s nothing stale to clear, and that’s fine. It is also debounced: fire it twice in quick succession and it runs once.
One sibling is worth recognizing by name. Next.js 16 also ships a refresh() from next/cache that you call inside a Server Action to refresh the client router after the action runs, the server-side twin of router.refresh(). You’ll meet it properly when you build forms; for now, just learn the name so it doesn’t surprise you later.
The complete post-mutation shape
Section titled “The complete post-mutation shape”Now pull it all together. Here is the full Server Action one more time, with the five seams labeled so you can see exactly where the invalidation slot sits in the flow you’ll write for the rest of the course.
'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 before anything else. Built in a later chapter; read parseArchiveInvoice as a stand-in.
'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. Later chapter.
'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 now 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. Because the cache was struck a line earlier, this target render is the blocking-fresh read, so the user lands on fresh data.
That gives you the rule of thumb to carry out of this 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. Those three clauses cover the entire cache-write lifecycle you spent this chapter building.
Now review a teammate’s version. The action below does the right work but gets the invalidation wrong in four distinct ways, each one a failure class this lesson named. Leave a review 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.
'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}`);}The invalidation lives inside db.transaction. If the transaction rolls back, you’ve struck a perfectly good cache entry for a write that never committed. Invalidation belongs after the write, once it’s certain — outside the transaction block.
'invoices' is a hand-typed string. The read side tagged the entry through a tags.ts helper, so this can drift from it, and a typo here is a silent no-op — the page stays stale forever with no error. Use invoiceTags.list(orgId).
revalidateTag(tag) with no second argument is deprecated in Next.js 16 — it’s a TypeScript error. Every call needs a cacheLife profile; 'max' is the default.
The user submitted this edit and gets redirected straight to the result — that’s a read-your-writes case. revalidateTag is stale-while-revalidate, so the user can land on the redirect target before the refresh and see their old data. This is the original bug. It should be updateTag.
The four flags map onto the four things to get right at the revalidate seam: the right tool (updateTag for read-your-writes), the right profile (when you do use revalidateTag), the right tag source (tags.ts, never inline), and the right placement (after the write, outside the transaction). Note that one line carries three of them — a single comment there should name all three.
Here is one more classification to settle the whole decision in place. 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.
sitemap.xml at /sitemap.xmlRecap and where this goes
Section titled “Recap and where this goes”You closed the cache loop. A cached read serves the snapshot it stored and cannot tell that the database moved underneath it, so the fix is to push an invalidation the moment a mutation lands. There are four tools to push with, and one question picks among them: does the user expect to see their own change immediately?
- Yes, in a Server Action →
updateTag(tag). Expires the tag, so the next read blocks for fresh data, and the redirect’s target render delivers read-your-writes. - No, out-of-band (webhook, cron, admin) →
revalidateTag(tag, 'max'). Marks the tag stale, refreshes on next visit, no fan-out, safe at scale, and the only option in a route handler. - The URL is the unit (sitemap, OG image, feed) →
revalidatePath(path). Use tags first for everything else. - A client interaction should re-pull server content → invalidate on the server, then
router.refresh(), which re-renders but doesn’t bust the cache.
The tags come from tags.ts, the call goes after the write and outside any transaction, and granularity is free, so fire both the record tag and the list tag. cacheLife is the clock that times an entry out on its own, and this is the push that strikes it on demand. Production uses both.
You’ve now seen the revalidate seam in isolation. When you reach Server Actions later in the course, you’ll build the other four seams around it, parse, authorize, mutate, and return, and this invalidation call will slot in exactly where you left it. Next, the chapter closes with the async request APIs and the legacy route config they replace.
External resources
Section titled “External resources”The four function reference pages are linked throughout the lesson. These three go a level up and a level down from there: the guide that frames all four tools together, the internals that explain why tags work, and a hands-on walkthrough of the whole model.
The official guide to all four invalidation tools, with the same read-your-writes versus stale-while-revalidate split this lesson is built on.
The internals beneath the tags: soft tags, cache consistency, and how invalidation propagates across instances.
Ali Alaa builds the full Cache Components model end to end, ending on the cacheTag, revalidateTag, and updateTag flow from this chapter.