Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
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):
- Client-side TanStack Query:
consumer-caching.md - Admin lists:
admin-caching.md - Server
fetchhelpers:lib/seo/serverApi.ts - Backend Redis namespaces:
backend/src/modules/events/discovery-reference.constants.ts
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
revalidateexpires. - 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.ts → publicGet().
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 (
RSCheader,_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.ts — sync 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/**(usescookies()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:
fetchDiscoveryCities()— Data Cache 5 daysfetchDiscoveryCategories()— Data Cache 5 daysfetchDiscoveryHomeFeed()— nationwide (nocityId), 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.tsfor server components — not@/config/axios. - Use
export const revalidateon new guest indexable landings. - Gate TanStack
initialDatafrom SSR to the exact key the server fetched (seeConsumerHomeDiscoveryuseSsrFeedguard). - Invalidate backend discovery cache when admin mutates cities/categories/events (existing
invalidatePermanentEntrypatterns). - When changing
PUBLIC_PAGE_SLUGS, update nginx static-marketing regex in bothghabilee-public-html-locations*.conffiles (§3.1).
Must not
- Call
cookies()orheaders()in guest ISR pages to read preferences (city, locale, A/B). Read them client-side instead. - Use
cache: 'no-store'on public discoveryfetchwithout 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
- Classify: guest-shared vs session-personalized.
- Guest-shared →
revalidate+serverApifetch with TTL (or static copy →86400for marketing/legal). - Session-personalized → dynamic + consumer query hooks.
- Update the route inventory table in this doc (section 3).
- If the page is a new
[infoPage]slug: append the slug to the nginx static-marketinglocationregex in bothghabilee-public-html-locations.confandghabilee-public-html-locations.dev.conf(see §3.1 —PUBLIC_PAGE_SLUGSsync). 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"
- Page ISR age (up to 60s)
- Next Data Cache for
discovery/home-feed(60s) - Redis
discovery-home-feed(30s) - Event discoverability flags in DB
"Second guest isn't faster"
- Confirm route is ISR (
revalidateexported, nocookies()in page) - Check
X-Ghabilee-Cacheheader —HITmeans Nginx edge served HTML - Check server logs — Next miss should not call API if Data Cache warm
- Distinguish TTFB (HTML) vs client hydration
"Logged-in user sees wrong city on home"
useViewerDiscoveryCityId/ profilecityIdconsumerKeys.discoveryHomeFeed(feedCityKey)— not SSRinitialDataon city keyplaceholderDatavsinitialDatarules inConsumerHomeDiscovery
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 |