# 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`](./consumer-caching.md) - Admin lists: [`admin-caching.md`](./admin-caching.md) - Server `fetch` helpers: [`lib/seo/serverApi.ts`](../lib/seo/serverApi.ts) - Backend Redis namespaces: [`backend/src/modules/events/discovery-reference.constants.ts`](../../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 `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.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`](./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): ```bash 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: ```nginx 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 `` 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 |