Cache Tag Convention
Next.js revalidateTag and
updateTag are used alongside
revalidatePath to invalidate cached data after mutations in the web app.
This document defines the tag naming convention, the two invalidation profiles,
the data sources that are tagged, and the server actions responsible for
invalidation.
The two invalidation profiles
Both profiles act on exactly the same tags. What differs is when the next read sees fresh data, and where each is legal.
| Profile | Call | Effect on the cached entry | Legal in |
|---|---|---|---|
| Stale-while-revalidate | revalidateTag(tag, "max") | Marked stale. The next read is still served the old entry while a refresh runs in the background. | Any server context — Server Actions, route handlers, webhooks, cron |
| Read-your-own-writes | updateTag(tag) | Expired immediately. The next read recomputes. | Server Actions only — Next throws anywhere else |
Which one to reach for
Use updateTag when the user must see their own write in the render the
action itself triggers. After a Server Action invalidates a tag, Next
re-renders the current route; under the stale-while-revalidate profile that
re-render is served the pre-mutation entry, so the button appears to do nothing
until something else re-reads the page. The lead workspace is the worked
example: every lever expires the aggregate so the page repaints with the state
it just wrote.
Use revalidateTag(…, "max") everywhere else — for tags no current render
reads (a list the user is navigating away from), and in every context that is
not a Server Action. It is also the only option in a route handler or a webhook:
updateTag throws there.
Both profiles come from the same factory,
apps/web/lib/queries/make-entity-cache.ts — revalidateList / revalidateDetail /
revalidate for the first, expireDetail for the second. Per-entity wrappers in
apps/web/lib/queries/*-cache.ts re-export only the helpers that entity needs.
expireDetail must never be reachable from a read path in lib/queries/* or
from a route handler.
There is no expireList, by design. A list tag is attached to every one of
that entity’s cached detail entries as well as to its list reads — getLeadById
tags both serviceLead and serviceLeadList — so expiring a list tag turns one
write into a tenant-wide cache miss. No render an action itself triggers reads a
list, so revalidateList is always the right call for one.
Tag naming convention
All tags follow a two-level hierarchy that keeps invalidation tenant-isolated:
tenant-scoped list → tenant:{tenantId}:{collection}
entity-scoped detail → tenant:{tenantId}:{entity}:{id}Rules
-
Always prefix with
tenant:{tenantId}.
This ensures that invalidating one tenant’s data never affects another tenant’s cache. -
List tags (e.g.
tenant:abc:service-leads) cover all records in a collection for a tenant. Invalidate them after any create, update, or delete that changes list views. -
Entity tags (e.g.
tenant:abc:service-lead:123) cover a single document. Invalidate them after any write that touches that specific record. -
A mutation should invalidate both the entity tag and the list tag so that detail and list views stay consistent.
Tag reference
Tags come from the entity-cache factory, apps/web/lib/queries/make-entity-cache.ts,
via the per-entity wrappers in apps/web/lib/queries/:
| Helper | Tag produced | Scope |
|---|---|---|
serviceLeadCacheTags.list(tenantId) | tenant:{tenantId}:service-leads | All leads for a tenant |
serviceLeadCacheTags.detail(tenantId, id) | tenant:{tenantId}:service-lead:{id} | One service lead |
proposalCacheTags.list(tenantId) | tenant:{tenantId}:proposals | All proposals for a tenant |
proposalCacheTags.detail(tenantId, id) | tenant:{tenantId}:proposal:{id} | One proposal |
workspaceCacheTags.detail(tenantId, leadId) | tenant:{tenantId}:workspace:{leadId} | One lead’s workspace aggregate |
workspaceCacheTags.list(tenantId) | tenant:{tenantId}:workspaces | Every lead’s workspace aggregate in a tenant |
Tagged data sources
Data is tagged at fetch time via unstable_cache wrappers in apps/web/lib/queries/:
| Query helper | File | Tags applied |
|---|---|---|
getLeadById(tenantId, id) | lib/queries/service-leads.ts | serviceLead, serviceLeadList |
getProposalById(tenantId, id) | lib/queries/proposals.ts | proposal, proposalList |
getWorkspaceAggregate(tenantId, leadId, persona) | lib/queries/workspace.ts | workspace (per lead), workspaceList, serviceLead |
Invalidation call sites
Service leads
| Server action | File | Tags invalidated |
|---|---|---|
updateLeadOverview | components/service-leads/lead-overview/actions.ts | serviceLead(tenantId, leadId), serviceLeadList(tenantId) — stale-while-revalidate |
The action also calls revalidatePath so that any sub-components that fetch
independently (e.g. LeadActivity, LeadProgress) receive fresh data when
the route re-renders.
Proposals
| Server action | File | Tags invalidated |
|---|---|---|
createProposal | app/(app)/proposals/create/actions.ts | proposalList(tenantId) — stale-while-revalidate |
updateProposal | app/(app)/proposals/create/actions.ts | proposal(tenantId, id), proposalList(tenantId) — stale-while-revalidate |
Lead workspace
| Server action | File | Tags invalidated |
|---|---|---|
every lever, via refreshWorkspace | components/workspace/actions.ts | workspace(tenantId, leadId), serviceLead(tenantId, leadId) — read-your-own-writes (updateTag); serviceLeadList(tenantId) — stale-while-revalidate |
The workspace is the read-your-own-writes case: the lever’s own re-render has to
show the lead it just changed, so refreshWorkspace expires the two tags
getWorkspaceAggregate carries rather than marking them stale. The tenant’s lead
list tag stays stale-while-revalidate for the reason above — no part of that
render reads a lead list, and the tag rides on every lead’s cached entry.
The workspace’s own list tag exists for changes that are not one lead’s. A match
or bid card prints facts about an expert — engagement class, day rate, vetting,
experience, availability, location — and one expert appears on many leads, so a
change to those facts marks every workspace in the tenant stale
(revalidateAllWorkspaces, stale-while-revalidate):
| Server action | File | Why |
|---|---|---|
setExpertEngagement | app/(app)/users/[id]/actions.ts | class and day rate |
setUserEmployment | app/(app)/users/[id]/actions.ts | employment is the Internal class |
updateTenantUser (experts and customers) | app/(app)/users/[id]/actions.ts | an expert’s location; a customer’s company, on the workspace customer card (customers also mark the lead list tag stale, for getLeadById) |
updateExpertKnowledge | app/(app)/expert/my-knowledge/actions.ts | position, years, availability |
approveExpertEvidence, rejectExpertEvidence | app/(app)/expert-evidence/actions.ts | vetted |
deleteExpertEvidence | app/(app)/expert/my-knowledge/expert-evidence-actions.ts | archiving an approved item can unvet |
The match card’s track record — “N completed · N active”, read across the tenant’s
leads by getExpertTrackRecords — is the other such fact: it moves when another of
the expert’s leads is awarded, delivered, qualified out or archived. The writes that
move it call refreshTrackRecords (lib/workspace/refresh-workspace.ts), the same
mark wrapped best-effort, beside the one-lead refresh:
| Server action | File | When |
|---|---|---|
acceptCustomerQuoteAction | components/service-leads/lead-customer-quote-approval/actions.ts | every acceptance — the lead becomes the expert’s active project |
qualifyOutLead | components/service-leads/lead-detail-actions/actions.ts | every qualify-out |
archiveLead | components/service-leads/lead-detail-actions/actions.ts | every archive — the count skips archived leads |
approveInvoiceAction | components/finances/invoice-approvals/actions.ts | only when the service reports leadStatusMoved (the final milestone) |
markInvoicePaidAction | components/finances/invoices-list/actions.ts | only when the service reports leadStatusMoved (the final invoice) |
It takes the tenantId from the requireWorkspaceParty guard the lever already
ran — a second context resolution here can fail silently and leave every tag
untouched. The aggregate is keyed on persona, viewer and permission version but
tagged per-lead, so expiring the two detail tags covers every viewer of that lead.
The expiry is wrapped so a throw degrades to revalidateTag(…, "max") rather
than invalidating nothing: the mutation has already committed by then.
Bid writes reach the aggregate through its key, not a tag. Submitting,
withdrawing, re-pricing, archiving or deciding a bid never writes the lead, yet
the Quote section’s bid count and the commercial value’s bid average read the
bid pool. So getWorkspaceAggregate reads proposalService.bidPoolVersion — the
lead’s bid count and the sum of every bid’s updatedAt, archived bids included —
on each render and keys the cache on it (bids= in workspaceAggregateCacheKey,
lib/workspace/as-at.ts). Any bid write, from any app or service flow, moves it;
no bid action calls refreshWorkspace for this. The same read-per-render posture
covers the other writes this app cannot expire: the lead’s own updatedAt, the
tenant’s platform rate, active-project cap and time zone.
How it works end-to-end
Stale-while-revalidate — the reader arrives on a later navigation:
User edits lead description
→ updateLeadOverview (Server Action)
→ leadService.update(...) ← writes to MongoDB
→ revalidateTag(serviceLead tag) ← marks cached getLeadById stale
→ revalidateTag(serviceLeadList) ← marks list cache stale
→ revalidatePath(...) ← invalidates full RSC route cache
→ Next.js navigation to /service-leads/[id]
→ getLeadById() (unstable_cache miss) ← re-fetches from DB
→ Page renders fresh data ✓Read-your-own-writes — the reader is the re-render the action itself triggers:
CSM presses "Assign CSM" on /workspace/[id]
→ assignCsmLever (Server Action)
→ assignLeadRole(...) ← writes to MongoDB
→ updateTag(workspace tag) ← EXPIRES the aggregate now
→ updateTag(serviceLead tag) ← EXPIRES the lead detail now
→ revalidateTag(serviceLeadList) ← list: stale-while-revalidate, as before
→ Next re-renders the current route after the action
→ getWorkspaceAggregate() (unstable_cache miss) ← re-fetches from DB
→ Rail, People and header show the assignee ✓ (no reload)Had those two calls been revalidateTag(tag, "max"), the re-render above would
have been served the pre-press aggregate and the screen would not have changed.
Adding new domains
To extend this pattern to a new entity:
- Add a
lib/queries/{entity}-cache.tswrapper aroundmakeEntityCache("{entity}"), re-exporting its tags and only the invalidation helpers that entity needs. - Create a
lib/queries/{entity}.tsfile that wraps the servicefindByIdcall inunstable_cachewith the appropriate tags. - Use the query helper in the relevant Server Component instead of calling the service directly.
- Invalidate the entity and list tags in every Server Action that mutates that
entity — through the wrapper’s
expireDetailhelper when the acting user’s own re-render must show the change, throughrevalidate*otherwise. List tags always takerevalidateList. - Update the call-site table in this document.