--- id: mohamed-hossam1/nextjs-skills/nextjs-cache-architecture version: "5ff047e8" license: MIT install: manual updated: 2026-05-30 --- # nextjs-cache-architecture — Structure caching from the start in Next.js 16+ App Router projects by building a tag registry, revalidation utilities, and data-fetching functions with "use cache" directives. This skill guides you through placing cache logic on data functions rather than pages, wiring mutations to invalidation, and organizing Suspense boundaries for partial prerendering. Includes templates for tags, revalidation handlers, and patterns for personalized content near cache boundaries. Publisher: mohamed-hossam1 · Stars: 8 · Updated: 2026-05-30 Install (manual): `git clone https://github.com/mohamed-hossam1/nextjs-skills` ## SKILL.md # Next.js Cache Architecture Architect caching in a Next.js 16+ App Router project from day one — not just dropping `"use cache"` where it happens to fit, but structuring the tag registry, revalidation utilities, Suspense boundaries, and mutation wiring so the cache stays correct as the codebase grows. ## How to use this skill Apply every rule and template below to the user's actual project. Replace placeholders like `[Entity]` and `[collection]` with names from their codebase before writing any code. ```text $ARGUMENTS ``` ## Where to look next Most implementations only need this file. Load a reference when the task calls for it. | If the user is... | Read | | ------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | Asking how cache keys are derived, what `cacheLife` profiles mean, or hitting a `"use cache"` limitation | `references/core-concepts.md` | | Caching anything that depends on a logged-in user | `references/personalized-content.md` | | Reporting stale data, or doing a final review pass | `references/debugging-and-checklist.md` | | Migrating an existing codebase off `unstable_cache` | `references/migration-from-unstable-cache.md` | Drop-in templates in `assets/` (rename placeholders to match the user's codebase): - `assets/tags.ts` → `lib/cache/tags.ts` - `assets/revalidate.ts` → `lib/cache/revalidate.ts` - `assets/SuspenseOnSearchParams.tsx` → `components/SuspenseOnSearchParams.tsx` ## The architecture in one breath A correct cache implementation has three load-bearing pieces. Build all three on day one — adding them later is much harder than getting them right up front. 1. **Tag registry** (`lib/cache/tags.ts`) — every tag string lives here. No raw strings anywhere else. 2. **Revalidation utilities** (`lib/cache/revalidate.ts`) — every `updateTag()` lives here. Mutations import from this file. 3. **Cache placement on data, not on pages** — `"use cache"` goes on data-fetching functions or cached child components. Page components orchestrate Suspense boundaries; the children fetch. Once those three are in place, the rest is just applying them consistently. ## Step 1 — Enable Cache Components ```ts // next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, }; export default nextConfig; ``` ## Step 2 — Build the cache tag registry **File:** `lib/cache/tags.ts` (template: `assets/tags.ts`) Use the `assets/tags.ts` template. The `as const satisfies TagRegistry` shape gives literal types and rejects malformed entries at compile time. ```ts // lib/cache/tags.ts (skeleton — full template in assets/tags.ts) export const CACHE_TAGS = { // Collection tags — one per logical data group, always present. [collection]: "[collection]", // Entity tag factories — only when a mutation targets a single entry. [entity]: (id: string | number) => `[entity]:${id}`, } as const; ``` ## Step 3 — Build revalidation utilities **File:** `lib/cache/revalidate.ts` (template: `assets/revalidate.ts`) All `updateTag()` calls live here. Mutations import these functions — they never call `updateTag()` directly. ```ts // lib/cache/revalidate.ts "use server"; import { updateTag } from "next/cache"; import { CACHE_TAGS } from "./tags"; function updateTags(tags: string[]) { for (const tag of tags) updateTag(tag); } // Bulk — any entry in the collection changed. export async function revalidate[Collection]Cache() { updateTags([CACHE_TAGS.[collection]]); } // Surgical — one specific entry changed. // Only write this if `CACHE_TAGS.[entity]` factory exists in the registry. export async function revalidate[Entity]Cache(id: string | number) { updateTags([ CACHE_TAGS.[collection], // always invalidate the parent collection too CACHE_TAGS.[entity](id), ]); } ``` ## Step 4 — Implement data fetching Place `"use cache"` in data-fetching functions. Never fetch inside page components — page components orchestrate, they do not fetch. ```ts // lib/data/[domain].ts import { cacheLife, cacheTag } from "next/cache"; import { CACHE_TAGS } from "@/lib/cache/tags"; const BASE_URL = process.env.API_BASE_URL!; // Good: collection fetch. export async function get[Collection]() { "use cache"; cacheLife("hours"); cacheTag(CACHE_TAGS.[collection]); const res = await fetch(`${BASE_URL}/[endpoint]`); return res.json(); } // Good: entity fetch. export async function get[Entity](id: string) { "use cache"; cacheLife("hours"); cacheTag(CACHE_TAGS.[collection]); // Add CACHE_TAGS.[entity](id) only if a mutation calls updateTag on this entry. const res = await fetch(`${BASE_URL}/[endpoint]/${id}`); return res.json(); } ``` ```tsx // Bad: fetching in a page component bypasses caching and invalidation. export default async function Page() { const res = await fetch("/api/items"); const data = await res.json(); return ; } ``` ## Step 5 — Structure rendering boundaries Every page follows this shape: ``` Page component (sync, orchestration only — no data fetching) ├── Static shell (layout, nav — no data) ├── → cached shared content └── → dynamic personalized content ``` ### Standard page ```tsx // app/[route]/page.tsx import { Suspense } from "react"; import { cacheLife, cacheTag } from "next/cache"; import { CACHE_TAGS } from "@/lib/cache/tags"; import { get[Collection] } from "@/lib/data/[domain]"; export default function AnyPage() { return ( <> }> }> ); } async function SharedContent() { "use cache"; cacheLife("hours"); cacheTag(CACHE_TAGS.[collection]); const data = await get[Collection](); return <[Collection]List data={data} />; } ``` ### Dynamic route page ```tsx // app/[domain]/[id]/page.tsx import { Suspense } from "react"; import { cacheLife, cacheTag } from "next/cache"; import { CACHE_TAGS } from "@/lib/cache/tags"; import { get[Entity] } from "@/lib/data/[domain]"; export default function EntityPage({ params, }: { params: Promise<{ id: string }>; }) { return ( }> ); } async function EntityDetail({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; return ; } async function CachedEntityView({ id }: { id: string }) { "use cache"; cacheLife("hours"); cacheTag(CACHE_TAGS.[collection]); // Add CACHE_TAGS.[entity](id) only if a mutation needs surgical invalidation. const item = await get[Entity](id); return <[Entity]View item={item} />; } ``` ### Filtered / search params page ```tsx // app/[route]/page.tsx import { cacheLife, cacheTag } from "next/cache"; import { CACHE_TAGS } from "@/lib/cache/tags"; import { get[Collection]ByFilter } from "@/lib/data/[domain]"; import SuspenseOnSearchParams from "@/components/SuspenseOnSearchParams"; export default function FilteredPage({ searchParams, }: { searchParams: Promise>; }) { return ( }> ); } async function FilteredList({ searchParams, }: { searchParams: Promise>; }) { "use cache"; cacheLife("minutes"); cacheTag(CACHE_TAGS.[collection]); // searchParams is an argument → auto-keyed per unique param combination. const { q = "", page = "1" } = await searchParams; return await get[Collection]ByFilter(q, page); } ``` A standard `` does not re-trigger its fallback on client-side navigation when only `searchParams` changes. Use `SuspenseOnSearchParams` (template: `assets/SuspenseOnSearchParams.tsx`) on every page with search or filter params. ## Step 6 — Handle personalized content Read `cookies()` / `headers()` / `auth()` **outside** the cache boundary and pass the value as a prop. The argument becomes part of the auto-generated cache key, so each user gets their own entry. Calling any of those APIs inside a `"use cache"` function throws or produces wrong behavior. See `references/personalized-content.md` for the full read-outside / cache-inside pattern and the rare `"use cache: private"` exception. ## Step 7 — Wire mutations to invalidation Mutations call revalidation utilities and never reach for `updateTag()` themselves. This keeps the cache layer mechanical and auditable from one file, and lets you add observability (logging, tracing) in one place. ```ts // app/actions/[domain].ts "use server"; import { revalidate[Collection]Cache, revalidate[Entity]Cache, } from "@/lib/cache/revalidate"; export async function create[Entity](payload: unknown) { await db.[entity].create(payload); await revalidate[Collection]Cache(); } export async function update[Entity](id: string | number, payload: unknown) { await db.[entity].update(id, payload); await revalidate[Entity]Cache(id); // requires the surgical utility to be exported } ``` ### `updateTag` vs `revalidateTag` Two APIs for two different needs: | API | Effect | Call from | | --------------------------- | ---------------------------------------------------------------- | ---------------------------------- | | `updateTag(tag)` | Immediate — the same request sees fresh data | Server actions, via `revalidate.ts` | | `revalidateTag(tag, "max")` | Background stale-while-revalidate — next request sees fresh data | Route handlers, webhooks | `revalidateTag` always takes a second argument (`"max"` for stale-while-revalidate, `{ expire: 0 }` for immediate hard expiry). The single-argument form is deprecated and silently does nothing in some configurations. ## Common mistakes When the cache misbehaves, walk these in order. The first six catch nearly everything; only run `next build` after the rest pass. The full debug walk and a sign-off checklist are in `references/debugging-and-checklist.md`. | Symptom or smell | Fix | | ----------------------------------------------------------- | ---------------------------------------------------------------------------- | | Function runs uncached on every request | `"use cache"` is after an `await` — move it to be the first statement. | | Cached function throws or returns wrong data per user | Move `cookies()` / `headers()` / `auth()` outside; pass values as arguments. | | `updateTag` does nothing | Tag string typo, or no `cacheTag` ever registered the matching tag. | | Mutation completes but the list still reads stale | Revalidation utility called before the write, or not called at all. | | Whole page re-renders even though only one section changed | A dynamic child sits inside a cached parent — split with ``. | | Filter UI doesn't show a loading state on navigation | Plain `` — switch to `SuspenseOnSearchParams`. | | Page marked dynamic when you expected static | Run `next build`; trace the leaked dynamic API in the route's source tree. | | Page component fetches data directly | Move the fetch into a cached child; pages should orchestrate, not fetch. | For the full debug walk and a sign-off checklist, see `references/debugging-and-checklist.md`. To verify the static parts of a finished implementation against the user's project, run `scripts/audit.mjs ` — usage and what it checks are documented in `README.md`. [View on SkillFed](https://skillfed.io/mohamed-hossam1/nextjs-skills/nextjs-cache-architecture) · [View on GitHub](https://github.com/mohamed-hossam1/nextjs-skills)