admin/docs/caching-strategy.md
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

18 KiB

Caching strategy (Ghabilee)

Audience: humans and coding agents working on قبیله frontend/backend.

Last updated: 2026-08-31

This document is the source of truth for how we cache public (guest) traffic vs authenticated (logged-in) traffic. Read it before changing SSR, fetch options, revalidate, cookies on public pages, or discovery Redis TTLs.

Related (narrower scope):


1. Two user models — two strategies

User model Phase-1 volume Goal Strategy
Guest (not logged in) Very high Fast TTFB, low server cost Shared HTML (ISR) + Data Cache + Redis
Logged in Lower Correct personalized UX Dynamic SSR/CSR + TanStack Query (data cache only)

Rule of thumb

If the response depends on session / JWT / user id → never put it in Full Route Cache (shared HTML).

If the response is the same for every guest → prefer ISR (shared HTML) with a short revalidate.

Guests browse nationwide events, city landings, event detail, SEO pages. Logged-in users use profile, bookings, chats, my-events — speed of first HTML byte is less important than correctness.


2. Cache layers (top to bottom)

Browser
  └─ TanStack Query (logged-in + client refinements)     ← per-tab, in-memory
Nginx (host edge)
  └─ proxy_cache for guest HTML document GETs            ← shared across guests (P2)
Next.js server
  └─ Full Route Cache (ISR / static HTML)                ← shared across guests
  └─ Data Cache (`fetch` + `next.revalidate`)            ← shared across all SSR
Backend (NestJS)
  └─ Redis (`PublicCacheService`)                        ← shared across all API callers
PostgreSQL
  └─ source of truth

2.1 Full Route Cache (HTML)

  • What: Pre-rendered HTML for a route segment, reused for many requests until revalidate expires.
  • Who benefits: Guests hitting high-traffic public routes.
  • What breaks it: Dynamic APIs in that segment — cookies(), headers(), searchParams (unstable), noStore(), cache: 'no-store'.
  • Our choice: Guest public pages use export const revalidate = N. Logged-in app surfaces stay dynamic.

2.2 Data Cache (Next fetch)

  • What: Cached JSON responses from fetch(url, { next: { revalidate: N } }).
  • Who benefits: Every SSR request (guest ISR rebuilds and dynamic logged-in pages).
  • Independent of per-user HTML: even when HTML is rebuilt per request, fetch results are shared across users until TTL.

Implemented in lib/seo/serverApi.tspublicGet().

2.3 TanStack Query (browser)

  • What: In-memory client cache after hydration; keys from queries/consumerKeys.ts.
  • Who benefits: Logged-in flows, tab keep-alive, soft navigation, filter changes.
  • Not a substitute for guest HTML ISR — guests often leave before hydration matters for repeat visits.

See consumer-caching.md.

2.4 Backend Redis

  • What: PublicCacheService.getOrSet() for @Public() discovery endpoints.
  • Namespaces: discovery-cities, discovery-categories, discovery-home-feed, etc.
  • TTL: Reference data = permanent until admin invalidation; home-feed = 30s.

Even if Next Data Cache misses, Redis avoids hitting Postgres on every API call.

PublicCacheService protects cache misses at two levels: an in-process single-flight map and a short Redis SET NX lease shared by all backend instances. The lease owner double-checks the cache before calling the factory; waiters consume its result, with a bounded fail-open timeout. Cache writes are conditional on the namespace version observed before the factory ran, so a slow request cannot repopulate an invalidated generation.

Permanent-entry invalidation is one atomic Redis Lua operation: it deletes the old-generation key and advances the namespace version together. Do not replace it with a separate DEL followed by INCR; that ordering can leave a concurrently rewritten, non-expiring orphan key.

2.5 Nginx edge HTML cache (P2)

Guest document GETs for ISR routes are cached on the VPS Nginx layer before Next.js. This is the fastest layer for repeat guest traffic.

Item Value
Config deploy/nginx/conf.d/ghabilee-public-html-cache.conf
Locations deploy/nginx/snippets/ghabilee-public-html-locations.conf (prod)
Install script scripts/nginx-install-public-html-cache.sh
Cache dir /var/cache/nginx/ghabilee_public_html
Diagnostic header X-Ghabilee-Cache: HIT | MISS | BYPASS | …

Cached paths (GET/HEAD only)

