Multi-layer caching strategy using Next.js unstable_cache for data, ISR for pages, and on-demand revalidation for real-time updates.

Never call createClient() inside unstable_cache()

Cached functions must use createServiceClient()createClient() reads cookies, which are dynamic and break (or poison) the cache. Only cache public/shared data; never cache RLS-filtered, user-specific reads. This is the single most common caching regression when extending the cache layer.

Data Caching (unstable_cache)

Query Type TTL Cache Tags
CMS Pages 24 hours cms-page-{slug}-{locale}, cms-pages-{locale}
CMS Blocks 24 hours cms-block-{key}, cms-blocks
Blog Categories 24 hours blog-categories
Blog Tags 24 hours blog-tags
Blog Posts (paginated) 1 hour cms-blog-{locale}, blog-categories, blog-tags
Billing Plans 1 hour billing-plans, billing-plan-{id}
Roles 1 hour roles, role-{slug}
Chat Sessions Not cached (live) chat-sessions-{accountId} (revalidation tag only)

Page Caching (ISR)

Page Revalidation
Home, Contact 1 hour (revalidate = 3600)
Pricing Static (revalidate = false)
Terms, Privacy, Legal 24 hours (revalidate = 86400)
CMS Pages (/[locale]/[slug]) Dynamic (force-dynamic) — see note below
Blog Posts (/[locale]/blog/[slug]) Dynamic (force-dynamic) — see note below
Why dynamic-slug pages are force-dynamic

The middleware sets a strict per-request CSP ('nonce-…' 'strict-dynamic') and the root layout reads headers() for that nonce. Reading headers() opts rendering into dynamic mode, so a dynamic-slug ISR page rendered on demand for a post/page published after the last build would throw DYNAMIC_SERVER_USAGE. blog/[slug] and [slug] are therefore force-dynamic; their data stays cached via unstable_cache (1h / 24h). To cache the rendered HTML, use the optional CDN edge layer below.

CDN Edge Caching (optional)

Because the strict nonce CSP keeps origin rendering dynamic, the rendered HTML of public pages can be cached at an edge CDN (e.g. Cloudflare) instead of via Next ISR. Set CDN_PUBLIC_CACHE_ENABLED=true (with CDN_PUBLIC_S_MAXAGE / CDN_PUBLIC_SWR). The middleware then adds Cache-Control: public, s-maxage=…, stale-while-revalidate=… — but only on anonymous GET requests to public content pages (home, /blog, /blog/[slug], /pricing, legal, /changelog, /affiliates, /contact) that carry no Supabase auth cookie and set no cookies. Authenticated, dashboard, and API responses always stay private, no-store.

How a request flows through the CDN:

CDN edge receives the request │ ├─ Request carries a session cookie (sb-*-auth-token) ? ──▶ YES │ └─▶ BYPASS cache ──▶ origin renders ──▶ Cache-Control: private, no-store │ (personalized response — never stored at the edge) │ └─ Anonymous + public allowlisted GET + no Set-Cookie ? ──▶ YES │ ├─ cache HIT (HTML already stored at the edge) │ └─▶ served from the edge — fast, the origin is NOT hit │ └─ cache MISS (not stored yet) └─▶ origin renders once (fresh per-request CSP nonce) ──▶ returns Cache-Control: public, s-maxage, stale-while-revalidate ──▶ CDN stores the HTML (+ its CSP header) for the TTL ──▶ every later visitor takes the HIT path above

The origin still renders with the strict per-request nonce; the CDN simply reuses that one rendered copy (HTML + matching CSP header) for the TTL on these public, user-data-free pages.

Lifetime of one cached copy (defaults s-maxage=300s, swr=3600s):

[ 0s → 300s ] FRESH served instantly from the edge cache [ 300s → 3900s ] STALE served instantly, AND refreshed in the background (SWR) [ after 3900s ] EXPIRED next request waits for a fresh origin render
Required CDN configuration

