Skip to Content
TechnicalCache Tag Convention

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.

ProfileCallEffect on the cached entryLegal in
Stale-while-revalidaterevalidateTag(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-writesupdateTag(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

  1. Always prefix with tenant:{tenantId}.
    This ensures that invalidating one tenant’s data never affects another tenant’s cache.

  2. 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.

  3. Entity tags (e.g. tenant:abc:service-lead:123) cover a single document. Invalidate them after any write that touches that specific record.

  4. 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/:

HelperTag producedScope
serviceLeadCacheTags.list(tenantId)tenant:{tenantId}:service-leadsAll leads for a tenant
serviceLeadCacheTags.detail(tenantId, id)tenant:{tenantId}:service-lead:{id}One service lead
proposalCacheTags.list(tenantId)tenant:{tenantId}:proposalsAll 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}:workspacesEvery 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 helperFileTags applied
getLeadById(tenantId, id)lib/queries/service-leads.tsserviceLead, serviceLeadList
getProposalById(tenantId, id)lib/queries/proposals.tsproposal, proposalList
getWorkspaceAggregate(tenantId, leadId, persona)lib/queries/workspace.tsworkspace (per lead), workspaceList, serviceLead

Invalidation call sites

Service leads

Server actionFileTags invalidated
updateLeadOverviewcomponents/service-leads/lead-overview/actions.tsserviceLead(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 actionFileTags invalidated
createProposalapp/(app)/proposals/create/actions.tsproposalList(tenantId) — stale-while-revalidate
updateProposalapp/(app)/proposals/create/actions.tsproposal(tenantId, id), proposalList(tenantId) — stale-while-revalidate

Lead workspace

Server actionFileTags invalidated
every lever, via refreshWorkspacecomponents/workspace/actions.tsworkspace(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 actionFileWhy
setExpertEngagementapp/(app)/users/[id]/actions.tsclass and day rate
setUserEmploymentapp/(app)/users/[id]/actions.tsemployment is the Internal class
updateTenantUser (experts and customers)app/(app)/users/[id]/actions.tsan expert’s location; a customer’s company, on the workspace customer card (customers also mark the lead list tag stale, for getLeadById)
updateExpertKnowledgeapp/(app)/expert/my-knowledge/actions.tsposition, years, availability
approveExpertEvidence, rejectExpertEvidenceapp/(app)/expert-evidence/actions.tsvetted
deleteExpertEvidenceapp/(app)/expert/my-knowledge/expert-evidence-actions.tsarchiving 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 actionFileWhen
acceptCustomerQuoteActioncomponents/service-leads/lead-customer-quote-approval/actions.tsevery acceptance — the lead becomes the expert’s active project
qualifyOutLeadcomponents/service-leads/lead-detail-actions/actions.tsevery qualify-out
archiveLeadcomponents/service-leads/lead-detail-actions/actions.tsevery archive — the count skips archived leads
approveInvoiceActioncomponents/finances/invoice-approvals/actions.tsonly when the service reports leadStatusMoved (the final milestone)
markInvoicePaidActioncomponents/finances/invoices-list/actions.tsonly 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:

  1. Add a lib/queries/{entity}-cache.ts wrapper around makeEntityCache("{entity}"), re-exporting its tags and only the invalidation helpers that entity needs.
  2. Create a lib/queries/{entity}.ts file that wraps the service findById call in unstable_cache with the appropriate tags.
  3. Use the query helper in the relevant Server Component instead of calling the service directly.
  4. Invalidate the entity and list tags in every Server Action that mutates that entity — through the wrapper’s expireDetail helper when the acting user’s own re-render must show the change, through revalidate* otherwise. List tags always take revalidateList.
  5. Update the call-site table in this document.
Last updated on