Path pattern Edge TTL Matches Next ISR
/ 60s 60s
/e/[slug] 60s 60s
/city/** 300s 300s
/category/** 300s 300s
/blog/** 300s 300s
/about, /contact, /become-a-host 1d 1d (86400s)
/faq, /terms, /privacy, /refund-policy, /host-guide 1d 1d (86400s)

Never edge-cached (bypass rules)

  • Non-GET/HEAD, WebSocket Upgrade
  • Next.js RSC / soft-nav (RSC header, _rsc= query, Next-Router-State-Tree, Accept: text/x-component)
  • All other routes (/profile, /api, /_next/* via catch-all) — unchanged

Logged-in users may receive cached guest HTML for these URLs; client-side auth refine is intentional (see §1).

Next.js Cache-Control: ISR guest pages still arrive from Next with private, no-store because app/layout.tsx reads CSP nonce headers. Nginx uses proxy_ignore_headers Cache-Control on these locations so edge TTL is controlled by proxy_cache_valid, then rewrites browser Cache-Control with s-maxage aligned to ISR.

CSP nonce note: Edge-cached HTML reuses the same nonce body+header pair for the cache TTL. This is an accepted trade-off for guest throughput; do not edge-cache personalized routes.

Static assets (/_next/static, /uploads/): long Cache-Control in deploy/nginx/ghabile.conf (separate from this HTML zone).

VPS rollout (after merging nginx changes):

cd /opt/ghabilee
git pull
sudo ./scripts/nginx-install-public-html-cache.sh
sudo cp deploy/nginx/ghabile.conf /etc/nginx/sites-available/ghabilee
sudo nginx -t && sudo systemctl reload nginx
curl -sSI https://ghabilee.ir/ | grep -i x-ghabilee-cache   # expect MISS then HIT

3. Route inventory

3.1 Guest — ISR (shared HTML)

Route File revalidate Notes
/ home app/(consumer)/page.tsx 60s Nationwide home-feed SSR; no cookies() on this page
/e/[slug] event landing app/(seo)/e/[slug]/page.tsx 60s Hard load + SEO; soft-nav uses client overlay
/city/[slug] app/(seo)/city/[slug]/page.tsx 300s
/category/** app/(seo)/category/** 300s
/blog/** app/(public)/blog/** 300s
/about app/(public)/about/page.tsx 86400s Static marketing copy
/contact app/(public)/contact/page.tsx 86400s Static shell; form POST is client/API
/become-a-host app/(public)/become-a-host/page.tsx 86400s Static marketing copy
/faq, /terms, … app/(public)/[infoPage]/page.tsx 86400s PUBLIC_PAGE_SLUGS in content/publicPages.tssync nginx regex (below)

Static info pages (PUBLIC_PAGE_SLUGS) — keep nginx in sync

app/(public)/[infoPage]/page.tsx only generates routes for slugs in PUBLIC_PAGE_SLUGS (frontend/content/publicPages.ts). Nginx does not read that list — the 1-day edge-cache regex is hard-coded in:

  • deploy/nginx/snippets/ghabilee-public-html-locations.conf (production)
  • deploy/nginx/snippets/ghabilee-public-html-locations.dev.conf (staging)

When you add or rename a slug in PUBLIC_PAGE_SLUGS, you must also update the alternation in both files, for example:

location ~ ^/(?:about|contact|become-a-host|faq|terms|privacy|refund-policy|host-guide|NEW-SLUG)$ {

Then on the VPS: copy snippets + nginx -t && systemctl reload nginx (see §2.5). Forgetting this step means the new page works in Next but misses edge HTML cache.

Dedicated routes /about, /contact, /become-a-host are listed explicitly in that regex — they are not driven by PUBLIC_PAGE_SLUGS. | /u/[slug] | app/(public)/** | 300s | |

3.2 Guest — dynamic by design

Surface Why dynamic
Soft-nav event overlay @modal/(...)e/[slug] Client intercept; not a full document navigation
TrafficAttributionBeacon Wrapped in <Suspense> on home; attribution POST is client-side

3.3 Logged-in — dynamic + TanStack Query

All under app/(consumer)/ except the guest-shared home ISR shell:

  • /profile, /bookings, /chats, /my-events, /identity, …
  • Host wizard app/events/** (uses cookies() for token)
  • After login, home refines feed client-side (ConsumerHomeDiscovery + useViewerDiscoveryCityId)

Do not add export const revalidate to these routes.

3.4 Root layout caveat

app/layout.tsx reads headers() for CSP nonce → opts the root layout into dynamic rendering. Child segments can still use ISR when they export revalidate and avoid cookies() / headers() in their own segment. This is why /e/[slug] ISR works today.

Agents: do not remove the root headers() call without a security review. Fix caching at the page segment that serves guests, not by weakening CSP.


4. Home page (/) — detailed contract

4.1 SSR payload (same for every guest)

Server component app/(consumer)/page.tsx fetches in parallel:

  1. fetchDiscoveryCities() — Data Cache 5 days
  2. fetchDiscoveryCategories() — Data Cache 5 days
  3. fetchDiscoveryHomeFeed()nationwide (no cityId), Data Cache 60s

Passed as props to ConsumerHomeDiscovery:

  • initialCategories, initialCities, initialHomeFeed, error props

4.2 Client refinements

ConsumerHomeDiscovery (app/(consumer)/_components/ConsumerHomeDiscovery.tsx):

Concern Guest Logged in
Popular + category feed scope Nationwide (feedCityId = undefined) Profile city when set
Feed query placeholderData from SSR nationwide feed while city feed loads Same pattern

4.3 Revalidate alignment

Layer TTL
Page ISR (export const revalidate) 60
fetchDiscoveryHomeFeed Data Cache 60
Backend Redis discovery-home-feed 30
TanStack discoveryHomeFeedStaleTime see services/discovery.ts

When debugging stale home events, check all three layers plus admin event publish.


5. Event detail — two entry paths

Entry Path Caching
Hard load / share link / SEO (seo)/e/[slug]/page.tsx ISR 60s + fetchPublicEventLandingBootstrap
Soft-nav from home shell @modal/(...)e/[slug] intercept Client fetch; overlay UI

Agents adding event UI must consider both paths or document which is in scope.


6. What agents must / must not do

Must

  • Keep guest marketing data on @Public() API routes with Redis caching.
  • Use lib/seo/serverApi.ts for server components — not @/config/axios.
  • Use export const revalidate on new guest indexable landings.
  • Gate TanStack initialData from SSR to the exact key the server fetched (see ConsumerHomeDiscovery useSsrFeed guard).
  • Invalidate backend discovery cache when admin mutates cities/categories/events (existing invalidatePermanentEntry patterns).
  • When changing PUBLIC_PAGE_SLUGS, update nginx static-marketing regex in both ghabilee-public-html-locations*.conf files (§3.1).

Must not

  • Call cookies() or headers() in guest ISR pages to read preferences (city, locale, A/B). Read them client-side instead.
  • Use cache: 'no-store' on public discovery fetch without explicit approval.
  • Assume Data Cache = HTML Cache — they are separate.
  • Add Full Route Cache to /profile, /bookings, or any JWT-scoped page.

When adding a new public page

  1. Classify: guest-shared vs session-personalized.
  2. Guest-shared → revalidate + serverApi fetch with TTL (or static copy → 86400 for marketing/legal).
  3. Session-personalized → dynamic + consumer query hooks.
  4. Update the route inventory table in this doc (section 3).
  5. If the page is a new [infoPage] slug: append the slug to the nginx static-marketing location regex in both ghabilee-public-html-locations.conf and ghabilee-public-html-locations.dev.conf (see §3.1 — PUBLIC_PAGE_SLUGS sync). Reload nginx on the VPS after deploy.

7. Implementation priorities

Priority Work Status
P0 ISR / — remove cookies() from page.tsx, revalidate = 60 Done (2026-08-31)
P1 Ensure guest event views hit ISR route on hard load (already /e/[slug]) Done
P2 Nginx edge HTML cache for guest ISR routes Config in repo — activate on VPS via nginx-install-public-html-cache.sh

8. Debugging checklist

"Guest home shows old events"

  1. Page ISR age (up to 60s)
  2. Next Data Cache for discovery/home-feed (60s)
  3. Redis discovery-home-feed (30s)
  4. Event discoverability flags in DB

"Second guest isn't faster"

  1. Confirm route is ISR (revalidate exported, no cookies() in page)
  2. Check X-Ghabilee-Cache header — HIT means Nginx edge served HTML
  3. Check server logs — Next miss should not call API if Data Cache warm
  4. Distinguish TTFB (HTML) vs client hydration

"Logged-in user sees wrong city on home"

  1. useViewerDiscoveryCityId / profile cityId
  2. consumerKeys.discoveryHomeFeed(feedCityKey) — not SSR initialData on city key
  3. placeholderData vs initialData rules in ConsumerHomeDiscovery

9. Glossary

Term Meaning
ISR Incremental Static Regeneration — shared HTML with timed revalidation
Full Route Cache Next.js cache of rendered route output (HTML + RSC payload)
Data Cache Next.js cache of fetch responses
Nationwide feed home-feed without cityId — marketing default for guests