关于 nextjs-cache-architecture
为 Next.js 16+ App Router 项目规划缓存架构的技能,涵盖标签注册、重新验证、数据获取和渲染边界,并提供模板与排查指南。
主要功能
- 01集中管理缓存标签
- 02统一重新验证工具
- 03缓存数据获取函数
- 04Suspense 边界模式
- 05模板与故障排查指南
适用场景
- 01为新项目设计缓存架构
- 02为变更操作添加缓存失效逻辑
- 03处理个性化或筛选页面
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mohamed-hossam1/ nextjs-skills --skill nextjs-cache-architecture安装后在 Claude Code 中输入/nextjs-cache-architecture确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
overlay.yamlassets/revalidate.tsSuspenseOnSearchParams.tsxtags.tsevals/evals.jsonreferences/core-concepts.mddebugging-and-checklist.mdmigration-from-unstable-cache.mdpersonalized-content.mdscripts/audit.mjs详细介绍
Next.js 缓存架构
从一开始就为 Next.js 16+ App Router 项目设计缓存架构——不要只是在合适的地方添加 "use cache",还要构建标签注册表、重新验证工具、Suspense 边界和变更操作接线,让缓存随着代码库增长依然保持正确。
如何使用此技能
将以下所有规则和模板应用到用户的实际项目中。编写代码前,先将 [Entity] 和 [collection] 等占位符替换为代码库中的名称。
$ARGUMENTS
接下来查看哪里
大多数实现只需要此文件。任务需要时再加载参考资料。
| 如果用户…… | 阅读 |
|---|---|
询问缓存键如何派生、cacheLife 配置文件的含义,或遇到 "use cache" 的限制 | references/core-concepts.md |
| 缓存任何依赖已登录用户的数据 | references/personalized-content.md |
| 报告数据过时,或进行最终审查 | references/debugging-and-checklist.md |
将现有代码库从 unstable_cache 迁移出去 | references/migration-from-unstable-cache.md |
assets/ 中的即用模板(将占位符重命名为与用户代码库匹配的名称):
assets/tags.ts→lib/cache/tags.tsassets/revalidate.ts→lib/cache/revalidate.tsassets/SuspenseOnSearchParams.tsx→components/SuspenseOnSearchParams.tsx
一句话概括架构
正确的缓存实现有三个关键组成部分。从第一天起就构建好这三部分——事后添加远比一开始就做好困难。
- 标签注册表(
lib/cache/tags.ts)——所有标签字符串都存放在此处。其他地方不得使用原始字符串。 - 重新验证工具(
lib/cache/revalidate.ts)——所有updateTag()调用都放在此处。变更操作从此文件导入。 - 将缓存放在数据上,而不是页面上——
"use cache"应用于数据获取函数或缓存子组件。页面组件负责编排 Suspense 边界;子组件负责获取数据。
这三部分就位后,其余工作只需始终如一地应用它们。
步骤 1 — 启用 Cache Components
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
步骤 2 — 构建缓存标签注册表
文件: lib/cache/tags.ts(模板:assets/tags.ts)
使用 assets/tags.ts 模板。as const satisfies TagRegistry 结构提供字面量类型,并在编译时拒绝格式错误的条目。
// 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;
步骤 3 — 构建重新验证工具
文件: lib/cache/revalidate.ts(模板:assets/revalidate.ts)
所有 updateTag() 调用都放在此处。变更操作导入这些函数——绝不直接调用 updateTag()。
// 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),
]);
}
步骤 4 — 实现数据获取
在数据获取函数中使用 "use cache"。绝不要在页面组件中获取数据——页面组件负责编排,不负责获取数据。
// 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} />;
}
步骤 5 — 构建渲染边界
每个页面都遵循以下结构:
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";
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} />;
}
动态路由页面
// 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} />;
}
筛选 / 搜索参数页面
// 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);
}
当只有 searchParams 发生变化时,标准 <Suspense> 在客户端导航中不会再次触发回退内容。对于带有搜索或筛选参数的每个页面,请使用 SuspenseOnSearchParams(模板:assets/SuspenseOnSearchParams.tsx)。
步骤 6 — 处理个性化内容
在缓存边界之外读取 cookies() / headers() / auth(),并将值作为 prop
传入。该参数会成为自动生成的缓存键的一部分,因此每位用户都会有自己的条目。在 "use cache" 函数
内部调用这些 API 会抛出错误或导致行为异常。
完整的“外部读取/内部缓存”模式以及少见的 "use cache: private" 例外情况,请参阅 references/personalized-content.md。
步骤 7 — 将变更操作连接到失效机制
变更操作调用重新验证工具,不会自行调用 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); // requires the surgical utility to be exported
}
updateTag 与 revalidateTag
两个 API,满足两种不同需求:
| API | 效果 | 调用位置 |
|---|---|---|
updateTag(tag) | 立即生效 — 同一请求会读取到新数据 | 服务端操作,通过 revalidate.ts 调用 |
revalidateTag(tag, "max") | 后台过期时重新验证 — 下一个请求会读取到新数据 | 路由处理程序、webhook |
revalidateTag 始终需要第二个参数("max" 表示过期时重新验证,{ expire: 0 } 表示立即硬过期)。单参数形式已弃用,在某些配置中会静默失效。
常见错误
缓存行为异常时,请按顺序逐项检查。前六项几乎可以捕获所有问题;只有其余检查通过后,才运行 next build。完整的调试流程和签核清单见 references/debugging-and-checklist.md。
| 症状或迹象 | 修复 |
|---|---|
| 函数在每次请求中都未缓存地运行 | "use cache" 位于 await 之后 — 将其移到第一条语句。 |
| 缓存函数抛出错误,或针对不同用户返回错误数据 | 将 cookies() / headers() / auth() 移到外部;将值作为参数传入。 |
updateTag 没有作用 | 标签字符串拼写错误,或从未通过 cacheTag 注册匹配的标签。 |
| 变更操作已完成,但列表仍读取到过期数据 | 在写入前调用了重新验证工具,或者完全没有调用。 |
| 只有一个区块发生变化,整个页面却重新渲染 | 动态子组件位于缓存父组件内 — 使用 <Suspense> 拆分。 |
| 筛选 UI 在导航时没有显示加载状态 | 使用了普通的 <Suspense> — 改为 SuspenseOnSearchParams。 |
| 页面被标记为动态,而你原本预期它是静态页面 | 运行 next build;在路由的源代码树中追踪泄漏的动态 API。 |
| 页面组件直接获取数据 | 将数据获取移入缓存子组件;页面应负责协调,而非获取数据。 |
完整的调试流程和签核清单请参阅
references/debugging-and-checklist.md。要根据用户的项目验证已完成实现中的静态部分,请运行
scripts/audit.mjs <project-root> — 用法及其检查内容记录在
README.md 中。