nextjs-cache-architecture
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNext.js Cache Architecture
Next.js 缓存架构
Architect caching in a Next.js 16+ App Router project from day one — not just
dropping 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.
"use cache"从项目初期就为Next.js 16+ App Router项目设计缓存架构——不只是在合适的地方添加,而是构建标签注册表、重验证工具、Suspense边界和变更关联逻辑,确保随着代码库的增长,缓存始终保持正确。
"use cache"How to use this skill
如何使用此技能
Apply every rule and template below to the user's actual project. Replace
placeholders like and with names from their codebase
before writing any code.
[Entity][collection]text
$ARGUMENTS将以下所有规则和模板应用到用户的实际项目中。在编写任何代码之前,将和等占位符替换为用户代码库中的名称。
[Entity][collection]text
$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 | |
| Caching anything that depends on a logged-in user | |
| Reporting stale data, or doing a final review pass | |
Migrating an existing codebase off | |
Drop-in templates in (rename placeholders to match the user's
codebase):
assets/- →
assets/tags.tslib/cache/tags.ts - →
assets/revalidate.tslib/cache/revalidate.ts - →
assets/SuspenseOnSearchParams.tsxcomponents/SuspenseOnSearchParams.tsx
大多数实现只需要此文件。当任务需要时,加载对应的参考文档。
| 用户场景 | 参考文档 |
|---|---|
询问缓存键如何生成、 | |
| 缓存依赖于已登录用户的内容 | |
| 报告陈旧数据,或进行最终审核 | |
将现有代码库从 | |
assets/- →
assets/tags.tslib/cache/tags.ts - →
assets/revalidate.tslib/cache/revalidate.ts - →
assets/SuspenseOnSearchParams.tsxcomponents/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 () — every tag string lives here. No raw strings anywhere else.
lib/cache/tags.ts - Revalidation utilities () — every
lib/cache/revalidate.tslives here. Mutations import from this file.updateTag() - Cache placement on data, not on pages — goes on data-fetching functions or cached child components. Page components orchestrate Suspense boundaries; the children fetch.
"use cache"
Once those three are in place, the rest is just applying them consistently.
一个正确的缓存实现包含三个核心部分。从项目第一天就构建这三个部分——事后添加比一开始就做好要困难得多。
- 标签注册表()——所有标签字符串都存放在这里。其他任何地方都不使用原始字符串。
lib/cache/tags.ts - 重验证工具()——所有
lib/cache/revalidate.ts调用都在这里。变更操作从此文件导入这些工具。updateTag() - 在数据层而非页面层设置缓存——应放在数据获取函数或缓存子组件中。页面组件负责编排Suspense边界;子组件负责数据获取。
"use cache"
一旦这三个部分就位,剩下的就是持续应用它们。
Step 1 — Enable Cache Components
步骤1 — 启用缓存组件
ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;Step 2 — Build the cache tag registry
步骤2 — 构建缓存标签注册表
File: (template: )
lib/cache/tags.tsassets/tags.tsUse the template. The shape
gives literal types and rejects malformed entries at compile time.
assets/tags.tsas const satisfies TagRegistryts
// 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;文件: (模板:)
lib/cache/tags.tsassets/tags.ts使用模板。的结构提供字面量类型,并在编译时拒绝格式错误的条目。
assets/tags.tsas const satisfies TagRegistryts
// lib/cache/tags.ts(框架——完整模板在assets/tags.ts中)
export const CACHE_TAGS = {
// 集合标签——每个逻辑数据组对应一个,始终存在。
[collection]: "[collection]",
// 实体标签工厂——仅当变更操作针对单个条目时使用。
[entity]: (id: string | number) => `[entity]:${id}`,
} as const;Step 3 — Build revalidation utilities
步骤3 — 构建重验证工具
File: (template: )
lib/cache/revalidate.tsassets/revalidate.tsAll calls live here. Mutations import these functions — they
never call directly.
updateTag()updateTag()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),
]);
}文件: (模板:)
lib/cache/revalidate.tsassets/revalidate.ts所有调用都在这里。变更操作导入这些函数——它们从不直接调用。
updateTag()updateTag()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);
}
// 批量重验证——集合中的任意条目发生变更。
export async function revalidate[Collection]Cache() {
updateTags([CACHE_TAGS.[collection]]);
}
// 精准重验证——单个特定条目发生变更。
// 仅当注册表中存在`CACHE_TAGS.[entity]`工厂时才编写此函数。
export async function revalidate[Entity]Cache(id: string | number) {
updateTags([
CACHE_TAGS.[collection], // 始终同时失效父集合
CACHE_TAGS.[entity](id),
]);
}Step 4 — Implement data fetching
步骤4 — 实现数据获取
Place in data-fetching functions. Never fetch inside page
components — page components orchestrate, they do not fetch.
"use cache"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} />;
}将放在数据获取函数中。永远不要在页面组件中获取数据——页面组件负责编排,不负责数据获取。
"use cache"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!;
// 正确示例:集合数据获取。
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]);
// 仅当变更操作会对此条目调用updateTag时,才添加CACHE_TAGS.[entity](id)。
const res = await fetch(`${BASE_URL}/[endpoint]/${id}`);
return res.json();
}tsx
// 错误示例:在页面组件中获取数据会绕过缓存和失效机制。
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
步骤5 — 构建渲染边界
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每个页面都遵循以下结构:
页面组件(同步,仅负责编排——不进行数据获取)
├── 静态外壳(布局、导航——无数据)
├── <Suspense> → 缓存的共享内容
└── <Suspense> → 动态个性化内容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} />;
}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} />;
}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]);
// 仅当变更操作需要精准失效时,才添加CACHE_TAGS.[entity](id)。
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 does not re-trigger its fallback on client-side
navigation when only changes. Use
(template: ) on every page with search or
filter params.
<Suspense>searchParamsSuspenseOnSearchParamsassets/SuspenseOnSearchParams.tsxtsx
// 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作为参数→每个唯一参数组合都会自动生成对应的缓存键。
const { q = "", page = "1" } = await searchParams;
return await get[Collection]ByFilter(q, page);
}标准的在仅变化时的客户端导航中不会重新触发其加载状态。在所有带有搜索或筛选参数的页面上使用(模板:)。
<Suspense>searchParamsSuspenseOnSearchParamsassets/SuspenseOnSearchParams.tsxStep 6 — Handle personalized content
步骤6 — 处理个性化内容
Read / / 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 function throws or produces wrong behavior.
cookies()headers()auth()"use cache"See for the full read-outside / cache-inside
pattern and the rare exception.
references/personalized-content.md"use cache: private"在缓存边界外部读取//,并将值作为props传递。该参数会成为自动生成的缓存键的一部分,因此每个用户都会有自己的缓存条目。在函数内部调用这些API会抛出错误或导致错误行为。
cookies()headers()auth()"use cache"查看获取完整的“外部读取/内部缓存”模式以及罕见的例外情况。
references/personalized-content.md"use cache: private"Step 7 — Wire mutations to invalidation
步骤7 — 将变更操作关联到缓存失效
Mutations call revalidation utilities and never reach for
themselves. This keeps the cache layer mechanical and auditable from one
file, and lets you add observability (logging, tracing) in one place.
updateTag()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()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); // 需要导出精准重验证工具
}updateTag
vs revalidateTag
updateTagrevalidateTagupdateTag
vs revalidateTag
updateTagrevalidateTagTwo APIs for two different needs:
| API | Effect | Call from |
|---|---|---|
| Immediate — the same request sees fresh data | Server actions, via |
| Background stale-while-revalidate — next request sees fresh data | Route handlers, webhooks |
revalidateTag"max"{ expire: 0 }两个API对应两种不同的需求:
| API | 效果 | 调用来源 |
|---|---|---|
| 立即生效——同一请求会获取最新数据 | 服务器操作,通过 |
| 后台 stale-while-revalidate 模式——下一次请求会获取最新数据 | 路由处理器、Webhooks |
revalidateTag"max"{ expire: 0 }Common mistakes
常见错误
When the cache misbehaves, walk these in order. The first six catch nearly
everything; only run after the rest pass. The full debug walk
and a sign-off checklist are in .
next buildreferences/debugging-and-checklist.md| Symptom or smell | Fix |
|---|---|
| Function runs uncached on every request | |
| Cached function throws or returns wrong data per user | Move |
| Tag string typo, or no |
| 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 |
| Page marked dynamic when you expected static | Run |
| 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
. To verify the static parts of a
finished implementation against the user's project, run
— usage and what it checks are documented
in .
references/debugging-and-checklist.mdscripts/audit.mjs <project-root>README.md当缓存行为异常时,按以下顺序排查。前六条几乎能解决所有问题;在其余检查通过后再运行。完整的调试步骤和验收清单在中。
next buildreferences/debugging-and-checklist.md| 症状或问题迹象 | 修复方案 |
|---|---|
| 函数每次请求都无缓存运行 | |
| 缓存函数抛出错误或为不同用户返回错误数据 | 将 |
| 标签字符串拼写错误,或从未调用 |
| 变更操作完成但列表仍显示陈旧数据 | 重验证工具在写入操作之前调用,或根本未调用。 |
| 仅一个部分变更但整个页面重新渲染 | 动态子组件位于缓存父组件内部——用 |
| 筛选UI在导航时不显示加载状态 | 使用了普通 |
| 页面被标记为动态,但预期是静态 | 运行 |
| 页面组件直接获取数据 | 将数据获取移到缓存子组件中;页面应负责编排,而非数据获取。 |
完整的调试步骤和验收清单请查看。要验证已完成实现中的静态部分是否符合用户项目要求,运行——其用法和检查内容在中有说明。",
references/debugging-and-checklist.mdscripts/audit.mjs <project-root>README.md