# ADR 022: Caching Strategy (Next.js 16 App Router)

**Status**: Accepted
**Date**: 2026-05-22
**Deciders**: Architecture Lead, Platform PO
**Closes**: KAR-527 (KAR-522 Phase 4)

---

## Context

Next.js 16 App Router exposes four interacting cache layers, each with its own opt-in mechanics:

1. **Request Memoization** — same `fetch()` within one render is de-duped automatically. Per-request, in-memory.
2. **Data Cache** — `fetch()` results persist across requests when the call opts in (`{ cache: 'force-cache' }` or `{ next: { revalidate } }`). Per-deploy, server-side.
3. **Full Route Cache** — the rendered RSC output for a route segment, persisted at build for static routes and at request time for dynamic ones.
4. **Router Cache** — short-lived client-side cache of RSC payloads navigated to.

The defaults changed materially in Next.js 15+: `fetch()` is **no longer cached by default**, and route segments are no longer statically cached unless the data inside them is. This shifts the burden onto callers — caches must be **opted into**, not opted out.

Today the repo behaves as follows:

- **8 routes** opt out of all caching with `export const dynamic = 'force-dynamic'`, including the root `app/layout.tsx`. This makes the entire app dynamic by default — there is no Full Route Cache hit anywhere.
- **49 call-sites** invalidate via `revalidatePath()` after mutations. Patterns are consistent: a Server Action mutates Supabase, then revalidates the route(s) the change is visible on.
- **No `revalidateTag()` usage.** All invalidation is path-based.
- **No explicit `fetch()` cache opts-in** — data is fetched through the Supabase client, which sits outside the Next.js fetch cache anyway.
- **No CDN-level cache headers** beyond `Cache-Control` on static assets (Next.js default).

That gives a coherent (if conservative) baseline. What it lacks is a written rule for the *next* developer: which cache layer to lean on for which data, and how to invalidate without over-fetching.

## Decision

Caching is decided per data-class, not per route. Five classes cover the app:

| Data class | Examples | Cache strategy |
|---|---|---|
| **Per-user dynamic** | dashboards, assessments, evaluations, audit trail | No cache — `force-dynamic` on the segment, fresh from Supabase on every request |
| **Master / reference data** | suppliers, customers, project types, BMW master data | `unstable_cache()` or `fetch(..., { next: { revalidate: 3600 } })` — 1 h TTL, on-demand `revalidateTag()` on mutation |
| **Tenant config** | branding, feature flags, locale, theme | `unstable_cache()` with `revalidateTag('tenant:<id>')`. Reload only when the tenant config changes. |
| **Static / marketing** | landing pages, public help docs (if any) | Full Route Cache — no `force-dynamic`, no fetch opt-out. Re-built on deploy. |
| **External API** (BMW APIs, third party) | rate-limited or expensive calls | Always cached with explicit TTL; tag with `vendor:<name>` for targeted invalidation. |

### Per-class rules

**Per-user dynamic data**

- The current default. Keep `force-dynamic` on routes that gate on `session` or query Supabase with row-level security in scope.
- Mutations follow Server Action → DB write → `revalidatePath()` on the affected segments. The existing 49 call-sites stay.
- Do **not** cache responses that depend on RLS-evaluated rows. The cache key cannot encode "which user" safely.

**Master / reference data**

- Wrap with `unstable_cache(fn, [key], { tags: ['master:suppliers'], revalidate: 3600 })`.
- Mutation paths (admin only) call `revalidateTag('master:<class>')` after the write.
- Migrate the existing read sites that re-fetch master data on every render — that is the visible win.

**Tenant config**

- Branding lookup (`lib/branding/engine.ts`) is the canonical example. Wrap with `revalidateTag('tenant:<tenantId>:branding')`.
- Onboarding / settings UIs invalidate the tag on save.
- Avoid `revalidatePath()` here — multiple routes consume the same config; tag-based invalidation is one line per writer instead of N.

**Static / marketing**

- Default Next.js behavior. No special config. Rebuild on deploy.
- If the project later grows public marketing pages, they go in a `(marketing)` route group and inherit cacheable defaults.

**External API**

- Every BMW-API or third-party call gets an explicit `revalidate` (10–60 min depending on the endpoint).
- Tag with the vendor so a manual purge is one line: `revalidateTag('vendor:bmw-master')`.
- Document the TTL choice in the call-site comment — quick context for future debugging.

### Invalidation rules

1. **`revalidatePath()` for one-segment, one-writer changes.** The current pattern (`revalidatePath('/intake/board')` after a board-action) stays.
2. **`revalidateTag()` for cross-route changes.** When the same data shows on three routes, tag-based invalidation keeps the writer single-line.
3. **No `router.refresh()` as a "make it fresh" fallback.** It bypasses the Router Cache but does not invalidate the Data Cache or Full Route Cache — silent staleness elsewhere.
4. **No `unstable_noStore()` sprinkled into components to "fix" a cache issue.** Fix the cache key, not the symptom.

### Router Cache (client-side)

Default Next.js values are kept (30 s for dynamic segments, 5 min for static). Override per-link only when there is a concrete reason — e.g. `prefetch={false}` on a list link whose targets are always uncached.

## Migration

Forward-looking, no forced sweep.

- The existing 8 `force-dynamic` routes stay.
- The existing 49 `revalidatePath()` call-sites stay.
- New features pick the right class up front; old features migrate when the surrounding code is touched (golden-path rule per ADR 019).

The first concrete migration is the master-data reads — they currently round-trip Supabase on every render and are the cheapest win when wrapped with `unstable_cache()`. That work lands in its own PR, not this ADR.

## Consequences

### Accepted

- A second mental model (cache classes) on top of Next.js' four cache layers. Pays for itself once the next developer asks "where do I cache this?".
- Tag-based invalidation requires writers to know all consumers' tags. Mitigated by keeping tags grouped by data class, not by route.

### Forbidden

- Caching per-user data without encoding the user in the cache key. The reviewer flags this as an RLS bypass.
- Mixing `revalidatePath()` and `revalidateTag()` for the same data class in the same feature. Pick one per class.
- Disabling the Router Cache globally via `staleTimes: 0`. That defeats Next.js' navigation perf model.

## References

- KAR-522 Architecture Audit · Source 9 (Next.js App Router)
- KAR-522 Architecture Audit · Source 1 (12-Factor IV — Backing services as attached resources)
- KAR-522 Architecture Audit · Source 20 (REST — Cacheability constraint)
- ADR 016 (Logging and observability) — cache-hit/miss should be observable
- ADR 019 (New module golden path) — wires this into the module scaffold
- KAR-532 (@vercel/otel + Supabase spans) — companion change for cache-layer visibility