Before enabling, configure your CDN to bypass the cache when a session cookie (sb-*-auth-token) is present and to never cache a response carrying Set-Cookie. The per-request CSP nonce is reused for the cache TTL on these public, user-data-free pages (the HTML nonce still matches the cached CSP header) — an acceptable trade-off only because no user data is rendered on them.

On-Demand Revalidation

Revalidation utilities in lib/cache/revalidate.ts allow invalidating specific content when it changes:

Function Invalidates
revalidateCMSPage(slug, locale) Single CMS page
revalidateCMSPages(locale) All pages for locale
revalidateAllCMS() All CMS content
revalidateBlogCategories() Blog categories cache
revalidateBlogTags() Blog tags cache
revalidateBlogPosts(locale?) Paginated blog posts (single or all locales)
revalidatePlans() Billing plans cache
revalidateChatSession(id) Single chat session and messages (tags chat-session-{id}, chat-messages-{id})
revalidateChatSessions(accountId) All chat sessions for an account (tag chat-sessions-{accountId})
revalidateRoles() All roles cache (tag roles) — call after seeding or editing the roles table
revalidateLegalPages() Iterates all locales × (terms, privacy, legal) and revalidates each cms-page-{slug}-{locale} tag
revalidateFrontendPages() All frontend pages (path-based via revalidatePath('/[locale]/(frontend)', 'layout'))
revalidateReferralStats(accountId) Referrer dashboard counters + referrals list (tags referral-stats-{accountId}, referral-list-{accountId}) — call from qualify / reverse
revalidateReferralAdminStats() Admin referrals overview aggregates (tag referral-admin-stats)
revalidateAffiliateState(accountId) Per-account affiliate state (tag affiliate-state-{accountId}) — call after application / approval / rejection / status change
revalidateAffiliateStats(affiliateId) Per-affiliate stats: clicks, attributions, conversion counters (tag affiliate-stats-{affiliateId}) — call from click / attribution / Stripe-conversion hooks
revalidateAffiliateLinks(affiliateId) Affiliate link list (tag affiliate-links-{affiliateId}) — call after creating / deactivating / rotating a link
revalidateAffiliateTiers() Affiliate tiers cache (tag affiliate-tiers) — rare, only on tier seed changes
revalidateAffiliateAdminStats() Admin affiliates overview aggregates (tag affiliate-admin-stats)
revalidateAll() Nuclear option — revalidates all CMS, blog categories/tags, billing plans, chat, roles, and the root layout. Use sparingly.
Admin Queries

Admin dashboard queries always bypass cache to show real-time data. Use *Admin() query variants for admin pages.

Marketing Auth Hydration

Marketing pages keep their anonymous HTML cacheable. The shared marketing layout must not call getUser(), cookies(), headers(), or createClient() just to show an authenticated navbar state. A user-specific layout read would make the whole marketing tree dynamic and would remove ISR/CDN reuse from pages such as home, pricing, blog, legal, and contact.

The boilerplate uses client-side auth hydration for the marketing shell:

  • app/[locale]/(frontend)/layout.tsx renders static anonymous HTML and wraps the shell in MarketingAuthProvider.
  • components/transparent-navbar.tsx, components/account-dropdown.tsx, and the marketing command palette consume the hydrated context instead of receiving server auth props.
  • GET /api/auth/me runs after hydration, calls server-side getUser(), and returns only minimal sanitized user/profile/workspace data needed by the navbar.
  • The endpoint is read-only, uses apiSecurity.public({ rateLimit: 'relaxed' }), and responds with Cache-Control: private, no-store.
Expected trade-off

Authenticated visitors can briefly see the anonymous navigation before hydration replaces it. This preserves static marketing performance while keeping authenticated dashboard links available after the first client read. If a connected user clicks the login link before hydration, middleware/proxy redirects them to their dashboard.

Request-Level Dashboard Context

Protected dashboards remain dynamic because they must read the current request cookies and enforce membership/billing guards. They should still avoid duplicated reads inside the same request tree. The shared helpers in core/accounts/dashboard-context.ts use React.cache() for per-request deduplication:

Helper Used by What it deduplicates
getPrivateDashboardContext() /private-dashboard/* layout and pages getUser(), profile, memberships, pending invitations, active account, billing access, credits, trial state, admin/org-management flags
getOrgDashboardContext() /org-dashboard/* layout and pages getUser(), profile, managed workspaces, current workspace, member count, subscription, access check, trial/Stripe state

These helpers are intentionally not wrapped in unstable_cache(). They are request-specific and may read cookies through getUser() / createClient(). Use them to avoid repeated waterfalls between a layout and its child page, while keeping redirects and access decisions in the Server Component layout/page that owns the route.

Dashboard context stays parallel

getPrivateDashboardContext() starts profile, maybeAutoPromoteAdmin(), memberships, and pending-invitation reads in one Promise.all. The auto-promotion helper also parallelizes its own profiles.is_admin and app_settings.admin_email reads, so the private dashboard does not pay a profile → settings → memberships waterfall on every request. Keep that shape when extending the protected layout.

Dashboard Aggregate RPCs

Dashboard cards and org analytics should not fetch raw usage rows into Server Components just to count, sum, or group them. The boilerplate centralizes those reads in core/accounts/dashboard-usage.ts, backed by SQL RPCs and composite indexes:

RPC Used for Why
get_account_dashboard_usage_summary Private dashboard and org overview counters Returns total requests, recent requests/tokens/cost, and chat session count without downloading ai_requests or chat_sessions rows.
get_org_dashboard_analytics /org-dashboard/analytics Pre-aggregates 30-day/previous-period KPIs, daily usage, top members, model/agent breakdowns, active users, latency, and success rate.
get_org_member_usage_summary /org-dashboard/members and member detail Returns per-member AI totals, cost, last activity, and chat-session counts in one aggregate read.

The supporting hot-path indexes are ai_requests(account_id, user_id, created_at desc), chat_sessions(account_id, user_id, created_at desc), and user_access_logs(user_id, created_at desc). Add new dashboard summaries as aggregate RPCs or head: true count queries; avoid select('*') usage-table downloads for dashboard cards.

Performance Optimizations

Beyond caching, the boilerplate applies several runtime and build-time optimizations:

Optimization Impact
Analytics afterInteractive Loading GTM, Google Ads, and Meta Pixel scripts use the afterInteractive strategy, deferring load until after hydration to avoid blocking the critical path.
Parallelized Layout Queries Dashboard context helpers use React.cache() plus Promise.all for independent user/account/subscription reads, eliminating repeated layout/page waterfall fetches within one request.
SQL Dashboard Aggregates Private dashboard counters, org analytics, and member usage summaries use aggregate RPCs instead of transferring raw ai_requests / chat_sessions rows to Server Components.
Static Marketing Shell Marketing navigation hydrates auth state through /api/auth/me after load, so public pages keep ISR/static output instead of becoming dynamic for a personalized navbar.
Batched Access Checks Permission and membership checks are batched into a single query instead of N+1 individual lookups per route.
AI Credit Consolidated Writes Credit deduction and usage logging are combined into fewer database round-trips using atomic SQL RPCs.
Real LLM Token Counting Token usage is calculated from actual LLM response metadata, not character-based approximation, ensuring accurate billing.
Client Disconnect Handling AI streaming routes listen for AbortSignal to stop processing when the client disconnects, saving compute and credits.
Selective Syntax Highlighting Only 10 common languages are loaded for code highlighting (vs. 190+ in the full bundle), reducing JS payload significantly.
Cookie-Based Onboarding Cache Middleware checks an onboarding_completed cookie before hitting the database, eliminating a DB call on every navigation for completed users.
AVIF Image Format Next.js Image component is configured with AVIF as the preferred format, delivering 20-50% smaller files than WebP.
Markdown Image Optimization CMS markdown content uses next/image for automatic resizing, format conversion, and lazy loading of inline images.