Skip to content
Chapter 73Lesson 1

Project overview

What you'll build this chapter, a tag-driven cache over the invoices list with use cache, and how to set up the starter.

The starter is the uncached invoices list from The production list view. Every visit to /invoices recomputes the whole list and the per-org totals from scratch. That is the right default for an authenticated surface. This project caches exactly three reads that are hit often and tolerate a few minutes of staleness: the list, the per-org summary, and the single-invoice detail. Everything else stays dynamic.

You mark those three reads with use cache and define one tags.ts that every read and write imports. The four lifecycle actions fan updateTag out so a user who edits an invoice sees their own change on the redirect, while a background recompute job, where no one is waiting, calls revalidateTag. That is the lesson: who is waiting on a write picks the invalidation primitive, and tag strings live in one place. Next.js does not expose cache hits or misses to your code, so a fetchedAt timestamp that holds steady across refreshes is your only signal. You read it off two surfaces the starter provides: a <FetchedAtStrip /> atop /invoices and a dedicated /inspector page.

The finished /invoices page: a FetchedAtStrip above the invoices table.
The finished state the roadmap reaches, not the starter: the two surfaces where a fetchedAt timestamp reveals cache hits and misses.
  • Deciding which reads earn use cache and which stay dynamic.
  • Centralizing every tag string in one tags.ts that reads and writes both import.
  • Keeping a cached read pure: its tags depend only on its arguments, never on session state.
  • Reading hit and miss off a fetchedAt value, since the framework hides them.
  • Choosing updateTag (a user is waiting) over revalidateTag (no one is) by asking who needs the result.
  • Fanning one mutation out to every cached read it touches, after the commit and before the redirect.

The previous chapter covered why: why cacheComponents, which cacheLife profile, which invalidation call. This one builds it.

The diagram puts the cached reads at the center. /invoices feeds them, the inspector reads their fetchedAt, and two write paths invalidate them. Which invalidation call each write path uses turns on one question: is a user waiting on this write? A lifecycle action has one waiting, the same user who reloads the page, so it calls updateTag for read-your-writes. The recompute job has no one waiting, so it calls revalidateTag and lets the next visit pick up the change.

/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

Observes cache state: the fetchedAt strip, the hit/miss probe, 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 their fetchedAt.

The starter is The production list view, working end to end, with one change: an in-memory store replaces Postgres, so there is no database, no Docker, and no .env. It boots with pnpm install and pnpm dev. You add the cache on top; you never rewrite the surface.

Your work lives in five files, highlighted below and marked with an inline TODO. Everything else is provided; read it as needed, but you will not write it.

  • Directorysrc/
    • Directorylib/
      • Directorycache/
        • tags.ts TODO(L2) — three tag helpers, stubbed to empty strings
        • 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)

Two things are easy to miss. The summaries Map starts empty, but the summary read still works: getOrgInvoiceSummary counts and sums the live rows when no summary row exists, then serves the summaries row once the background job writes one. And <FetchedAtStrip /> is already built; you only thread a fetchedAt: new Date().toISOString() line through each cached read’s return so the strip has something to show.

Lesson 2 — Cache the reads

Add tags.ts and profiles.ts, then annotate the list, summary, and detail reads with use cache, cacheLife, and cacheTag, emitting fetchedAt so timestamps hold steady across refreshes.

Lesson 3 — Read-your-writes invalidation

Fan three updateTag calls out of the four lifecycle actions so an edit refreshes the list and summary on the same render, then wire the demo that misuses revalidateTag.

Lesson 4 — Eventual invalidation

Implement recomputeOrgSummary to recompute the aggregate and call revalidateTag, landing the new summary on the next visit.

There is no database, no Docker, and no environment file: the project runs against an 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

On success, /invoices renders the list as it did in The production list view, with <FetchedAtStrip /> showing a “List fetched at” and a “Summary fetched at” timestamp. Refresh a few times: both advance on every load, because nothing is cached yet and each visit recomputes the reads. Now 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.