Caching
Learn how Litestore tags cached reads from CACHE_TAGS, how the CRUD layer invalidates them, and which paths are never cached.
Litestore caches storefront and admin reads with Next.js tags drawn from one closed vocabulary, and invalidates them from the CRUD layer on every write.
The vocabulary being closed is the whole design. A cache tag is a string shared between a read and a write that never see each other, and a mismatched string produces no error at all — just stale data. So the strings are constants, and the build fails on anything else.
This page covers that tag system, the five-minute feed rotation window that keeps the home feed cacheable, and the paths that are deliberately never cached.
Tag-based invalidation
CACHE_TAGS in lib/cache.ts is the whole vocabulary.
It holds static tags:
products
orders
settings
facetsAnd functions that build entity-scoped tags:
CACHE_TAGS.product(id)
CACHE_TAGS.productBySlug(slug)
CACHE_TAGS.variantStock(id)Cached reads declare the tags they depend on, and writes fire the same constants, so the two sides cannot drift.
const resolveSoleChannelCached = unstable_cache(
async (defaultCurrency: string): Promise<ChannelInfo | null> => {
const channels = await db.channel.findMany({
where: { isActive: true, deletedAt: null },
select: channelCandidateSelect,
take: 2,
})
if (channels.length !== 1) return null
const only = channels[0]
return toChannelInfo({ ...only, currency: only.currency || defaultCurrency })
},
["sole-active-channel"],
{ revalidate: 300, tags: [CACHE_TAGS.channels] },
)Never invalidate with a raw tag string
scripts/check-cache-tags.ts fails the build on a string or template literal
passed to revalidateTag/invalidateTag/invalidateTags, and on a BaseCRUD
subclass declaring protected cacheTag = "...". A typo'd tag — "admin-refunds"
where the read used "refunds" — never throws; the page serves stale data
with no error.
Mutations invalidate through the CRUD layer
BaseCRUD.getRelatedCacheTags maps each entity to the tags a write touches.
Because the base calls it on every mutation, a mutation cannot forget a surface. The module names its tags once rather than at each write site.
Domain events take the second route
invalidateCachesForDomainEvent in lib/cache.ts is one static map from event type to invalidation, executed by functions/domain-events.ts.
Some events have no cache dependents at all:
cart.merged
checkout.started
social.*
wishlist.*Those invalidate nothing, and the map says so explicitly. An event listed with an empty invalidation is a decision; an event missing from the map is an oversight, and writing them the same way would hide the difference.
Invalidation is best-effort by design
safeRevalidateTag swallows and logs failures rather than throwing into the caller.
The data write has already happened by that point. A missed flush means a stale cache; a thrown invalidation during render means a 500. The first is recoverable and the second is not.
Time-ISR pages need the path too
Some storefront pages are time-ISR rather than purely tag-driven.
So invalidateProducts, invalidateCollections and invalidateCategories also call revalidatePath on the entity's URL. Otherwise an edit would wait out the ISR window before showing.
Why every write invalidates
A mutation that writes without invalidating is silent, intermittent, and easy to miss in review. The admin table shows stale data until the cache TTL expires, and by then nobody connects the two.
scripts/check-cache-invalidation.ts scans every server-side file that runs a direct Prisma mutation and requires it to import either from ~/lib/cache, or revalidateTag/revalidatePath from next/cache.
Files that legitimately bypass the rule are named in an explicit ALLOWLIST in that script, each with the reason it is exempt:
BaseCRUDsubclasses- Activity and analytics writes
- Seeds
- Tests
The allowlist is explicit and each entry carries its reason, so an exemption has to be argued for rather than inherited by accident.
The five-minute window
FEED_ROTATION_WINDOW_MS in server/web/feed/index.ts is five minutes.
currentFeedSeed() derives the rotation bucket from the clock, rather than from per-request randomness. It cycles all FEED_SEED_BUCKETS orderings over roughly eighty minutes.
Everyone loading the page inside one window sees the same order, which is what keeps exactly one cache variant hot.
The alternative was tried. A per-request random seed fragmented getFeedCached sixteen ways:
per-request random seed
↓
16 cache variants
↓
most storefront views miss
↓
full uncached feed build, per requestThe rotation still happens; it is the clock that decides when, not the visitor.
The seed is generated by the caller and threaded through pagination, so page 2 rotates the same way as page 1 — even across a window boundary.
What is never cached
- Anything behind authentication in the admin
- The storefront cart, which reads the cart cookie in
server/web/cart/queries.tsand is therefore dynamic per request - Stock at the moment of reservation —
lib/inventory.tstakes aSELECT … FOR UPDATErow lock inside the transaction
Stock is the interesting one, because it appears on both sides of the line.
Stock shown on a product page is cached under CACHE_TAGS.variantStock(id). Stock checked when the order is placed is not.
That split is deliberate — the first is a hint, the second is a decision.
Deciding how to cache a new read
Cache it with a tag from CACHE_TAGS when the value is derived from stored data and a write can name the surfaces it affects. That is the ordinary case, and it is the one the CRUD layer already handles.
Add a revalidate window on top when the read is expensive and slightly stale is acceptable, as resolveSoleChannelCached does at 300 seconds.
Add revalidatePath as well when the page is time-ISR, or the edit will sit behind the ISR window.
Leave it uncached when the read is per-request by nature — the cart cookie, anything behind admin auth — or when the value is being used to commit to something. Correctness at the moment of a write is not a caching problem to solve.
And when adding a variant to a cached read, count the variants first. Cheap-looking per-request inputs are how one hot cache entry becomes sixteen cold ones.
The question to ask of any cached read is not "how long can this be stale?" It is:
Is this value a hint, or a decision?
Contracts
Learn what the shared Zod schemas in contracts/ validate — Email, Phone, Url, Slug, DateRange, PaginationInput — and how domain schemas compose them.
Overview
Learn which of Litestore's three transports to use — zsa server actions for admin and storefront writes, oRPC at /api/rpc for service-key consumers, and the REST endpoints described by /api/openapi.json.