
nextjs-cache-architecture
当用户希望在 Next.js 16+ App Router 项目中设计或实现缓存时使用此技能——包括设置 "use cache" 指令、构建缓存标签注册表、将数据变更与失效工具连接、为部分预渲染构建 Suspense 边界、在缓存边界附近处理个性化内容、选择 cacheLife 配置文件、正确调用 cacheTag / updateTag / revalidateTag、从 unstable_cache 迁移,或调试过期或错误的新鲜数据。即使用户仅描述其领域(例如“我有一个 posts 表”)并询问如何正确缓存时,也会触发。
当用户希望在 Next.js 16+ App Router 项目中设计或实现缓存时使用此技能——包括设置 "use cache" 指令、构建缓存标签注册表、将数据变更与失效工具连接、为部分预渲染构建 Suspense 边界、在缓存边界附近处理个性化内容、选择 cacheLife 配置文件、正确调用 cacheTag / updateTag / revalidateTag、从 unstable_cache 迁移,或调试过期或错误的新鲜数据。即使用户仅描述其领域(例如“我有一个 posts 表”)并询问如何正确缓存时,也会触发。
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 — 启用缓存组件
// 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(骨架——完整模板在 assets/tags.ts 中)
export const CACHE_TAGS = {
// 集合标签——每个逻辑数据组一个,始终存在。
[collection]: "[collection]",
// 实体标签工厂——仅当变更针对单个条目时。
[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);
}
// 批量——集合中的任何条目发生更改。
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),
]);
}
步骤 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!;
// 好:集合获取。
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();
}
// 坏:在页面组件中获取数据会绕过缓存和失效。
export default async function Page() {
const res = await fetch("/api/items");
const data = await res.json();
return <View data={data} />;
}
步骤 5 — 构建渲染边界
每个页面遵循以下结构:
页面组件(同步,仅编排——不获取数据)
├── 静态外壳(布局、导航——无数据)
├── <Suspense> → 缓存的共享内容
└── <Suspense> → 动态个性化内容
标准页面
// 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]);
// 仅当某个变更需要精确失效时才添加 CACHE_TAGS.[entity](id)。
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 是参数 → 每个唯一参数组合自动生成键。
const { q = "", page = "1" } = await searchParams;
return await get[Collection]ByFilter(q, page);
}
标准的 <Suspense> 在客户端导航时,如果只有 searchParams 发生变化,不会重新触发其 fallback。在每个带有搜索或筛选参数的页面上使用 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); // 需要导出精确工具
}
updateTag 与 revalidateTag
两个 API 用于两种不同的需求:
| API | 效果 | 调用位置 |
|---|---|---|
updateTag(tag) |
立即——同一请求看到新鲜数据 | 服务器操作,通过 revalidate.ts |
revalidateTag(tag, "max") |
后台 stale-while-revalidate——下一个请求看到新鲜数据 | 路由处理程序、webhooks |
revalidateTag 始终接受第二个参数("max" 用于 stale-while-revalidate,{ 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 中有文档说明。





