nextjs-cache-architecture
mohamed-hossam1/nextjs-skills
Design and implement correct caching in Next.js 16+ App Router projects with tag registries, revalidation utilities, and Suspense boundaries.
What is nextjs-cache-architecture?
This skill guides you through architecting caching in Next.js 16+ App Router projects from the ground up. Use it when designing cache strategies, setting up the "use cache" directive, building tag registries, wiring mutations to invalidation, structuring Suspense boundaries for partial prerendering, or debugging stale data.
- Build a centralized cache tag registry to eliminate raw tag strings across the codebase
- Create revalidation utilities that wire mutations to cache invalidation functions
- Place "use cache" directives on data-fetching functions, not page components
- Structure Suspense boundaries for partial prerendering and dynamic personalized content
- Handle personalized content near cache boundaries without leaking user data
- Choose appropriate cacheLife profiles and debug stale or incorrectly fresh data
How to install nextjs-cache-architecture
npx skills add https://github.com/mohamed-hossam1/nextjs-skills --skill nextjs-cache-architecture- Next.js 16 or later with App Router enabled
- cacheComponents: true in next.config.ts
How to use nextjs-cache-architecture
- 1.Enable cacheComponents in next.config.ts
- 2.Create lib/cache/tags.ts using the assets/tags.ts template with your domain entities
- 3.Create lib/cache/revalidate.ts using the assets/revalidate.ts template with revalidation functions for each entity
- 4.Move all data fetching into dedicated functions in lib/data/ with "use cache" and cacheTag() calls
- 5.Structure page components to orchestrate Suspense boundaries; place cached fetches in child components
- 6.Wire mutations (server actions) to call revalidation functions from lib/cache/revalidate.ts
- 7.Test invalidation by triggering mutations and verifying the correct tags are updated
Use cases
- Setting up a new Next.js 16+ project with a multi-entity data model and designing the cache architecture upfront
- Migrating an existing codebase from unstable_cache to the new "use cache" directive
- Implementing surgical cache invalidation for a single entity while keeping collection caches fresh
- Structuring a page with both shared cached content and user-specific dynamic sections
- Debugging why cached data is stale or why mutations aren't invalidating the correct tags
- Next.js 16+ App Router developers building data-heavy applications
- Teams wanting to establish cache patterns before the codebase grows
- Developers migrating from unstable_cache or other caching approaches
- Full-stack engineers responsible for both data fetching and cache invalidation logic
nextjs-cache-architecture FAQ
cacheLife() sets how long data stays fresh (e.g., "hours", "days"). cacheTag() assigns a label to the cached data so you can invalidate it by tag when a mutation occurs. Use both together: cacheLife for time-based expiry, cacheTag for event-based invalidation.
Add entity tags (via a factory function like CACHE_TAGS.post(id)) only if a mutation targets a single entry and you want surgical invalidation. If mutations always invalidate the whole collection, skip the entity tag.
No. Page components should orchestrate Suspense boundaries only. Move all fetches into separate async components or lib/data/ functions with "use cache". This ensures caching and invalidation work correctly.
Fetch personalized data in a separate Suspense boundary without cacheTag(), or use a tag that includes the user ID (e.g., CACHE_TAGS.userFeed(userId)). See references/personalized-content.md for detailed patterns.
updateTag() is the low-level function that invalidates a single tag. revalidateTag() is the older API. This skill uses updateTag() wrapped in revalidation utility functions (e.g., revalidatePostCache()) so mutations never call updateTag() directly.
Full instructions (SKILL.md)
Source of truth, from mohamed-hossam1/nextjs-skills.
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. metadata: author: mohamed-hossam1 version: 2.2.0
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.
$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.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.
- Tag registry (
lib/cache/tags.ts) — every tag string lives here. No raw strings anywhere else. - Revalidation utilities (
lib/cache/revalidate.ts) — everyupdateTag()lives here. Mutations import from this file. - 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 content
Standard 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.
Related skills
More from mohamed-hossam1/nextjs-skills and the wider catalog.

cmake
Modern CMake for C/C++ projects: targets, dependencies, generators, and cross-compilation.

static-analysis
Static analysis skill for C/C++ codebases. Use when hardening code quality, triaging noisy builds, running clang-tidy, cppcheck, or scan-build, interpreting check categories, suppressing false positives, or integrating static analysis into CI. Activates on queries about clang-tidy checks, cppcheck, scan-build, compile_commands.json, code hardening, or static analysis warnings.

akshare-stock
Real-time A-stock analysis with market data, technicals, fundamentals, sectors, and cross-market coverage via akshare

mo-qa
Run and control Mo QA sessions with Momentic's qa CLI for automated browser testing.

momentic-explore-prompt
Generate repo-specific context for Momentic's explore agent to auto-generate tests from code changes.

momentic-maintain
Diagnose, classify, and repair failing Momentic tests with MCP tools and AI triage.