nextjs-cache-architecture

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Next.js Cache Architecture

Next.js 缓存架构

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.
从项目初期就为Next.js 16+ App Router项目设计缓存架构——不只是在合适的地方添加
"use cache"
,而是构建标签注册表、重验证工具、Suspense边界和变更关联逻辑,确保随着代码库的增长,缓存始终保持正确。

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.
text
$ARGUMENTS
将以下所有规则和模板应用到用户的实际项目中。在编写任何代码之前,将
[Entity]
[collection]
等占位符替换为用户代码库中的名称。
text
$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.ts
  • assets/revalidate.ts
    lib/cache/revalidate.ts
  • assets/SuspenseOnSearchParams.tsx
    components/SuspenseOnSearchParams.tsx
大多数实现只需要此文件。当任务需要时,加载对应的参考文档。
用户场景参考文档
询问缓存键如何生成、
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.ts
  • assets/revalidate.ts
    lib/cache/revalidate.ts
  • assets/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.
  1. Tag registry (
    lib/cache/tags.ts
    ) — every tag string lives here. No raw strings anywhere else.
  2. Revalidation utilities (
    lib/cache/revalidate.ts
    ) — every
    updateTag()
    lives here. Mutations import from this file.
  3. 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.
一个正确的缓存实现包含三个核心部分。从项目第一天就构建这三个部分——事后添加比一开始就做好要困难得多。
  1. 标签注册表
    lib/cache/tags.ts
    )——所有标签字符串都存放在这里。其他任何地方都不使用原始字符串。
  2. 重验证工具
    lib/cache/revalidate.ts
    )——所有
    updateTag()
    调用都在这里。变更操作从此文件导入这些工具。
  3. 在数据层而非页面层设置缓存——
    "use cache"
    应放在数据获取函数或缓存子组件中。页面组件负责编排Suspense边界;子组件负责数据获取。
一旦这三个部分就位,剩下的就是持续应用它们。

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:
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.
ts
// 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.ts
(模板:
assets/tags.ts
使用
assets/tags.ts
模板。
as const satisfies TagRegistry
的结构提供字面量类型,并在编译时拒绝格式错误的条目。
ts
// 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:
lib/cache/revalidate.ts
(template:
assets/revalidate.ts
)
All
updateTag()
calls live here. Mutations import these functions — they never call
updateTag()
directly.
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.ts
(模板:
assets/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
"use cache"
in data-fetching functions. Never fetch inside page components — page components orchestrate, they do not fetch.
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
<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.
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作为参数→每个唯一参数组合都会自动生成对应的缓存键。

  const { q = "", page = "1" } = await searchParams;
  return await get[Collection]ByFilter(q, page);
}
标准的
<Suspense>
在仅
searchParams
变化时的客户端导航中不会重新触发其加载状态。在所有带有搜索或筛选参数的页面上使用
SuspenseOnSearchParams
(模板:
assets/SuspenseOnSearchParams.tsx
)。

Step 6 — Handle personalized content

步骤6 — 处理个性化内容

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.
在缓存边界外部读取
cookies()
/
headers()
/
auth()
,并将值作为props传递。该参数会成为自动生成的缓存键的一部分,因此每个用户都会有自己的缓存条目。在
"use cache"
函数内部调用这些API会抛出错误或导致错误行为。
查看
references/personalized-content.md
获取完整的“外部读取/内部缓存”模式以及罕见的
"use cache: private"
例外情况。

Step 7 — Wire mutations to invalidation

步骤7 — 将变更操作关联到缓存失效

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.
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

updateTag
vs
revalidateTag

Two APIs for two different needs:
APIEffectCall from
updateTag(tag)
Immediate — the same request sees fresh dataServer actions, via
revalidate.ts
revalidateTag(tag, "max")
Background stale-while-revalidate — next request sees fresh dataRoute 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.
两个API对应两种不同的需求:
API效果调用来源
updateTag(tag)
立即生效——同一请求会获取最新数据服务器操作,通过
revalidate.ts
调用
revalidateTag(tag, "max")
后台 stale-while-revalidate 模式——下一次请求会获取最新数据路由处理器、Webhooks
revalidateTag
始终需要第二个参数(
"max"
对应stale-while-revalidate模式,
{ expire: 0 }
对应立即强制过期)。单参数形式已被弃用,在某些配置中会静默失效。

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 smellFix
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 userMove
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 staleRevalidation utility called before the write, or not called at all.
Whole page re-renders even though only one section changedA dynamic child sits inside a cached parent — split with
<Suspense>
.
Filter UI doesn't show a loading state on navigationPlain
<Suspense>
— switch to
SuspenseOnSearchParams
.
Page marked dynamic when you expected staticRun
next build
; trace the leaked dynamic API in the route's source tree.
Page component fetches data directlyMove 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
.
当缓存行为异常时,按以下顺序排查。前六条几乎能解决所有问题;在其余检查通过后再运行
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
中有说明。",