Catalog Caching
How the public storefront is cached between seller edits, so traffic spikes don't hammer Postgres and the IP rate limit doesn't lock out real shoppers.
1. Overview
Public catalog pages (/catalog/[slug], /catalog/[slug]/p/[productSlug], /catalog/[slug]/cart, /catalog/[slug]/checkout) are the only routes that anonymous traffic hits. The backend rate-limits /api/public/catalog/* to 60 req/min per IP, which is fine for browsers but fragile for bots, crawlers, and viral moments. To protect both Postgres and that rate limit, catalog reads are served from Next.js Data Cache (tagged fetch) on the frontend. There is no backend cache — every cache MISS goes straight to Prisma. Invalidation is push-based: product/store/catalog writes in the backend fire revalidateCatalog(slug), which calls a signed route on the frontend that runs revalidateTag("catalog-<slug>").
2. Architecture
The only cache layer that matters is the Next.js fetch cache keyed by URL + tag. The backend itself is stateless for public reads (the in-memory LRUCache in backend/lib/eziseller-parser/cache/catalog-cache.ts is for the WhatsApp/IG AI parser's tenant catalog index — not the public storefront).
3. Data model
Caching is not persisted — no DB tables involved. The tag catalog-<slug> is derived from Store.slug. See schema.prisma for Store, Product, CatalogSettings.
4. Key flows
4.1 Cache MISS (cold read)
4.2 Cache HIT + seller edit + invalidation
5. Lifecycle
There is no TTL on the tagged fetch — entries live until invalidated or the Next.js server restarts/redeploys.
6. Key files
Frontend (cache consumers — all tag reads with catalog-<slug>):
- src/app/catalog/[slug]/page.tsx:L9-L16 — storefront root,
fetchCatalogData - src/app/catalog/[slug]/p/[productSlug]/page.tsx:L11-L53 — product detail page
- src/app/catalog/[slug]/cart/page.tsx:L11-L14 — cart page
- src/app/catalog/[slug]/checkout/page.tsx:L11-L14 — checkout page
Frontend (invalidation endpoint):
- src/app/api/revalidate/route.ts:L1-L40 — validates
x-revalidation-secret, enforces^catalog-[a-z0-9-]+$tag pattern, callsrevalidateTag
Backend (cache miss handler):
- backend/routes/public-catalog.ts:L38-L148 —
GET /:slugstorefront read (rate-limited 60/min/IP) - backend/routes/public-catalog.ts:L14-L19 —
catalogLimiter
Backend (invalidation producer):
- backend/lib/revalidate-frontend.ts:L14-L67 —
revalidateCatalog({slug|userId}), fire-and-forget with 5s AbortSignal timeout - backend/routes/products.ts:L477, L692, L820, L935, L1053, L1319, L1440, L1548, L1676 — every product mutation calls it
- backend/routes/store.ts:L127, L132, L247, L327 — store + checkout settings changes
- backend/routes/catalog.ts:L68, L109 — catalog settings (theme, publish toggle)
Client cart state (separate, not cached server-side):
- src/contexts/CartContext.tsx — cart lives in
localStorage, scoped per slug; never touches the cache layer
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
REVALIDATION_SECRET | yes (prod) | Shared HMAC-style token between Express and Next.js /api/revalidate | If unset on BE: edits never invalidate (stale catalog). If mismatched: frontend returns 401, still stale. Logged as warning, never throws. |
FRONTEND_URL | yes (prod) | Where the backend POSTs the revalidation request | If unset: revalidateCatalog logs a warning and no-ops |
NEXT_PUBLIC_API_URL | yes | Where the Next.js server fetches catalog data from | Page returns notFound() on failure |
There is no TTL / revalidate / cache option passed to fetch() — the implicit Next.js behaviour is full caching until tag invalidation.
8. Gotchas & troubleshooting
- Seller edits don't show up on the storefront → Cause:
REVALIDATION_SECRETorFRONTEND_URLmissing on the backend, or mismatched across services. Fix: check backend logs for[revalidateCatalog] FRONTEND_URL or REVALIDATION_SECRET not setorHTTP 401. Both services must share the exact same secret. - Revalidation is fire-and-forget on purpose → revalidate-frontend.ts:L14-L19. The function returns
void, notPromise<void>, so callers cannotawaitit. A slow or down frontend must never fail a product save. Cost: if the POST fails, the cache stays stale until the next successful write or deploy. - No backend cache for public reads → Every MISS hits Postgres with 5–6 Prisma queries (store, settings, products+variants, categories). The
LRUCacheinbackend/lib/eziseller-parser/cache/catalog-cache.tslooks relevant but is only used by the WhatsApp/Instagram AI message parser pipeline, not the HTTP route. - Tag regex is strict → revalidate/route.ts:L24 only accepts
^catalog-[a-z0-9-]+$. Slugs with uppercase or underscores will silently fail to invalidate (400 response, logged as error in backend). - Store settings include JSON blobs →
orderSettings,paymentSettings,shippingSettingsare read at cache-miss time (public-catalog.ts:L58-L66) and returned incheckoutConfig. Changes to these must callrevalidateCatalog(store.ts does; check new settings routes too). - Rate limit is on the BE, not cache → A cache HIT bypasses Express entirely, so the 60 req/min/IP limit only applies to MISSes. Don't rely on it for hot catalogs.
- No stale-while-revalidate → When a tag is busted, the next request is a full MISS (not SWR). First shopper after an edit pays the DB round-trip.
9. Extension points
- Add a new write path that changes storefront output → must call
revalidateCatalog({slug})orrevalidateCatalog({userId})after the DB write. Pattern: see bulk product update at products.ts:L1319. - Add a new public storefront page → fetch with
{ next: { tags: [\catalog-${slug}`] } }` so it participates in the same invalidation. - Add a backend in-memory cache (e.g. for the DB query itself) → would sit inside
GET /:slugin public-catalog.ts; needs an explicit invalidation hook wired into the same places that callrevalidateCatalog. Keep LRU size small — one entry per store slug. - Migrate to Redis → replace the in-memory
Setfor refresh tokens first (auth.ts:68), then consider a shared cache layer. Today nothing in the request path uses Redis.
10. Related docs
- catalog-overview.md — what the public storefront actually renders
- cart.md — client-side cart state (localStorage, not cached on the server)
- checkout.md — the write path that reads
checkoutConfigout of the same cached payload - ../products/products-and-variants.md — every product write fires
revalidateCatalog