Skip to content
Chapter 73Lesson 2

Cache the reads

Wrap each of the three reads behind the invoices page in use cache — the paginated list, the per-org totals summary, and the single-invoice detail — so each serves a cached value instead of running its query on every request.

The timestamps tell you it worked. The starter stamps a fresh time on every refresh today, because nothing is cached; once you finish, reloading /invoices leaves listFetchedAt and summaryFetchedAt unchanged, and a detail page holds its fetchedAt too. A timestamp that stays put means the framework served a cached value rather than rerunning the query.

The finished /invoices page at desktop width — the FetchedAtStrip showing a List fetched at and a Summary fetched at timestamp, in a bordered muted bar above the invoices table.

Turn those three reads into cached functions without touching the query logic underneath. A cached read opens its body with 'use cache', picks a cacheLife profile that sets how stale the result may get, and emits its invalidation tags through the shared tags.ts helpers; the directive wraps the existing scoped query, it does not replace it.

The decision is which reads earn the directive. The list, summary, and detail are read often and tolerate a few minutes of staleness, so they take use cache. The toolbar and every Client Component stay dynamic: they read request-scoped URL state, and caching one would serve that request’s state to every other request.

The cache backend never exposes hit or miss to your code. Your only window is a fetchedAt timestamp that each cached function computes once when it runs and freezes into the entry: stable across requests means a hit, advancing means a miss. The starter already returns a fetchedAt line from each read; your job is to wrap that return in 'use cache' so the timestamp is computed per entry rather than per request.

Constraints. Every tag string lives only in tags.ts, so read and write sites import the same function. And a cached function’s tags depend only on its arguments: pass orgId in from the page, never read the session inside the cached body, or the cache key leaks request state across tenants.

Out of scope. updateTag and revalidateTag (the next two lessons), per-user 'use cache: private' caches, and edge or CDN tuning.

The three helpers in tags.ts each return their scoped string, vary by orgId, and are the only place a raw org:/invoice: literal exists.
tested
The list read runs on the minutes profile, carries the org list tag, returns a fetchedAt, and still serves the seeded active rows.
tested
The summary read runs on the hours profile, carries the org summary tag, returns a fetchedAt, and reads correctly against the empty seed via the live-aggregate fallback.
tested
Changing a filter argument (for example ?status=paid) returns a different result set, and a fixed argument set returns the same rows every time — each distinct argument set is its own cache key.
tested
The detail read runs on the minutes profile, carries both the record tag and the org list tag, and returns a fetchedAt.
tested
No cached body reads getSession(), cookies(), or headers() — every emitted tag is derived from the orgId argument the page passes in.
tested
The inspector’s cacheLife readout shows listInvoices: 'minutes', getInvoiceDetail: 'minutes', and getOrgInvoiceSummary: 'hours'.
untested

Implement tags.ts, profiles.ts, and the three cached reads against the brief and the test suite first. Then open the reference walkthrough below to see how each decision was made.

Reference solution and walkthrough

The starter ships invoiceTags as three stubs returning ''. Fill each with its tag template: scope first, lowercase, colon-delimited.

src/lib/cache/tags.ts
// The single source of truth for tag strings. Read sites (cached reads) and write
// sites (actions, the recompute job) import these helpers — a raw `org:`/`invoice:`
// literal anywhere else is a regression. Each is a pure function of its arguments.
// Tag strings are lowercase, colon-delimited, scope first.
export const invoiceTags = {
list: (orgId: string): string => `org:${orgId}:invoices`,
record: (orgId: string, id: string): string => `org:${orgId}:invoice:${id}`,
summary: (orgId: string): string => `org:${orgId}:summary`,
};

A read tags itself with invoiceTags.list(orgId) and a later write invalidates with the same call, so the two strings must match character-for-character. One module is what guarantees they do.

profiles.ts — the readout’s source of truth

Section titled “profiles.ts — the readout’s source of truth”

The inspector’s cacheLife readout reads this map rather than parsing the query bodies. Populate it to mirror the profiles you set in queries.ts.

src/lib/cache/profiles.ts
// Mirrors the `cacheLife` profile chosen for each cached read, so the inspector's
// readout can show it without parsing the query bodies. Keyed by cached-function
// name to match the directive trio in queries.ts.
export const cacheProfiles: Record<string, { profile: string }> = {
listInvoices: { profile: 'minutes' },
getInvoiceDetail: { profile: 'minutes' },
getOrgInvoiceSummary: { profile: 'hours' },
};

Until you fill it the readout shows dashes; once it matches the directives the panel reads listInvoices: 'minutes', getInvoiceDetail: 'minutes', getOrgInvoiceSummary: 'hours'. It is a dumb mirror, not a parser, so keep it in step with queries.ts by hand.

Every read gets the same three-line opener; nothing below it changes. The list read is the template, so step through it.

export const listInvoices = async ({
orgId,
view,
status,
sort,
q,
cursor,
role,
pageSize = 20,
}: ListInvoicesArgs): Promise<ListInvoicesResult & { fetchedAt: string }> => {
'use cache';
cacheLife('minutes');
cacheTag(invoiceTags.list(orgId));
const scoped = scopedInvoices(orgId);
const resolved = resolveView(view, role);
const base =
resolved === 'archived'
? scoped.archived()
: resolved === 'all'
? scoped.includingDeleted()
: scoped.active();
const needle = q.trim().toLowerCase();
// Compose status/search/sort/cursor onto the chosen view, then page it.
const paged = base
.filter((inv) => (status ? inv.status === status : true))
.filter((inv) =>
needle
? inv.customerName.toLowerCase().includes(needle) ||
inv.number.toLowerCase().includes(needle)
: true,
)
.sort((a, b) => compareBySort(a, b, sort))
.cursorAfter(cursor);
const page = paged.take(pageSize);
const nextCursor = paged.hasMoreThan(pageSize)
? (page[page.length - 1]?.id ?? null)
: null;
return {
rows: page,
nextCursor,
hasPrev: paged.hasPrev(),
fetchedAt: new Date().toISOString(),
};
};

Every argument is serializable, which is what lets it participate in the cache key.

export const listInvoices = async ({
orgId,
view,
status,
sort,
q,
cursor,
role,
pageSize = 20,
}: ListInvoicesArgs): Promise<ListInvoicesResult & { fetchedAt: string }> => {
'use cache';
cacheLife('minutes');
cacheTag(invoiceTags.list(orgId));
const scoped = scopedInvoices(orgId);
const resolved = resolveView(view, role);
const base =
resolved === 'archived'
? scoped.archived()
: resolved === 'all'
? scoped.includingDeleted()
: scoped.active();
const needle = q.trim().toLowerCase();
// Compose status/search/sort/cursor onto the chosen view, then page it.
const paged = base
.filter((inv) => (status ? inv.status === status : true))
.filter((inv) =>
needle
? inv.customerName.toLowerCase().includes(needle) ||
inv.number.toLowerCase().includes(needle)
: true,
)
.sort((a, b) => compareBySort(a, b, sort))
.cursorAfter(cursor);
const page = paged.take(pageSize);
const nextCursor = paged.hasMoreThan(pageSize)
? (page[page.length - 1]?.id ?? null)
: null;
return {
rows: page,
nextCursor,
hasPrev: paged.hasPrev(),
fetchedAt: new Date().toISOString(),
};
};

First statement in the body, so everything below is a cached Server Function.

