Skip to content
Chapter 72Lesson 1

Route classes and the tag scheme

The judgment behind Next.js caching, deciding which routes to cache and designing a tag scheme that invalidates exactly the right entries.

Open the app/ directory of the application you’ve been building across this course. There’s the dashboard, the invoices list with its URL-driven filters, the invoice detail page, the settings screens, the public marketing and pricing pages. You already know every caching mechanic that could touch these routes: 'use cache', cacheLife, cacheTag, the four invalidation calls, and the cacheComponents: true default. What you don’t yet have is the decision that sits one step earlier, before you type a single 'use cache'. For each of these routes, what is the right caching posture? And once a route caches anything, what tag does each cached read carry, so that the mutation which changes its data invalidates exactly the right entries and nothing more?

That decision is what separates a cache that quietly makes the app faster from one that silently serves a customer last week’s invoice. This lesson teaches almost no new API. It teaches the judgment that goes in front of the API, and it produces two artifacts you’ll commit to the repo: a one-page table that classifies every route, and a single lib/tags.ts file where every tag string lives. Get those two right and the actual cache code writes itself.

Caching is opt-in, and dynamic is the right default

Section titled “Caching is opt-in, and dynamic is the right default”

Start from what cacheComponents: true already does for you. Under that config, every route is dynamic until you add a 'use cache' boundary. Nothing gets cached by accident: caching is something you reach for deliberately, not something that happens behind your back.

Here’s a Server Component that reads the signed-in user’s invoices and renders them.

app/invoices/page.tsx
export default async function InvoicesPage() {
const { orgId } = await requireOrgUser();
const invoices = await listInvoices(orgId);
return <InvoiceTable invoices={invoices} />;
}

Notice there is no 'use cache' here, and that is correct, not unfinished. The instinct that “uncached means I haven’t optimized this yet” is the first thing to unlearn. For an authenticated, per-user, per-org surface like this one, leaving it dynamic is the experienced call. Here’s the reasoning behind it.

A cache only pays off when the same value gets read many times between writes. The invoices list is read by one user, scoped to one org, and the underlying rows change whenever anyone on the team edits an invoice. If you cached it, the hit rate would hover near zero. Almost every read would recompute anyway, because the audience is tiny and the data keeps moving. You’d pay the full cost of a cache, namely one more thing that can go stale and one more thing to invalidate correctly, and get almost none of the benefit.

The question that decides whether a route is even a candidate is the read-to-write ratio : how often is this read, divided by how often the underlying data changes? A high ratio, read by many users and written rarely, is the green light. A low ratio is the signal to stay dynamic.

This quietly reframes the question most people ask. The instinct is to ask “is this page slow?”, and slowness pushes you toward caching whatever is heaviest. The better question is “is this value shared and stable?” Caching the app chrome that every user loads on every page beats caching one deep, slow, per-user widget, even though the widget is the slower of the two. Shared and stable wins over slow-but-personal every time.

With that default in mind, every route in the app lands in one of three classes. Think of it as a checklist you run on each new route, and read the classes as a gradient: on one end everything renders at request time, on the other everything renders at build time, and in the middle a mix.

Fully dynamic is the left end: every read happens at request time, with no 'use cache' anywhere. The dashboard, the inbox, the settings page, and the invoices list you built with its URL-driven filters all belong here, along with anything whose contents can change from one second to the next, scoped to one user or one org. Most authenticated routes live here, and that is healthy, not a failure to optimize.

Fully static is the right end: no dynamic signal anywhere, so the whole page prerenders at build time and ships as flat HTML. The marketing pages, the pricing page (whose plan copy is written at build time, not read per request), and a docs site all qualify. Every visitor gets identical bytes.

Partially cached is the middle, and it’s the shape you already met as Partial Prerendering: a dynamic outer shell wraps cached subtrees behind Suspense boundaries. The static shell streams instantly, and the cached holes fill in. The invoice detail page is the classic case. The invoice itself mutates rarely, so you cache that subtree, while the surrounding chrome (the “last viewed by you” line, a live activity feed) stays dynamic and streams.

The following diagram lays the three classes out as that gradient, left to right, with example routes under each and a one-line note on what renders when.

Fully dynamic Rendered per request
/dashboard /invoices /settings
 dynamic every read at request time
Partially cached Static shell, cached holes
/invoices/[id]
shell · static
cached read the invoice subtree
Fully static Prerendered at build
/pricing /(marketing) /docs
 static whole page at build time
