Lesson 2 — Cache the reads
Write tags.ts and profiles.ts, annotate the list, summary, and detail reads with use cache + cacheLife + cacheTag, and emit fetchedAt so the strip holds steady across refreshes.
The starter is the invoices list you built in The production list view — URL-driven filters, sorting, cursor pagination, version-precondition writes, an audit log — running with no caching at all. Every visit to /invoices recomputes the whole list and the per-org totals from scratch, on every refresh. That is the correct default for an authenticated SaaS surface, and it is also the thing this project changes for exactly three reads: the list, the per-org summary, and the single-invoice detail are read often and tolerate a few minutes of staleness, so they earn a cache. The rest of the surface stays dynamic.
The work itself is small and decision-dense. You will mark those three reads with use cache, define a single tags.ts that every read site and every write site imports, fan updateTag out of the four lifecycle actions so a user who edits an invoice reads their own change on the redirect, and call revalidateTag from a background recompute job where nobody is waiting. The payoff is the decision lens, not the mechanics — who is waiting on a write decides which invalidation primitive fires, and tag strings live in exactly one place. You read every one of these effects off two surfaces the starter hands you: a <FetchedAtStrip /> at the top of /invoices, and a dedicated /inspector page. Next.js does not surface cache hit or miss to your code, so a timestamp that holds steady across refreshes is the only signal you get — and these two pages are where you read it.
use cache directive on an authenticated SaaS surface and what stays dynamic.tags.ts that read sites and write sites share.fetchedAt proxy because the framework does not surface it.updateTag (read-your-writes, from Server Actions) and revalidateTag (eventual, from background work) by asking who is waiting on the result.Read this shape left to right, from the read path through to the two write paths. None of the rationale lives here — why cacheComponents, why these cacheLife profiles, which invalidation call to reach for — was the subject of Cache decisions as architecture; this project is where you build it.
/invoices Server Component reads the session, then calls the cached reads with orgId passed in as an argument — listInvoices(args), getOrgInvoiceSummary(orgId), and getInvoiceDetail({ orgId, id }) on the detail route.'use cache', sets a cacheLife profile, emits its invalidation tags through tags.ts, and returns a fetchedAt timestamp frozen into the cache entry.updateInvoice, archiveInvoice, restoreInvoice, softDeleteInvoice — commit their in-store write, then fan updateTag out to the list, record, and summary tags, then redirect. The same user lands on a fresh read.recomputeOrgSummary job recomputes the summaries row and calls revalidateTag for the summary tag. No user is waiting, so the next visit picks up the new aggregate./inspector Server Component drives every cache-state observation: the fetchedAt strip, the hit/miss probe, one-click edit and lifecycle buttons, the run-summary-task button, the misuse toggle, the force-updateTag island, and the tag-invalidation log tail.Reads the session, then passes orgId in as an argument.
listInvoices(args) getOrgInvoiceSummary(orgId) getInvoiceDetail({ orgId, id }) 'use cache' cacheLife(profile) cacheTag(tags.ts) fetchedAt update · archive · restore · softDelete
commit → updateTag(list, record, summary) → redirect
The same user lands on a fresh read.
recompute → revalidateTag(summary)
No one is waiting — the next visit picks up the new aggregate.
Drives every cache-state observation — the fetchedAt strip, the hit/miss probe, the one-click lifecycle and run-summary buttons, the misuse toggle, and the invalidation-log tail.
/invoices feeds them, the lifecycle actions invalidate them with updateTag (read-your-writes, a user is waiting), the recompute job with revalidateTag (eventual, no one is waiting), and /inspector reads fetchedAt off them.
The starter ships The production list view working end to end, with one change from that project: the data layer is a deterministic in-memory store standing in for Postgres, so there is no database to run, no Docker, and no .env file. The whole project boots with pnpm install and pnpm dev. You layer the cache on top — no rewrites to the existing surface, only additions inside five files.
Those five files are your focus; they are the highlighted ones below, each carrying an inline TODO. Everything uncommented is provided: read it as you need to, but you will not author it.
cacheLife map, empty for nowlogCacheInvalidation(tag, source)use cache yetrevalidatePath, no updateTagscopedInvoices(orgId) fluent builderResult<T>, ok / err / conflictauthedAction(role, schema, fn)summaries Map (seeded empty), invalidation log, misuse flag, reseed()Invoice, AuditLog, Rolesummary job not implemented<FetchedAtStrip /> over the listupdateTag route, and all panelscacheComponents: true is already setdev, build, verify, test:lesson (no db scripts)The store’s summaries Map is seeded empty, which would be a problem for a summary read on minute one — except the provided getOrgInvoiceSummary falls back to a live count and sum over the active rows when no summary row exists yet. So the read works from the start; once the background job lands and writes a summaries row, the read serves it. The <FetchedAtStrip /> is provided too — you thread a fetchedAt: new Date().toISOString() line through each cached read’s return so the strip has something to show, rather than building the component.
Lesson 2 — Cache the reads
Write tags.ts and profiles.ts, annotate the list, summary, and detail reads with use cache + cacheLife + cacheTag, and emit fetchedAt so the strip holds steady across refreshes.
Lesson 3 — Read-your-writes invalidation
Fan three updateTag calls out of the four lifecycle actions after commit, so an edit refreshes the list and summary on the same render; wire the misuse-revalidateTag failure-mode demo.
Lesson 4 — Eventual invalidation
Implement recomputeOrgSummary to recompute the aggregate and call revalidateTag, landing the new summary on the next visit (stale-while-revalidate).
There is no database, no Docker, and no environment file. The project runs against the in-memory store, and your dev identity comes from an acting-identity cookie that defaults to org-acme:admin and is switched on the inspector.
Get the starter codebase from the project repository, under Chapter 073/start/.
Move into the starter directory (start/ is the project; solution/ holds the reference):
cd startInstall dependencies:
pnpm installStart the dev server:
pnpm devThere are no environment variables to set — that is deliberate, and it is what lets the project boot with nothing but pnpm dev.
On success, /invoices renders the list exactly as it did in The production list view, with the <FetchedAtStrip /> showing a “List fetched at” and a “Summary fetched at” timestamp. Refresh the page a few times and watch those timestamps advance on every load — nothing is cached yet, so each visit recomputes the reads and stamps a fresh time. Open /inspector: the page loads, “Edit one invoice” commits at the store and writes an audit-log row but fires no updateTag, and “Run summary task” throws because the job body is unimplemented. That is the expected starting state.