export const listInvoices = async ({
orgId,
view,
status,
sort,
q,
cursor,
role,
pageSize = 20,
}: ListInvoicesArgs): Promise<ListInvoicesResult & { fetchedAt: string }> => {
'use cache';
cacheLife('minutes');
cacheTag(invoiceTags.list(orgId));
const scoped = scopedInvoices(orgId);
const resolved = resolveView(view, role);
const base =
resolved === 'archived'
? scoped.archived()
: resolved === 'all'
? scoped.includingDeleted()
: scoped.active();
const needle = q.trim().toLowerCase();
// Compose status/search/sort/cursor onto the chosen view, then page it.
const paged = base
.filter((inv) => (status ? inv.status === status : true))
.filter((inv) =>
needle
? inv.customerName.toLowerCase().includes(needle) ||
inv.number.toLowerCase().includes(needle)
: true,
)
.sort((a, b) => compareBySort(a, b, sort))
.cursorAfter(cursor);
const page = paged.take(pageSize);
const nextCursor = paged.hasMoreThan(pageSize)
? (page[page.length - 1]?.id ?? null)
: null;
return {
rows: page,
nextCursor,
hasPrev: paged.hasPrev(),
fetchedAt: new Date().toISOString(),
};
};

minutes suits a read edited this often: if an invalidation ever fails to fire, a stale list self-heals within minutes rather than lingering.

export const listInvoices = async ({
orgId,
view,
status,
sort,
q,
cursor,
role,
pageSize = 20,
}: ListInvoicesArgs): Promise<ListInvoicesResult & { fetchedAt: string }> => {
'use cache';
cacheLife('minutes');
cacheTag(invoiceTags.list(orgId));
const scoped = scopedInvoices(orgId);
const resolved = resolveView(view, role);
const base =
resolved === 'archived'
? scoped.archived()
: resolved === 'all'
? scoped.includingDeleted()
: scoped.active();
const needle = q.trim().toLowerCase();
// Compose status/search/sort/cursor onto the chosen view, then page it.
const paged = base
.filter((inv) => (status ? inv.status === status : true))
.filter((inv) =>
needle
? inv.customerName.toLowerCase().includes(needle) ||
inv.number.toLowerCase().includes(needle)
: true,
)
.sort((a, b) => compareBySort(a, b, sort))
.cursorAfter(cursor);
const page = paged.take(pageSize);
const nextCursor = paged.hasMoreThan(pageSize)
? (page[page.length - 1]?.id ?? null)
: null;
return {
rows: page,
nextCursor,
hasPrev: paged.hasPrev(),
fetchedAt: new Date().toISOString(),
};
};

The org’s list tag, derived purely from the orgId argument, never from session.

export const listInvoices = async ({
orgId,
view,
status,
sort,
q,
cursor,
role,
pageSize = 20,
}: ListInvoicesArgs): Promise<ListInvoicesResult & { fetchedAt: string }> => {
'use cache';
cacheLife('minutes');
cacheTag(invoiceTags.list(orgId));
const scoped = scopedInvoices(orgId);
const resolved = resolveView(view, role);
const base =
resolved === 'archived'
? scoped.archived()
: resolved === 'all'
? scoped.includingDeleted()
: scoped.active();
const needle = q.trim().toLowerCase();
// Compose status/search/sort/cursor onto the chosen view, then page it.
const paged = base
.filter((inv) => (status ? inv.status === status : true))
.filter((inv) =>
needle
? inv.customerName.toLowerCase().includes(needle) ||
inv.number.toLowerCase().includes(needle)
: true,
)
.sort((a, b) => compareBySort(a, b, sort))
.cursorAfter(cursor);
const page = paged.take(pageSize);
const nextCursor = paged.hasMoreThan(pageSize)
? (page[page.length - 1]?.id ?? null)
: null;
return {
rows: page,
nextCursor,
hasPrev: paged.hasPrev(),
fetchedAt: new Date().toISOString(),
};
};

Now computed once per cache entry, so a timestamp stable across requests is your hit signal.

1 / 1

Order is load-bearing: 'use cache' marks the boundary, then cacheLife and cacheTag configure it from inside. The query logic below is the list code you already shipped; you wrapped it, you did not rewrite it.

The summary read takes the same opener with the hours profile and tags the org summary:

src/lib/invoices/queries.ts
export const getOrgInvoiceSummary = async (
orgId: string,
): Promise<{
totalCount: number;
totalAmount: number;
updatedAt: string;
fetchedAt: string;
}> => {
'use cache';
cacheLife('hours');
cacheTag(invoiceTags.summary(orgId));
const row = getSummaryRow(orgId);
if (row) {
return {
totalCount: row.totalCount,
totalAmount: row.totalAmount,
updatedAt: row.updatedAt,
fetchedAt: new Date().toISOString(),
};
}
// Live fallback: count + sum(total) over the active (non-archived,
// non-deleted) rows for this org.
const active = scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER);
const totalCount = active.length;
const totalAmount = active.reduce((sum, inv) => sum + Number(inv.total), 0);
return {
totalCount,
totalAmount,
updatedAt: new Date(0).toISOString(),
fetchedAt: new Date().toISOString(),
};
};

hours fits a number that moves slowly and is refreshed by a background job, so the longer window means fewer recomputes. The provided fallback computes live totals over the active rows against the empty seed, so the read works before the job ever lands its first summaries row.

The detail read carries two tags. Step through its tag call.

export const getInvoiceDetail = async ({
orgId,
id,
role,
}: GetInvoiceDetailArgs): Promise<(Invoice & { fetchedAt: string }) | null> => {
'use cache';
cacheLife('minutes');
// The tag union: either a record-level write or an org-level (list) write
// reaches the detail entry.
cacheTag(invoiceTags.record(orgId, id), invoiceTags.list(orgId));
// Active + archived rows load for everyone (archived so the row can be
// restored); a soft-deleted row only loads for an admin.
const scoped = scopedInvoices(orgId);
const live = scoped.archived().find((inv) => inv.id === id);
if (live) {
return { ...live, fetchedAt: new Date().toISOString() };
}
const active = scoped.active().find((inv) => inv.id === id);
if (active) {
return { ...active, fetchedAt: new Date().toISOString() };
}
if (role === 'admin') {
const deleted = scoped.includingDeleted().find((inv) => inv.id === id);
return deleted ? { ...deleted, fetchedAt: new Date().toISOString() } : null;
}
return null;
};

The same opener as the list, because a single invoice is read and edited just as often.

export const getInvoiceDetail = async ({
orgId,
id,
role,
}: GetInvoiceDetailArgs): Promise<(Invoice & { fetchedAt: string }) | null> => {
'use cache';
cacheLife('minutes');
// The tag union: either a record-level write or an org-level (list) write
// reaches the detail entry.
cacheTag(invoiceTags.record(orgId, id), invoiceTags.list(orgId));
// Active + archived rows load for everyone (archived so the row can be
// restored); a soft-deleted row only loads for an admin.
const scoped = scopedInvoices(orgId);
const live = scoped.archived().find((inv) => inv.id === id);
if (live) {
return { ...live, fetchedAt: new Date().toISOString() };
}
const active = scoped.active().find((inv) => inv.id === id);
if (active) {
return { ...active, fetchedAt: new Date().toISOString() };
}
if (role === 'admin') {
const deleted = scoped.includingDeleted().find((inv) => inv.id === id);
return deleted ? { ...deleted, fetchedAt: new Date().toISOString() } : null;
}
return null;
};

Two tags on one entry: this exact invoice and the whole org list.

export const getInvoiceDetail = async ({
orgId,
id,
role,
}: GetInvoiceDetailArgs): Promise<(Invoice & { fetchedAt: string }) | null> => {
'use cache';
cacheLife('minutes');
// The tag union: either a record-level write or an org-level (list) write
// reaches the detail entry.
cacheTag(invoiceTags.record(orgId, id), invoiceTags.list(orgId));
// Active + archived rows load for everyone (archived so the row can be
// restored); a soft-deleted row only loads for an admin.
const scoped = scopedInvoices(orgId);
const live = scoped.archived().find((inv) => inv.id === id);
if (live) {
return { ...live, fetchedAt: new Date().toISOString() };
}
const active = scoped.active().find((inv) => inv.id === id);
if (active) {
return { ...active, fetchedAt: new Date().toISOString() };
}
if (role === 'admin') {
const deleted = scoped.includingDeleted().find((inv) => inv.id === id);
return deleted ? { ...deleted, fetchedAt: new Date().toISOString() } : null;
}
return null;
};