Every route lands somewhere on the request-time to build-time gradient. The middle is Partial Prerendering: a static shell with a cached read punched into it.

The middle column is the one to sit with: it is literally a static shell with a cached hole punched in it. That’s why “partially cached” is the same picture as PPR. It is PPR, just viewed through the question “which subtree did we choose to cache?”

The cacheable shortlist, and the not-cacheable list

Section titled “The cacheable shortlist, and the not-cacheable list”

The read-to-write ratio is the right principle, but in practice you won’t recompute a ratio for every route. You’ll pattern-match. Commit two short lists to memory, because in a SaaS the cacheable surfaces cluster in predictable places.

The cacheable shortlist has a high ratio: the same value is read by many users many times between writes.

  • Plan entitlements , read on nearly every authenticated request and changed only when a subscription changes.
  • Org membership lists and role definitions, read constantly and edited rarely.
  • Feature flags , read every request and toggled maybe weekly.
  • Public marketing, pricing, and docs pages, the same bytes for everyone.
  • OG image generators and route-shell metadata, computed once and served to everyone who shares the link.

The property they share is that the same value is read by many people, many times, between rare writes.

The not-cacheable list is where the reflex is “don’t bother”.

  • Personalized lists where the URL state owns the filter. Your invoices list with ?status=overdue&sort=-amount is a different payload for every filter combination, so the hit rate fragments to nothing across all the combinations users actually type.
  • Real-time dashboards and inbox feeds, where the whole point is freshness.
  • Anything behind cookies() or headers() that depends on the request.
  • Anything whose payload includes now() or a per-action counter, which is different on every read by construction.

Trying to cache something from the second list isn’t just wasted effort. It actively adds a staleness bug to a surface that had none, because now there’s an entry that can go stale and a mutation somewhere that has to remember to invalidate it.

The following exercise gives you eight concrete surfaces from an app like yours. For each one, decide the caching posture: cache it because the ratio earns it, or keep it dynamic.

Decide the caching posture for each surface — cache it only if the read-to-write ratio earns it. Drag each item into the bucket it belongs to, then press Check.

Cache it High read-to-write ratio, shared value
Keep it dynamic Per-user, per-request, or fast-changing
An org’s plan-entitlements row, hit on the layout of every authenticated page
The public pricing page, identical for every visitor
Feature flags consulted on each request, flipped during a rollout once a week
Published help-center articles served straight from build output
An invoices view rendered from ?status=overdue&sort=-amount query state
The dashboard’s live revenue counter that ticks as orders land
A single user’s notification feed, keyed to their session
A page header that prints the current server time on each load

cacheLife is a product decision, not a performance knob

Section titled “cacheLife is a product decision, not a performance knob”

For every route you do decide to cache, you pick a cacheLife profile. The three numbers behind a profile, stale, revalidate, and expire, all describe one thing: how long the user tolerates slightly-old data. That’s a product question, not a performance one. You answer “how fresh does this need to feel?” by thinking about the user, not the server.

The 2026 built-in profiles run from “barely cached” to “effectively static”: 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max'. Picking one is a judgment call you make per cached read. Here are a few worked examples to calibrate against.

export async function listMembershipsForUser(userId: string) {
'use cache';
cacheLife('hours');
cacheTag(userTags.all(userId));
return db.query.orgMembers.findMany({
where: eq(orgMembers.userId, userId),
});
}

Membership changes rarely, so an hour of staleness is invisible to the user. Someone joining or leaving an org is a once-in-a-while event, so serving an hour-old list costs nothing perceptible.

Plan entitlements read inside an action path are the interesting case: you’d pick 'minutes' and invalidate explicitly the moment the plan changes. The profile is the floor, the longest you’d ever serve a stale value if nothing pushed it, and the tag is the push that refreshes it the instant the truth changes. The shape you saw in the chapter on Cache Components is 'max' plus precise tags: for data that only ever changes through a known mutation you can tag, let the entry live effectively forever and rely on the tag to invalidate it on the dot.

One thing to watch: 'seconds' on a frequently-read public page barely lifts the hit rate and pays constant revalidation churn for the privilege. When the data permits, reach for 'hours' or 'days'.

The tag scheme is the contract between read and write

Section titled “The tag scheme is the contract between read and write”

This is the heart of the lesson. A cached read declares its tags with cacheTag(...). A mutating write, whether a Server Action or later a webhook, calls updateTag or revalidateTag with the same string. Those two strings, written in two different files by possibly two different people, must line up exactly. The tag is the named contract between the read that produces a value and the write that changes its data.

Here’s what happens without a scheme. Tags become free-form strings, invented at each call site. The read tags itself 'invoice-list'. Weeks later, someone writes the edit action and tags 'invoices', because that’s the obvious name to them. Watch what TypeScript does about it.

// read site — db/queries/invoices.ts
cacheTag('invoice-list');
// write site — app/invoices/actions.ts
updateTag('invoices'); // never matches the read's tag

No error, no warning: the strings simply never match, so the cached list never invalidates and serves stale data indefinitely. Each site reads as correct on its own. Only together do they reveal the silent drift.

This failure mode is the one to watch for, because it is invisible. Nothing crashes. No type error. No log line. The cached entry simply never invalidates and serves the old value forever, until a user files a ticket that says “I edited the invoice but it still shows the old amount.” By then the bug is days old and lives in two files that look individually correct.

With a scheme, the tag stops being a string you invent and becomes a function of the entity and its scope, something you derive mechanically. That’s the whole idea, and the rest of the lesson works out the shape.

Four tag scopes: entity, record, org, user

Section titled “Four tag scopes: entity, record, org, user”

The scheme produces four shapes of tag. You’ll use two of them constantly and the other two situationally, so we’ll build up in that order.

Here are the two you’ll reach for on almost every entity:

  • The org-scoped list tag, invoiceTags.list(orgId), invalidates every cached invoice-list read for one org. In a multi-tenant SaaS, reads are always tenant-bound, so this “entity” tag is org-scoped by construction. There is no useful global “all invoices everywhere” tag, because no read ever wants all invoices everywhere.
  • The record tag, invoiceTags.record(orgId, id), invalidates a single invoice’s cached read and nothing else.

Then the two situational scopes:

  • The whole-org tag, orgTags.all(orgId), is a coarse switch that invalidates everything cached for an org at once. Reach for it on org-wide events: an org rename, or a plan change that cascades across many cached surfaces.
  • The user-scoped tag, userTags.all(userId), is for user-private data like a user’s “orgs I’m in” list or personal notifications. Note the caution here: user-scoped caches are usually low-value, since a per-user read has a near-zero hit rate, as you saw. So this tag mostly exists to invalidate the rare shared-but-user-keyed read, not as an invitation to cache per-user data.

The rule that ties them together is the tag union : a cached read attaches all the tags by which any writer might want to invalidate it. Walk through the invoice detail read with that lens.

export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.record(orgId, id));
cacheTag(invoiceTags.list(orgId));
const [invoice] = await tenantDb(orgId)
.select()
.from(invoices)
.where(eq(invoices.id, id));
return invoice;
}

The record tag. A writer that edits this one invoice fires this, and only this read refreshes.

export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.record(orgId, id));
cacheTag(invoiceTags.list(orgId));
const [invoice] = await tenantDb(orgId)
.select()
.from(invoices)
.where(eq(invoices.id, id));
return invoice;
}

The list tag, attached to the same read. Now a writer that invalidates the whole org’s list, say after an archive sweep, also reaches this detail read, so it can’t go stale behind a coarser write.

export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.record(orgId, id));
cacheTag(invoiceTags.list(orgId));
const [invoice] = await tenantDb(orgId)
.select()
.from(invoices)
.where(eq(invoices.id, id));
return invoice;
}

The union: this one cached entry carries both tags. Invalidating either one invalidates the entry. The read attaches every tag a writer might use, and the writer later picks the narrowest one that covers its change.

1 / 1

That last point is the symmetry to hold onto: the read attaches the union of every tag that could matter to it, and the write picks the single narrowest tag that captures what it changed. Reads are generous, writes are precise.

Tag-string conventions and the lib/tags.ts helper

Section titled “Tag-string conventions and the lib/tags.ts helper”

The functions produce strings, and the strings follow conventions. We’ll name the conventions first, because they explain the helper’s shape, then build the helper.

The string format is lowercase and colon-delimited, with scope first, entity next, and id last: org:${orgId}:invoices for the list, org:${orgId}:invoice:${id} for the record. Three rules govern the contents. No interpolated user input that isn’t already a validated id. No PII in tags, since they show up in logs and traces. And stay under the framework’s 256-character cap, which you’ll never approach in practice. You’ll rarely write these raw strings yourself, though, because the helper produces them.

