
Nextjs Cache Architecture
- 3.3k installs
- 8 repo stars
- Updated May 30, 2026
- mohamed-hossam1/nextjs-cache-architecture
nextjs-cache-architecture is an agent skill that structures Next.js 16+ use cache tag registries, revalidation utilities, and Suspense-aware data fetching for correct invalidation.
About
nextjs-cache-architecture is an agent skill for designing correct caching in Next.js 16+ App Router projects from day one rather than sprinkling use cache ad hoc. It prescribes three load-bearing pieces: a centralized tag registry in lib/cache/tags.ts, revalidation utilities in lib/cache/revalidate.ts where every updateTag call lives, and cache placement on data-fetching functions instead of page components that only orchestrate Suspense. Stepwise guidance enables cacheComponents in next.config.ts, implements collection and entity tag factories with as const satisfies TagRegistry typing, wires mutations to revalidate helpers, and applies cacheLife plus cacheTag on getCollection and getEntity fetchers. Reference files cover core concepts, personalized content near cache boundaries, debugging checklists, and migration from unstable_cache, with drop-in assets for tags.ts, revalidate.ts, and SuspenseOnSearchParams.tsx. The skill triggers when users design cache tags, call cacheTag or updateTag, structure partial prerendering, or debug stale data after mutations. Developers reach for it when scaffolding SaaS apps that need deterministic invalidation as entity counts grow.
- Three-piece architecture: tag registry, revalidation utilities, and data-layer use cache placement.
- Enables cacheComponents in next.config.ts before implementing tags and fetch helpers.
- assets/ templates provide tags.ts, revalidate.ts, and SuspenseOnSearchParams.tsx starters.
- References cover personalized content, debugging checklists, and unstable_cache migration.
- Mutations import revalidate helpers instead of calling updateTag with raw tag strings.
Nextjs Cache Architecture by the numbers
- 3,265 all-time installs (skills.sh)
- +110 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #159 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
nextjs-cache-architecture capabilities & compatibility
- Capabilities
- centralized cache tag registry design · mutation driven revalidation utilities · suspense boundary placement guidance · personalized content cache boundary patterns
- Works with
- vercel
- Use cases
- frontend · api development · ci cd
What nextjs-cache-architecture says it does
every tag string lives here. No raw strings anywhere else.
cacheComponents: true
npx skills add https://github.com/mohamed-hossam1/nextjs-cache-architecture --skill nextjs-cache-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.3k |
|---|---|
| repo stars | ★ 8 |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 30, 2026 |
| Repository | mohamed-hossam1/nextjs-cache-architecture ↗ |
How do I design Next.js App Router caching so mutations invalidate the right tags without stale data or scattered raw tag strings?
Architect Next.js 16+ App Router caching with tag registries, revalidation utilities, Suspense boundaries, and correct use cache placement on data functions.
Who is it for?
Developers building Next.js 16+ SaaS apps who need centralized cache tags and mutation-driven revalidation from the start.
Skip if: Skip for Pages Router-only projects, client-only SPAs, or tasks unrelated to Next.js server caching.
When should I use this skill?
User sets up use cache, builds cache tag registries, wires updateTag after mutations, or debugs stale App Router data.
What you get
lib/cache/tags.ts registry, lib/cache/revalidate.ts helpers, and data functions with use cache, cacheLife, and cacheTag wired to mutations.
- Cache tag registry
- Revalidation utility module
- Cached data fetch functions
Files
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.
$ARGUMENTSWhere 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.tsassets/revalidate.ts→lib/cache/revalidate.tsassets/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
// 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.
// 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.
// 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.
// 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();
}// 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 contentStandard page
// 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
// 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
// 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.
// 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.
// lib/cache/revalidate.ts
//
// Every `updateTag()` call in the project lives here.
// Mutations import these functions — they never call `updateTag()` directly.
//
// Why centralize:
// - One place to audit invalidation behavior.
// - One place to change the strategy (e.g., add observability).
// - Mutations stay focused on data changes, not cache mechanics.
"use server";
import { updateTag } from "next/cache";
import { CACHE_TAGS } from "./tags";
function updateTags(tags: string[]) {
// Optional: add observability here. Because every invalidation flows
// through this one function, a single console.log / OpenTelemetry span
// gives full coverage with no per-call-site instrumentation.
// Example:
// console.log("[cache] updateTag", { tags, at: new Date().toISOString() });
for (const tag of tags) updateTag(tag);
}
// Bulk — any entry in the collection changed.
// Replace [Collection] / [collection] with the user's real names.
export async function revalidate[Collection]Cache() {
updateTags([CACHE_TAGS.[collection]]);
}
// Surgical — one specific entry changed.
// Only export this if `CACHE_TAGS.[entity]` factory exists in tags.ts.
// Always invalidate the parent collection too — list views read from it.
export async function revalidate[Entity]Cache(id: string | number) {
updateTags([
CACHE_TAGS.[collection],
CACHE_TAGS.[entity](id),
]);
}
// components/SuspenseOnSearchParams.tsx
//
// A standard <Suspense> does NOT re-trigger its fallback on client-side
// navigation when only `searchParams` changes — the boundary is keyed on
// the route, not the query string. This wrapper re-keys the boundary on
// every searchParams change so the fallback shows during the new fetch.
//
// Use this on every page that has search or filter params.
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense, type ReactNode } from "react";
type Props = {
fallback: ReactNode;
children: ReactNode;
};
export default function SuspenseOnSearchParams({ fallback, children }: Props) {
const searchParams = useSearchParams();
return (
<Suspense key={searchParams.toString()} fallback={fallback}>
{children}
</Suspense>
);
}
// lib/cache/tags.ts
//
// Single source of truth for every cache tag in the project.
// Raw tag strings are never written anywhere else.
//
// Conventions:
// - Lowercase only.
// - Entity tags use `domain:id` format.
// - Match the actual data model — do not invent names.
// - Add an entity factory ONLY when a mutation needs `updateTag()`
// on a single entry. Otherwise the collection tag is enough.
//
// Type safety:
// `as const satisfies TagRegistry` keeps every value literal-typed
// (so the compiler knows `CACHE_TAGS.posts` is exactly `"posts"`)
// AND enforces that every entry is either a tag string or a factory
// that returns one. Misuse — e.g. dropping in a number, an object,
// or a function with the wrong signature — fails to compile.
type Tag = string;
type EntityTagFactory = (id: string | number) => Tag;
type TagRegistry = Record<string, Tag | EntityTagFactory>;
export const CACHE_TAGS = {
// COLLECTION TAGS — one per logical data group, always present.
// Replace [collection] with the user's real collection name.
[collection]: "[collection]",
[anotherCollection]: "[anotherCollection]",
// ENTITY TAG FACTORIES — only when a mutation targets a single entry.
// Replace [entity] with the user's real entity name.
[entity]: ((id) => `[entity]:${id}`) satisfies EntityTagFactory,
} as const satisfies TagRegistry;
{
"skill_name": "nextjs-cache-architecture",
"evals": [
{
"id": 1,
"name": "greenfield-posts-with-comments",
"prompt": "I'm building a blog in Next.js 16 (App Router). I have a `posts` table and a `comments` table — every post has many comments. I need a posts listing page at /posts, a post detail page at /posts/[slug] showing the post and its comments, and a dashboard that lets me create/update/delete posts and approve/delete comments. Set up caching for the whole thing so updates from the dashboard show up immediately on the public pages, but don't refetch on every request. Show me the full file layout.",
"expected_output": "Should produce: lib/cache/tags.ts with `posts` and `comments` collection tags (and an entity factory for at least one of them since a single-post update is needed); lib/cache/revalidate.ts with revalidatePostsCache/revalidatePostCache/revalidateCommentsCache; cached fetchers in lib/data/; page components that don't fetch directly; mutations in app/actions/ that delegate to the revalidation utilities. No raw updateTag/revalidateTag outside revalidate.ts. cacheComponents:true in next.config.ts.",
"files": []
},
{
"id": 2,
"name": "stale-data-debugging",
"prompt": "My dashboard creates a new product but the /products listing page still shows the old list until I hard-refresh. Here's my server action:\n\n```ts\n'use server'\nimport { revalidateTag } from 'next/cache'\n\nexport async function createProduct(data: FormData) {\n await db.products.create({ name: data.get('name') })\n revalidateTag('products')\n}\n```\n\nAnd here's how I cache the listing:\n\n```ts\nexport async function getProducts() {\n const res = await fetch(`${BASE_URL}/products`)\n 'use cache'\n cacheTag('products')\n return res.json()\n}\n```\n\nWhat's wrong?",
"expected_output": "Should identify two bugs: (1) `\"use cache\"` is after the `await fetch` so it's silently ignored — it must be the first statement; (2) `revalidateTag('products')` uses the deprecated single-argument form. Should rewrite both with the centralization architecture (tags.ts + revalidate.ts + updateTag for immediate invalidation), not just patch the inline code.",
"files": []
},
{
"id": 3,
"name": "migrate-from-unstable-cache",
"prompt": "I'm upgrading to Next.js 16. Here's a representative chunk of my data layer using unstable_cache — please convert it to the new directive-based API and centralize the tags properly.\n\n```ts\nimport { unstable_cache } from 'next/cache'\n\nexport const getArticles = unstable_cache(\n async () => {\n const res = await fetch(`${BASE_URL}/articles`)\n return res.json()\n },\n ['articles'],\n { tags: ['articles'], revalidate: 3600 }\n)\n\nexport const getArticleBySlug = unstable_cache(\n async (slug: string) => {\n const res = await fetch(`${BASE_URL}/articles/${slug}`)\n return res.json()\n },\n ['article-by-slug'],\n { tags: ['articles'], revalidate: 3600 }\n)\n```",
"expected_output": "Should produce: a lib/cache/tags.ts with an `articles` collection tag (and an entity factory only if a mutation is shown that needs surgical invalidation — none is shown here, so collection-tag-only is correct); rewritten getArticles and getArticleBySlug with the directive at the top of the body, cacheLife('hours'), and cacheTag(CACHE_TAGS.articles); the manual key array dropped (auto-keying covers it); deprecated unstable_cache imports removed.",
"files": []
},
{
"id": 4,
"name": "search-and-filter-page",
"prompt": "I have a /jobs page that filters listings by `?q=` (text search), `?location=`, and `?page=` (pagination). The data comes from a `jobs` table. Set up caching so the same query+location+page combo is cached, but new combos fetch fresh — and make sure the loading skeleton actually shows up when users change the filters via client-side navigation.",
"expected_output": "Should produce: a fetcher that takes q/location/page as arguments (auto-keyed per combo) with cacheLife('minutes') and cacheTag(CACHE_TAGS.jobs); a page component that wraps the cached child in `SuspenseOnSearchParams` (not a plain `<Suspense>`); the SuspenseOnSearchParams component itself if not already in the project; explicit reasoning that plain Suspense doesn't re-trigger fallback on searchParams-only changes.",
"files": []
},
{
"id": 5,
"name": "personalized-content-cookies",
"prompt": "On my home page I want a public 'Featured products' section (cached, same for everyone) and a personalized 'Your recent orders' section that reads the userId from a cookie. How do I structure this without breaking the cache for the public part?",
"expected_output": "Should produce a page that returns a static shell + two Suspense boundaries; the public boundary contains a cached component reading from getFeaturedProducts(); the personalized boundary contains an outer async component that reads cookies()/userId, then passes userId as a prop to a cached inner component (auto-keyed per user). Should explicitly NOT call cookies() inside a `\"use cache\"` function, and should NOT recommend `\"use cache: private\"` as the default — that's the exception, not the rule.",
"files": []
}
]
}
name: nextjs-cache-architecture
description: Next.js 16 Cache Components guidance — PPR, use cache directive, cacheLife, cacheTag, updateTag, and migration from unstable_cache. Use when implementing partial prerendering, caching strategies, or migrating from older Next.js cache patterns.
metadata:
priority: 6
docs:
- "https://nextjs.org/docs/app/getting-started/cache-components"
- "https://nextjs.org/docs/app/api-reference/directives/use-cache"
pathPatterns:
- 'next.config.*'
- 'app/**'
- 'src/app/**'
- 'apps/*/app/**'
- 'apps/*/src/app/**'
importPatterns:
- "next/cache"
bashPatterns:
- '\bnext\s+(dev|build)\b'
promptSignals:
phrases:
- "use cache"
- "cache components"
- "partial prerendering"
- "PPR"
- "cacheLife"
- "cacheTag"
- "updateTag"
- "unstable_cache"
allOf:
- [cache, component]
- [cache, directive]
- [partial, prerender]
anyOf:
- "revalidateTag"
- "stale"
- "revalidate"
- "cache profile"
noneOf: []
minScore: 6
validate:
-
pattern: 'unstable_cache\s*\('
message: 'unstable_cache is deprecated in Next.js 16 — use the "use cache" directive with cacheTag() and cacheLife() instead'
severity: recommended
upgradeToSkill: nextjs-cache-architecture
upgradeWhy: 'Guides migration from unstable_cache to use cache directive with cacheTag and cacheLife.'
-
pattern: '\bcacheHandler\s*:'
message: 'Singular cacheHandler is deprecated in Next.js 16 — use cacheHandlers (plural) with per-type handlers'
severity: recommended
-
pattern: revalidateTag\(\s*['"][^'"]+['"]\s*\)
message: 'Single-arg revalidateTag(tag) is deprecated in Next.js 16 — pass a cacheLife profile: revalidateTag(tag, "max")'
severity: recommended
retrieval:
aliases:
- cache components
- partial prerendering
- PPR
- use cache
intents:
- enable partial prerendering in Next.js
- cache async data with use cache directive
- invalidate cache with cacheTag
- migrate from unstable_cache
entities:
- use cache
- cacheLife
- cacheTag
- updateTag
- revalidateTag
- PPR
chainTo:
-
pattern: 'use cache'
targetSkill: nextjs
message: 'Cache component detected — loading Next.js best practices for RSC boundaries and data patterns alongside caching.'
skipIfFileContains: 'next-best-practices'
Core Concepts
Background reading for the nextjs-cache-architecture skill. Load this when the agent needs to explain why the architecture is shaped the way it is, or when the user asks how cache keys are derived, what cacheLife profiles mean, or what the hard limits of "use cache" are.
Contents
Auto cache key generation
Next.js generates a unique cache key for every "use cache" function automatically. You never construct cache keys manually.
| Component | What it includes |
|---|---|
| Build ID | Changes on every deploy — all caches invalidated automatically |
| Function ID | Hash of the function's file path and position in source |
| Arguments | Every value passed to the function at call time |
| Closure variables | Every outer-scope value captured by the function |
// Good: every unique resourceId produces a separate cache entry — automatically.
async function Parent({ resourceId }: { resourceId: string }) {
const fetchData = async (filter: string) => {
"use cache";
// key = [buildId] + [fn hash] + resourceId (closure) + filter (argument)
return fetch(`/api/resources/${resourceId}?filter=${filter}`);
};
return fetchData("active");
}Tags are for invalidation. Auto-keying handles scoping. These are different concerns and should not be conflated.
"use cache" placement rules
| Placement | When to use |
|---|---|
| Top of an async function body | Single data-fetching function |
| Top of an async Server Component body | Entire component output is cacheable |
| Never in a page component | Page components orchestrate; they don't fetch |
"use cache" must be the first statement in the function body — before any await. If it appears after an await, Next.js silently ignores it and the function runs uncached on every request.
Cache duration reference
| Profile | Use when |
|---|---|
"seconds" | Near-real-time data (live feeds, counters) |
"minutes" | Frequently updated content (dashboards, notifications) |
"hours" | Moderately stable content (listings, articles, configs) |
"days" | Rarely updated content (reference data, documentation) |
"max" | Effectively permanent (build-time constants, static assets) |
For fine-grained control, pass an object instead of a profile name:
import { cacheLife } from "next/cache";
cacheLife({
stale: 3600, // serve stale for up to 1 hour
revalidate: 7200, // background revalidation every 2 hours
expire: 86400, // hard expiration at 1 day
});Limitations
- Edge runtime is not supported —
"use cache"requires Node.js. - Static export (
output: "export") is not supported. Math.random()andDate.now()inside"use cache"execute once at build
time, not per request. They will appear "stuck" on a single value.
cacheTag()accepts max 128 tags per call and max 256 characters per tag.
Tags exceeding these limits are silently dropped with a console warning.
For request-time non-determinism inside cached output, defer execution with connection():
import { connection } from "next/server";
async function DynamicContent() {
await connection(); // defers execution to request time
const id = crypto.randomUUID(); // different per request
return <div>{id}</div>;
}Debugging and Post-Implementation Checklist
Background reading for the nextjs-cache-architecture skill. Load this when cache behavior is wrong — stale data, no invalidation, unexpected freshness — or when reviewing an implementation before sign-off.
Contents
Debugging order
When cache behavior is wrong, walk these checks in order. The first six catch the vast majority of issues; only run next build after the rest pass.
1. Is "use cache" the first statement in the function body, before any await? If it appears after an await, Next.js ignores it. 2. Is a dynamic API (cookies, headers, auth) called inside a cached function? Move the read outside and pass the value as an argument. 3. Does the collection tag in cacheTag() exactly match the tag string in the registry? A typo silently creates a separate, never-invalidated tag. 4. If surgical invalidation is needed, does the entity tag factory exist in lib/cache/tags.ts? Calling updateTag with a string that no cacheTag ever registered is a no-op. 5. Is the revalidation utility actually called after the mutation completes? Calling it before the write means the cache repopulates with stale data. 6. Are Suspense boundaries correctly isolating dynamic from cached sections? A dynamic child inside a cached parent forces the parent to re-render too. 7. Run next build and inspect the static vs dynamic route output. Routes marked dynamic that you expected to be static usually point at a leaked request-time API.
Post-implementation checklist
Use this as a final review pass. Every item below should be checked off before treating the cache architecture as production-ready.
| Check | Status |
|---|---|
next.config.ts has cacheComponents: true | Yes/No |
lib/cache/tags.ts has a collection tag for every data domain | Yes/No |
Entity tag factories exist only where mutations require surgical updateTag() | Yes/No |
lib/cache/revalidate.ts contains all updateTag() calls — nowhere else | Yes/No |
Every "use cache" function has both cacheTag() and cacheLife() | Yes/No |
"use cache" is the first statement in every function that uses it | Yes/No |
No dynamic request APIs (cookies, headers, auth) inside cached functions | Yes/No |
| Closure variables captured by cached functions are primitives — not whole objects or class instances | Yes/No |
| Page components are synchronous and do not fetch data | Yes/No |
SuspenseOnSearchParams used on every page with search or filter params | Yes/No |
Mutations call revalidation utilities — never updateTag() directly | Yes/No |
revalidateTag() called with two arguments — never the deprecated single-argument form | Yes/No |
next build confirms expected static/dynamic rendering boundaries | Yes/No |
Migrating from unstable_cache
Background reading for the nextjs-cache-architecture skill. Load this when the user has existing code calling unstable_cache (Next.js 13/14/15) and wants to move to the "use cache" directive on Next.js 16.
Why migrate
unstable_cache is deprecated in Next.js 16. The new directive-based API fixes three architectural issues that made unstable_cache painful to use correctly:
- Manual cache keys were error-prone — closure variables silently leaked
into output. The new directive auto-keys on arguments and closures.
- String tags scattered across files had no single source of truth.
The architecture in SKILL.md centralizes them in lib/cache/tags.ts.
- `revalidate` as a number on every call site mixed lifecycle policy
with the fetch logic. cacheLife() separates the two.
The four mechanical rewrites
1. Function wrap → directive
// Bad: unstable_cache wrapper, manual key array.
import { unstable_cache } from "next/cache";
export const getPosts = unstable_cache(
async () => {
const res = await fetch(`${BASE_URL}/posts`);
return res.json();
},
["posts"], // cache key — repeated everywhere
{ tags: ["posts"], revalidate: 3600 },
);// Good: directive at the top of the function body.
import { cacheLife, cacheTag } from "next/cache";
import { CACHE_TAGS } from "@/lib/cache/tags";
export async function getPosts() {
"use cache";
cacheLife("hours");
cacheTag(CACHE_TAGS.posts);
const res = await fetch(`${BASE_URL}/posts`);
return res.json();
}2. Manual key array → auto-keying
unstable_cache required a manual key array that had to include every variable the function depended on. The new directive auto-derives the key from arguments and closure variables, so the manual array goes away.
// Bad: easy to forget a variable, silently caches wrong values per user.
export const getUserPosts = unstable_cache(
async (userId: string) => fetchUserPosts(userId),
["user-posts"], // userId NOT in the key — every user shares cache
{ tags: ["posts"] },
);// Good: userId is an argument, auto-included in the key.
export async function getUserPosts(userId: string) {
"use cache";
cacheLife("minutes");
cacheTag(CACHE_TAGS.posts);
return fetchUserPosts(userId);
}3. revalidate: number → cacheLife()
Old unstable_cache option | New equivalent |
|---|---|
{ revalidate: 60 } | cacheLife("minutes") |
{ revalidate: 3600 } | cacheLife("hours") |
{ revalidate: 86400 } | cacheLife("days") |
{ revalidate: false } | cacheLife("max") |
| Custom number | cacheLife({ revalidate, expire }) |
See references/core-concepts.md for the full profile table.
4. tags: [...] option → cacheTag()
// Bad: string literals scattered in option objects, no central registry.
unstable_cache(fn, key, { tags: ["posts", `post:${id}`] });// Good: centralized tags, registry-derived.
"use cache";
cacheLife("hours");
cacheTag(CACHE_TAGS.posts, CACHE_TAGS.post(id));Common gotchas during migration
- Closures that worked before may now fail —
"use cache"cannot capture
non-serializable values (class instances, functions, request-time symbols). If your unstable_cache body referenced db.client from an outer scope, pass the data the function needs as an explicit argument instead.
- `cookies()` / `headers()` inside the function body throws. Move the
read above the call site and pass the value as an argument. See references/personalized-content.md for the full pattern.
- **
Math.random()andDate.now()inside the function body run once, at
build time.** If unstable_cache happened to mask this (because each cache miss re-ran it), the directive will surface the bug. Defer with await connection() if request-time non-determinism is required.
- `revalidatePath()` callers do not need to change, but consider whether
revalidateTag() (with the second-argument form) is a better fit now that tags are centralized.
Order of operations
1. Add cacheComponents: true to next.config.ts. 2. Create lib/cache/tags.ts with collection tags for every domain that currently appears in unstable_cache calls. 3. Create lib/cache/revalidate.ts and move every revalidateTag() call into it (the centralization step from SKILL.md). 4. Migrate one fetcher at a time. Run next build after each — it surfaces broken closures and dynamic-API leaks immediately. 5. Delete the now-unused unstable_cache imports. 6. Run scripts/audit.mjs (if shipped with this skill) to confirm no raw updateTag( / revalidateTag( calls remain outside revalidate.ts.
Personalized Content Near Cache Boundaries
Background reading for the nextjs-cache-architecture skill. Load this when the user is dealing with per-user data, cookies, headers, or auth — the most error-prone area of "use cache", because dynamic request APIs and cached functions interact in ways that look subtle but fail loudly.
The rule
Never call cookies(), headers(), or auth() inside a "use cache" function. Read them outside the cache boundary and pass the derived primitives as props. The arguments become part of the auto-generated cache key, so each user gets their own cache entry without any manual keying.
Pattern: read outside, cache inside
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { cacheLife } from "next/cache";
// Good: 1. Read request-time data outside the cache boundary.
async function PersonalizedSection() {
const cookieStore = await cookies();
const userId = cookieStore.get("userId")?.value;
// Good: 2. Pass as a prop — auto-included in the cache key.
return <CachedPersonalizedView userId={userId} />;
}
// Good: 3. Cache the stable rendering, keyed per user automatically.
async function CachedPersonalizedView({
userId,
}: {
userId: string | undefined;
}) {
"use cache";
cacheLife("minutes");
// userId is an argument → auto-keyed per user
const data = await getPersonalizedData(userId);
return <div>{/* render */}</div>;
}Anti-pattern
// Bad: dynamic API inside cached function — throws or produces wrong behavior.
import { cookies } from "next/headers";
async function CachedView() {
"use cache";
const cookieStore = await cookies();
return <div />;
}Exception: "use cache: private"
Use only when compliance requirements prevent refactoring to the props pattern (for example, the request-time secret cannot leave a specific function for audit reasons). The private variant scopes the cache to the current user session and allows dynamic APIs inside.
import { cookies } from "next/headers";
async function getData() {
"use cache: private";
const session = (await cookies()).get("session")?.value; // allowed inside private
return fetchData(session);
}Prefer the props pattern when both are viable — it shares cache entries across identical inputs, while private always keeps a per-session copy.
#!/usr/bin/env node
/**
* scripts/audit.mjs
*
* Static audit for a Next.js project that follows the
* nextjs-cache-architecture skill. Runs the checks from the
* post-implementation checklist that can be verified without executing
* the app.
*
* Usage:
* node scripts/audit.mjs <project-root>
* node scripts/audit.mjs .
*
* Exit codes:
* 0 — all checks passed
* 1 — at least one check failed
* 2 — bad invocation / project not found
*
* Zero runtime dependencies. Node 18+.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const SCRIPT_NAME = path.basename(__filename);
const projectRoot = path.resolve(process.argv[2] ?? ".");
try {
await fs.access(projectRoot);
} catch {
console.error(`${SCRIPT_NAME}: project root not found: ${projectRoot}`);
process.exit(2);
}
// Files we expect to exist by convention.
const TAGS_FILE = "lib/cache/tags.ts";
const REVALIDATE_FILE = "lib/cache/revalidate.ts";
// Directories we scan for source files.
const SCAN_DIRS = ["app", "src/app", "lib", "src/lib", "components", "src/components"];
const SOURCE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts"]);
// Ignore directories that should not be scanned.
const IGNORE_DIRS = new Set([
"node_modules", ".next", ".turbo", "dist", "build", "out", ".git", "coverage",
]);
/** Walk `dir` recursively, yielding absolute paths to source files. */
async function* walk(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith(".") && entry.name !== ".") continue;
if (IGNORE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (SOURCE_EXTS.has(path.extname(entry.name))) {
yield full;
}
}
}
const findings = [];
function fail(check, detail) {
findings.push({ ok: false, check, detail });
}
function pass(check) {
findings.push({ ok: true, check });
}
// Check 1 — next.config.* declares cacheComponents: true.
async function checkNextConfig() {
const candidates = ["next.config.ts", "next.config.mjs", "next.config.js"];
for (const name of candidates) {
const file = path.join(projectRoot, name);
try {
const text = await fs.readFile(file, "utf8");
if (/cacheComponents\s*:\s*true/.test(text)) {
pass(`${name} declares cacheComponents: true`);
return;
}
fail(`${name}`, "found but does not set cacheComponents: true");
return;
} catch {
// try next candidate
}
}
fail("next.config.{ts,mjs,js}", "no Next.js config file found");
}
// Check 2 — tag registry and revalidation utility files exist.
async function checkSkeletonFiles() {
for (const rel of [TAGS_FILE, REVALIDATE_FILE]) {
const file = path.join(projectRoot, rel);
try {
await fs.access(file);
pass(`${rel} exists`);
} catch {
fail(rel, "expected file is missing");
}
}
}
// Check 3 — `updateTag(` only appears inside lib/cache/revalidate.ts.
async function checkUpdateTagCentralization() {
const offenders = [];
for await (const file of (async function* () {
for (const dir of SCAN_DIRS) {
yield* walk(path.join(projectRoot, dir));
}
})()) {
if (file.endsWith(REVALIDATE_FILE.replace(/\//g, path.sep))) continue;
const text = await fs.readFile(file, "utf8");
if (/\bupdateTag\s*\(/.test(text)) {
offenders.push(path.relative(projectRoot, file));
}
}
if (offenders.length === 0) {
pass("updateTag() centralized in lib/cache/revalidate.ts");
} else {
fail(
"updateTag() centralization",
`raw updateTag() calls found outside lib/cache/revalidate.ts:\n ${offenders.join("\n ")}`,
);
}
}
// Check 4 — no deprecated single-arg revalidateTag(...) calls.
async function checkRevalidateTagSecondArg() {
const offenders = [];
for await (const file of (async function* () {
for (const dir of SCAN_DIRS) {
yield* walk(path.join(projectRoot, dir));
}
})()) {
const text = await fs.readFile(file, "utf8");
// Match revalidateTag("...") or revalidateTag('...') with no comma after the literal.
const re = /\brevalidateTag\(\s*(['"])[^'"]+\1\s*\)/g;
if (re.test(text)) {
offenders.push(path.relative(projectRoot, file));
}
}
if (offenders.length === 0) {
pass("revalidateTag() always called with a second argument");
} else {
fail(
"revalidateTag() second-arg form",
`deprecated single-arg revalidateTag() calls found:\n ${offenders.join("\n ")}`,
);
}
}
// Check 5 — files declaring "use cache" do not also call cookies()/headers()/auth().
// Heuristic: a file that contains `"use cache"` AND also imports/calls
// any of the dynamic APIs is suspicious. False positives are possible
// when the dynamic API is used outside the cached function in the same
// file — review manually.
async function checkDynamicApisInsideCache() {
const suspicious = [];
for await (const file of (async function* () {
for (const dir of SCAN_DIRS) {
yield* walk(path.join(projectRoot, dir));
}
})()) {
const text = await fs.readFile(file, "utf8");
if (!/["']use cache(?::\s*private)?["']/.test(text)) continue;
if (/["']use cache:\s*private["']/.test(text)) continue; // private allows dynamic APIs
if (/\b(cookies|headers|auth)\s*\(/.test(text)) {
suspicious.push(path.relative(projectRoot, file));
}
}
if (suspicious.length === 0) {
pass("no dynamic-API calls in files declaring \"use cache\"");
} else {
fail(
"dynamic APIs near \"use cache\"",
`files declaring "use cache" that also call cookies()/headers()/auth() (review manually — false positives possible):\n ${suspicious.join("\n ")}`,
);
}
}
// Run all checks.
await checkNextConfig();
await checkSkeletonFiles();
await checkUpdateTagCentralization();
await checkRevalidateTagSecondArg();
await checkDynamicApisInsideCache();
// Report.
const passed = findings.filter((f) => f.ok).length;
const failed = findings.length - passed;
console.log(`\nnextjs-cache-architecture audit — ${path.relative(process.cwd(), projectRoot) || "."}\n`);
for (const f of findings) {
const mark = f.ok ? "PASS" : "FAIL";
console.log(` [${mark}] ${f.check}`);
if (!f.ok && f.detail) console.log(` ${f.detail}`);
}
console.log(`\n${passed} passed, ${failed} failed.\n`);
process.exit(failed === 0 ? 0 : 1);
Related skills
Forks & variants (1)
Nextjs Cache Architecture has 1 known copy in the catalog totaling 2.2k installs. They canonicalize to this original listing.
- mohamed-hossam1 - 2.2k installs
How it compares
Pick nextjs-cache-architecture over ad-hoc updateTag calls when invalidation logic needs a single audit point and shared observability hooks.
FAQ
What are the three required cache architecture pieces?
A tag registry in lib/cache/tags.ts, revalidation utilities in lib/cache/revalidate.ts, and use cache on data functions not page components.
Where should updateTag be called?
Only inside lib/cache/revalidate.ts helpers that mutations import, never with raw tag strings scattered in route handlers.
Is Nextjs Cache Architecture safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.