A write to invoice A invalidates A’s detail and leaves B’s alone, since record(orgId, A) is a distinct string from record(orgId, B).

export const getInvoiceDetail = async ({
orgId,
id,
role,
}: GetInvoiceDetailArgs): Promise<(Invoice & { fetchedAt: string }) | null> => {
'use cache';
cacheLife('minutes');
// The tag union: either a record-level write or an org-level (list) write
// reaches the detail entry.
cacheTag(invoiceTags.record(orgId, id), invoiceTags.list(orgId));
// Active + archived rows load for everyone (archived so the row can be
// restored); a soft-deleted row only loads for an admin.
const scoped = scopedInvoices(orgId);
const live = scoped.archived().find((inv) => inv.id === id);
if (live) {
return { ...live, fetchedAt: new Date().toISOString() };
}
const active = scoped.active().find((inv) => inv.id === id);
if (active) {
return { ...active, fetchedAt: new Date().toISOString() };
}
if (role === 'admin') {
const deleted = scoped.includingDeleted().find((inv) => inv.id === id);
return deleted ? { ...deleted, fetchedAt: new Date().toISOString() } : null;
}
return null;
};

The list tag too, so an org-wide change that invalidates the whole list also reaches this detail entry.

1 / 1

The tag-union rule in practice: a cached read carries every tag that names a write which should refresh it, so tagging the detail by its own identity alone would leave it stale after an org-wide change.

The brief never asks you to touch page.tsx. The list page already destructures fetchedAt off listInvoices and the summary, and feeds both into the strip:

src/app/(app)/invoices/page.tsx
// The page resolves the session and passes `orgId` in — no session/cookies call
// ever moves inside a cached read body (the cache key stays a pure function of
// its arguments).
const { rows, nextCursor, hasPrev, fetchedAt } = await listInvoices({
orgId: session.orgId,
role: session.role,
...parsed,
});
const summary = await getOrgInvoiceSummary(session.orgId);
return (
<div data-testid="invoices-page" className="space-y-4">
<h1 className="text-xl font-semibold">Invoices</h1>
{/* The cache-state strip renders ABOVE the two-region grid — never a third
grid child. */}
<FetchedAtStrip
listFetchedAt={fetchedAt}
summaryFetchedAt={summary.fetchedAt}
/>

The page resolves the session in the dynamic layer and passes session.orgId into each read as a plain argument, so the cached function never sees the session. Once your three reads return a frozen fetchedAt, the strip has stable values to show.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 2

Vitest has no live cache, so the suite spies on next/cache and checks each read’s cacheLife profile, tags, fetchedAt, and determinism. A failing assertion names the read and the missing piece. Green proves the directive stack is wired, not that the live cache holds values. Confirm that by hand against the dev server and the inspector.

Open /invoices; note listFetchedAt and summaryFetchedAt. Reload promptly — both are unchanged.
untested
Open the inspector’s hit/miss probe link to /invoices, reload promptly, and confirm the same listFetchedAt.
untested
Change the URL filter to ?status=paid; listFetchedAt is new for the new arguments; reload and it holds steady.
untested
Open an invoice detail page; note its fetchedAt; reload and it holds steady.
untested
The inspector’s cacheLife readout shows listInvoices: 'minutes', getInvoiceDetail: 'minutes', getOrgInvoiceSummary: 'hours'.
untested
Grep for org: and invoice: outside tags.ts and the cached reads — zero hits at any other site.
untested

Nothing invalidates these entries yet, so a write stays stale until its cacheLife window expires; the next two lessons add invalidation.