The helper is one file, lib/tags.ts, sitting next to your other shared lib/ utilities. It exports namespaced objects whose methods return the strings. Cached reads import them, and write sites import the exact same functions. The string exists in exactly one place. Refactoring a tag becomes a one-line edit to a function body instead of a project-wide grep-and-pray. A typo becomes a type error: misremember invoiceTags.lst and the build fails, rather than producing a silent miss that surfaces as a customer complaint.

The following three tabs show the one source of truth and the two sites that share it.

lib/tags.ts
export const invoiceTags = {
list: (orgId: string) => `org:${orgId}:invoices`,
record: (orgId: string, id: string) => `org:${orgId}:invoice:${id}`,
};
export const orgTags = {
all: (orgId: string) => `org:${orgId}`,
};
export const userTags = {
all: (userId: string) => `user:${userId}`,
};

The only file where tag strings exist, with every scope a small function of its arguments. Read sites and write sites both reach for these, so the string can only be defined once.

Org-scoped tags mirror the org-scoped data layer

Section titled “Org-scoped tags mirror the org-scoped data layer”

Look again at those tags and you might feel a small echo from org:${orgId}:invoices. Where have you scoped reads by orgId before?

In tenantDb(orgId), the org-scoped data layer. It injects the org predicate into every query so the missing-where bug, the one that leaks one tenant’s rows into another’s response, can’t even compile. The org-scoped tag is the cache-layer mirror of that exact boundary. org:${orgId}:invoices covers precisely the scope that tenantDb(orgId) reads, with the same orgId running through both sides.

The following diagram puts them side by side. The shared orgId key is what makes the two halves line up.

Data layer org-scoped reads & writes
tenantDb(orgId)
rows for org A only
shared key orgId
Cache layer org-scoped tags
org:A:invoices
cached reads for org A only
One scope shape runs through both layers — tenantDb(orgId) on reads, org:${orgId}:invoices on the cache. The same orgId keys both sides, so a mutation in one org's scope invalidates only that org's cached reads.

The payoff is concrete. A mutation inside an org’s scope calls updateTag(invoiceTags.list(orgId)), and only that org’s cached list reads invalidate. Every other org’s cached reads survive untouched: no cross-tenant invalidation, and no collateral cache misses for orgs that changed nothing. One scope shape, enforced identically on both sides.

That principle has a sharp edge, and it’s the highest-stakes mistake in this lesson. You already know the rule from the chapter on Cache Components: a 'use cache' function cannot read cookies(), headers(), or the session inside its body, and the outer-scope values it captures get folded into the cache key, so they must be serializable. Restated for tags: the tags a cached function emits can only be functions of its arguments.

The implication for multi-tenancy is the whole point. Org scoping has to arrive as an explicit orgId argument, never read from auth() inside the cached body. Look at what goes wrong if you forget.

export async function listInvoices() {
'use cache';
const { orgId } = await auth(); // baked into ONE shared entry
cacheLife('max');
cacheTag(invoiceTags.list(orgId));
return tenantDb(orgId).select().from(invoices);
}

Reading orgId from the session inside the cached body bakes the first org’s data into a single cross-request entry, so the next org to hit this route reads org A’s invoices. A tenant data leak that compiles fine.

Sit with the failure in the first tab, because you will see it in a code review someday. It compiles. It passes type-checking. It even works in development with one logged-in user. Then in production, the first org to hit the route computes the entry, its orgId and its invoices get baked into a single cache key that has no org in it, and every subsequent request, from every other org, reads that first org’s invoices. That’s a tenant data leak, the highest-stakes failure in the entire chapter, and it hides inside code that looks completely reasonable.

The rule to carry, and to watch for in review: tags are arguments, not ambient state. Pass orgId in.

The granularity decision: tag for the read, not the finest grain

Section titled “The granularity decision: tag for the read, not the finest grain”

One judgment call remains: at what scope should a given read tag itself? The answer is mechanical once you ask the right question, which is what change should refresh this read?

An invoice list read should tag invoiceTags.list(orgId), because any invoice in the org changing, whether created, edited, or archived, should refresh the list. An invoice detail read should tag invoiceTags.record(orgId, id), because only that invoice matters to it.

db/queries/invoices.ts
// list read — any invoice in the org should refresh it
cacheTag(invoiceTags.list(orgId));
// detail read — only this one invoice matters
cacheTag(invoiceTags.record(orgId, id));

