nextjs-cache-architecture

Use this skill whenever the user wants to design or implement caching in a Next.js 16+ App Router project — setting up the "use cache" directive, building a cache tag registry, wiring mutations to invalidation utilities, structuring Suspense boundaries for partial prerendering, handling personalized content near cache boundaries, choosing cacheLife profiles, calling cacheTag / updateTag / revalidateTag correctly, migrating from unstable_cache, or debugging stale or incorrectly fresh data. Trigger even when the user only describes their domain (e.g. "I have a posts table") and asks how to cache it properly.

mohamed-hossam1/nextjs-skills3.7k installsMITSynced Aug 25

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: nextjs-cache-architecture
description: Use this skill whenever the user wants to design or implement caching in a Next.js 16+ App Router project — setting up the "use cache" directive, building a cache tag registry, wiring mutations to invalidation utilities, structuring Suspense boundaries for partial prerendering, handling personalized content near cache boundaries, choosing cacheLife profiles, calling cacheTag / updateTag / revalidateTag correctly, migrating from unstable_cache, or debugging stale or incorrectly fresh data. Trigger even when the user only describes their domain (e.g. "I have a posts table") and asks how to cache it properly.
license: MIT
---

# 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 <View data={data} />;
}
```

## Step 5 — Structure rendering boundaries

Every page follows this shape:

```
Page component (sync, orchestration only — no data fetching)
  ├── Static shell (layout, nav — no data)
  ├── <Suspense> → cached shared content
  └── <Suspense> → 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 (
    <>
      <StaticShell />

      <Suspense fallback={<SharedSkeleton />}>
        <SharedContent />
      </Suspense>

      <Suspense fallback={<PersonalizedSkeleton />}>
        <PersonalizedSection />
      </Suspense>
    </>
  );
}

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 (
    <Suspense fallback={<EntitySkeleton />}>
      <EntityDetail params={params} />
    </Suspense>
  );
}

async function EntityDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <CachedEntityView id={id} />;
}

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<Record<string, string>>;
}) {
  return (
    <SuspenseOnSearchParams fallback={<FilteredListSkeleton />}>
      <FilteredList searchParams={searchParams} />
    </SuspenseOnSearchParams>
  );
}

async function FilteredList({
  searchParams,
}: {
  searchParams: Promise<Record<string, string>>;
}) {
  "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 `<Suspense>` 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 `<Suspense>`.       |
| Filter UI doesn't show a loading state on navigation        | Plain `<Suspense>` — 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 <project-root>` — usage and what it checks are documented
in `README.md`.

More Debugging skills

← All Debugging skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY