Skip to content
Chapter 73Lesson 1

Project overview

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.

The finished /invoices page — the FetchedAtStrip at the top showing a List fetched at and a Summary fetched at timestamp, above the invoices table with its Active / Archived / All view tabs, the status / sort / search toolbar, and the pagination row.
The finished state the roadmap reaches, not the starter — the two surfaces where you read cache hit and miss off a fetchedAt timestamp.
  • Choosing what earns a use cache directive on an authenticated SaaS surface and what stays dynamic.
  • Centralizing tag strings in a single tags.ts that read sites and write sites share.
  • Keeping a cached read pure — its tags depend only on its arguments, never on ambient session state.
  • Reading cache hit and miss off a fetchedAt proxy because the framework does not surface it.
  • Picking between updateTag (read-your-writes, from Server Actions) and revalidateTag (eventual, from background work) by asking who is waiting on the result.
  • Fanning a single mutation out to every cached read it affects, after commit and before the redirect.

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.

  • The /invoices Server Component reads the session, then calls the cached reads with orgId passed in as an argumentlistInvoices(args), getOrgInvoiceSummary(orgId), and getInvoiceDetail({ orgId, id }) on the detail route.
  • Each cached read opens with 'use cache', sets a cacheLife profile, emits its invalidation tags through tags.ts, and returns a fetchedAt timestamp frozen into the cache entry.
  • The four lifecycle Server Actions — 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.
  • The in-process 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.
  • The /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.
/invoices Server Component

Reads the session, then passes orgId in as an argument.

listInvoices(args) getOrgInvoiceSummary(orgId) getInvoiceDetail({ orgId, id })
Cached reads
'use cache'
cacheLife(profile)
cacheTag(tags.ts)
fetchedAt
Lifecycle Server Actions

update · archive · restore · softDelete

commit updateTag(list, record, summary) redirect

The same user lands on a fresh read.

recomputeOrgSummary job

recompute revalidateTag(summary)

No one is waiting — the next visit picks up the new aggregate.

/inspector Server Component

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.

The cached reads are the hub: /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.

  • Directorysrc/
    • Directorylib/
      • Directorycache/
        • tags.ts TODO(L2) — the three tag helpers, returning empty-string stubs
        • profiles.ts TODO(L2) — the cacheLife map, empty for now
        • log.ts logCacheInvalidation(tag, source)
      • Directoryinvoices/
        • queries.ts TODO(L2) — the three reads, no use cache yet
        • actions.ts TODO(L3) — four lifecycle actions, only revalidatePath, no updateTag
        • scoped-query.ts scopedInvoices(orgId) fluent builder
        • search-params.ts nuqs parsers
      • result.ts Result<T>, ok / err / conflict
      • authed-action.ts authedAction(role, schema, fn)
    • Directoryserver/
      • store.ts in-memory store: invoices, audit logs, summaries Map (seeded empty), invalidation log, misuse flag, reseed()
      • session.ts cookie-based dev identity
      • types.ts Invoice, AuditLog, Role
      • Directoryjobs/
        • summary-recompute.ts TODO(L4) — body throws summary job not implemented
    • Directoryapp/
      • Directory(app)/invoices/
        • page.tsx renders the <FetchedAtStrip /> over the list
        • fetched-at-strip.tsx the cache-state readout
      • Directoryinspector/ provided in full — page, actions, the force-updateTag route, and all panels
  • next.config.ts cacheComponents: true is already set
  • package.json dev, 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.

  1. Get the starter codebase from the project repository, under Chapter 073/start/.

  2. Move into the starter directory (start/ is the project; solution/ holds the reference):

    Terminal window
    cd start
  3. Install dependencies:

    Terminal window
    pnpm install
  4. Start the dev server:

    Terminal window
    pnpm dev

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