The experienced call is to tag at the granularity the cached read needs, not at the finest granularity available. It’s tempting to tag the list with per-record tags too, on the theory that being precise means tagging every row it contains. Don’t. That forces every mutation to remember and fire every record tag the list happens to depend on. And it has a specific failure that’s easy to miss: a brand-new invoice is created, but there was no per-record tag for it yet, because it didn’t exist when the list was cached, so nothing fires for the list. The list goes stale, missing the new row, even though you were being “careful.” The org-scoped list tag has no such hole: the create fires invoiceTags.list(orgId) and the list refreshes regardless of which rows it contained.

Test that understanding before moving on.

A teammate creates a brand-new invoice in their org. The cached invoices-list read is tagged invoiceTags.list(orgId), and the create action fires that same tag. Does the list pick up the new invoice?

Yes — that tag keys off the org, not any one invoice, so a row that never existed when the entry was built still falls inside its scope.
No — the framework can only invalidate rows the cached entry already knew about, and this one wasn’t among them.
Only once the create also fires invoiceTags.record(orgId, id) for the new row, since the list rebuilds from its record tags.
No — the create has to add the new id to the list’s tag set first, then fire it, before the list can refresh.

This sets up the write side you’ll work through next: reads tag at the grain they need, and writes fire the narrowest tag that captures the change. Two halves of the same symmetry.

The fetchedAt discipline: proving the cache works

Section titled “The fetchedAt discipline: proving the cache works”

The last piece is the diagnostic you’ll lean on for every cache decision you make. Every cached read emits a timestamp captured inside the cached function, and the render surfaces it: always in development, and behind a flag in production.

db/queries/invoices.ts
export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.record(orgId, id));
const [invoice] = await tenantDb(orgId)
.select()
.from(invoices)
.where(eq(invoices.id, id));
return { ...invoice, fetchedAt: new Date().toISOString() };
}

Reading it is simple. When fetchedAt is stable across page loads, the cache is hitting, and you’re seeing the same entry each time. When fetchedAt is advancing, the entry was refreshed or invalidated since the last load. It’s the first thing to look at for any cache-related bug, because it answers the most basic question, “is this even caching?”, before you go hunting for a tag mismatch.

You might hesitate at this, because it looks like exactly the now()-in-a-cached-function trap from the not-cacheable list. It’s the opposite, and the difference is deliberate. A freshness read computes now() to defeat the cache: it changes on every read, so the entry can never be reused. fetchedAt is captured once, the moment the entry is computed, and frozen into the entry. It doesn’t change on a cache hit, and that stability is the whole signal. It tells you when this entry was built, which is exactly what you want to know.

You now have both artifacts. You’ve seen the second one, lib/tags.ts, in full. The first one is the table, and the experienced move is to write it before the cache code. Each route gets a row: its path, its class, the cached subtrees if it’s partial, its tag set, and its cacheLife profile.

Here’s that table filled out for the app you know.

| Route | Class | Cached subtrees | Tag set | cacheLife | | --- | --- | --- | --- | --- | | /dashboard | Dynamic | — | — | — | | /invoices | Dynamic | — | — | — | | /invoices/[id] | Partial | the invoice entity | invoiceTags.record(orgId, id), invoiceTags.list(orgId) | 'max' | | /pricing | Static | whole page | — | build-time | | /settings/members | Partial | the membership list | orgTags.all(orgId) | 'hours' |

This table lives next to next.config.ts, as part of the project’s architecture docs. It isn’t buried in a code comment, but kept somewhere the team reviews and agrees on. The reason to write it first is the reason any architecture doc comes first: if you classify after the fact, the wrong decisions have already calcified into the codebase, and the audit that would have caught them never happens. A route that “just happened” to get cached without a row in this table is exactly the route that leaks or goes stale.

These two artifacts, this table and lib/tags.ts, are what the next chapter implements on the live invoices list. The official docs in the next section keep the underlying mechanics one click away.

Two rules carry the weight of this lesson. First, dynamic is the default. Cache only the reads you can prove have a high read-to-write ratio and a value shared across users, and leave authenticated, per-user surfaces dynamic without apology. Second, tags are scoped arguments funneled through lib/tags.ts, never inline strings and never read from the session, so they mirror the same org boundary your data layer already enforces.

You’ve classified every route and tagged every cached read. The open question is the other side of the contract: now that reads are tagged, which of the four invalidation calls does each mutation fire, and how does the question “did this user expect to see their own change” decide it? That’s next.