Loading...
Loading...
Next.js 16+ caching architecture using use cache, cacheLife(), cacheTag(), and updateTag(). Applies to any App Router project regardless of domain.
npx skill4agent add mohamed-hossam1/nextjs-cache-architecture nextjs-cache-architecture[Entity][collection]$ARGUMENTSLayer 1 — Static shell
Synchronous layout, nav, headers. No data fetching. Prerendered at build time.
Layer 2 — Cached shared content
Same output for all users. Uses "use cache" + collection tag + cacheLife().
Revalidated in the background. Wrapped in <Suspense>.
Layer 3 — Auto-scoped entity / filtered content
Per-item pages or search results. Auto-keyed by arguments and closures.
Entity tag added only when a mutation needs surgical invalidation.
Layer 4 — Dynamic personalized content
User-specific. Reads cookies/headers OUTSIDE the cache boundary.
Passes derived primitives as props into a nested cached component.
Wrapped in <Suspense>.
Layer 5 — Invalidation
Mutations call revalidation utilities only.
Utilities call updateTag() — collection tags for bulk, entity tags for surgical.Before adding cacheTag(CACHE_TAGS.[entity](id)):
"Will a mutation ever call updateTag() on this specific entry individually?"
YES → add entity tag factory to registry, tag the fetch, wire the revalidation utility
NO → auto-keying handles scoping; only the collection tag is needed"use cache"| 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 |
// 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");
}| 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 do not fetch |
"use cache"await// CORRECT
async function getItems() {
"use cache";
cacheLife("hours");
cacheTag(CACHE_TAGS.items);
return db.items.findMany();
}
// WRONG — directive after await
async function getItem(id: string) {
const row = await db.items.findUnique({ where: { id } });
"use cache"; // ignored — too late
return row;
}stringnumberboolean// BAD — large object serialized into cache key
async function Parent({ item }: { item: Item }) {
const fetch = async () => {
"use cache";
return getItem(item.id); // item is the whole object in closure
};
}
// GOOD — extract only the primitive needed
async function Parent({ item }: { item: Item }) {
const id = item.id;
const fetch = async () => {
"use cache";
return getItem(id); // only a string in closure
};
}// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;import { cacheLife, cacheTag, updateTag } from "next/cache";lib/cache/tags.tsdomain:idupdateTag()// lib/cache/tags.ts
export const CACHE_TAGS = {
// COLLECTION TAGS — one per logical data group, always present
[collection]: "[collection]",
[anotherCollection]: "[anotherCollection]",
// ENTITY TAG FACTORIES — only when a mutation targets a single entry via updateTag()
[entity]: (id: string | number) => `[entity]:${id}`,
} as const;// lib/cache/tags.ts
export const CACHE_TAGS = {
// Collection tags — always present
products: "products",
categories: "categories",
users: "users",
// Entity tag factories — only where surgical invalidation is needed
product: (id: string | number) => `product:${id}`,
// "category" and "user" omitted — no mutation targets a single entry individually
} as const;lib/cache/revalidate.tsupdateTag()updateTag()// lib/cache/revalidate.ts
"use server";
import { updateTag } from "next/cache";
import { CACHE_TAGS } from "./tags";
function invalidateTags(tags: string[]) {
for (const tag of tags) updateTag(tag);
}
// Bulk — any entry in the collection changed
export async function revalidate[Collection]Cache() {
invalidateTags([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) {
invalidateTags([
CACHE_TAGS.[collection], // always invalidate the parent collection too
CACHE_TAGS.[entity](id),
]);
}// lib/cache/revalidate.ts
"use server";
import { updateTag } from "next/cache";
import { CACHE_TAGS } from "./tags";
function invalidateTags(tags: string[]) {
for (const tag of tags) updateTag(tag);
}
export async function revalidateProductsCache() {
invalidateTags([CACHE_TAGS.products]);
}
export async function revalidateProductCache(id: string | number) {
invalidateTags([CACHE_TAGS.products, CACHE_TAGS.product(id)]);
}
export async function revalidateCategoriesCache() {
invalidateTags([CACHE_TAGS.categories]);
}"use cache"// lib/data/[domain].ts
export async function get[Collection]() {
"use cache";
cacheLife("hours");
cacheTag(CACHE_TAGS.[collection]);
const res = await fetch(`${BASE_URL}/[endpoint]`);
return res.json();
}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 specific entry
const res = await fetch(`${BASE_URL}/[endpoint]/${id}`);
return res.json();
}// WRONG — fetching in 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} />;
}Page component (sync, orchestration only — no data fetching)
├── Static shell (layout, nav — no data)
├── <Suspense> → Cached shared content
└── <Suspense> → Dynamic personalized content// app/[route]/page.tsx
import { Suspense } from "react";
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} />;
}// app/[domain]/[id]/page.tsx
import { Suspense } from "react";
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} />;
}// app/[route]/page.tsx
import { Suspense } from "react";
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);
}<Suspense>searchParams// components/SuspenseOnSearchParams.tsx
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
export default function SuspenseOnSearchParams({
fallback,
children,
}: {
fallback: React.ReactNode;
children: React.ReactNode;
}) {
const searchParams = useSearchParams();
return (
<Suspense key={searchParams.toString()} fallback={fallback}>
{children}
</Suspense>
);
}cookies()headers()auth()"use cache"// CORRECT
// 1. Read request-time data outside the cache boundary
async function PersonalizedSection() {
const cookieStore = await cookies();
const userId = cookieStore.get("userId")?.value;
// 2. Pass as prop — auto-included in cache key
return <CachedPersonalizedView userId={userId} />;
}
// 3. Cache the stable rendering
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>;
}// WRONG — dynamic API inside cached function
async function CachedView() {
"use cache";
const cookieStore = await cookies(); // throws or produces incorrect behavior
return <div />;
}"use cache: private"async function getData() {
"use cache: private";
const session = (await cookies()).get("session")?.value; // allowed
return fetchData(session);
}updateTag()// 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);
}
export async function delete[Entity](id: string | number) {
await db.[entity].delete(id);
await revalidate[Entity]Cache(id);
}// WRONG — updateTag scattered in business logic, raw strings
export async function update[Entity](id: string, payload: unknown) {
await db.[entity].update(id, payload);
updateTag("[collection]");
updateTag(`[entity]:${id}`);
}| Profile | Use when |
|---|---|
| Near-real-time data (live feeds, counters) |
| Frequently updated content (dashboards, notifications) |
| Moderately stable content (listings, articles, configs) |
| Rarely updated content (reference data, documentation) |
| Effectively permanent (build-time constants, static assets) |
cacheLife({
stale: 3600, // serve stale for up to 1 hour
revalidate: 7200, // background revalidation every 2 hours
expire: 86400, // hard expiration at 1 day
});output: "export"Math.random()Date.now()"use cache"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>;
}"use cache"awaitcookiesheadersauthcacheTag()lib/cache/tags.tsnext buildnext.config.tscacheComponents: truelib/cache/tags.tsupdateTag()lib/cache/revalidate.tsupdateTag()"use cache"cacheTag()cacheLife()"use cache"cookiesheadersauthSuspenseOnSearchParamsupdateTag()next buildlib/cache/tags.tsupdateTag()lib/cache/revalidate.ts"use cache""use cache"awaitcacheTag()cacheLife()cookies()headers()SuspenseOnSearchParamsnext buildlib/cache/tags.tsupdateTag()"use cache"page.tsx"use cache"await