
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 會拋出錯誤或產生錯誤行為。
請參閱 references/personalized-content.md 以了解完整的「外部讀取 / 內部快取」模式以及罕見的 "use cache: private" 例外。
步驟 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——下一個請求看到最新資料 | 路由處理器、webhook |
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 中。





