nextjs-cache-architecture

nextjs-cache-architecture

當使用者想在 Next.js 16+ App Router 專案中設計或實作快取時使用此技能——設定 "use cache" 指令、建立快取標籤註冊表、將資料異動與失效工具串接、為部分預渲染建構 Suspense 邊界、在快取邊界附近處理個人化內容、選擇 cacheLife 設定檔、正確呼叫 cacheTag / updateTag / revalidateTag、從 unstable_cache 遷移,或偵錯過時或不正確的新鮮資料。即使使用者只描述其領域(例如「我有一個 posts 資料表」)並詢問如何正確快取,也應觸發。

8星標
0分支
更新於 2026/5/30
SKILL.md
唯讀
名稱
nextjs-cache-architecture
描述

當使用者想在 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.tslib/cache/tags.ts
  • assets/revalidate.tslib/cache/revalidate.ts
  • assets/SuspenseOnSearchParams.tsxcomponents/SuspenseOnSearchParams.tsx

架構一句話

一個正確的快取實作包含三個關鍵部分。第一天就全部建立——之後再新增會比一開始就做對困難得多。

  1. 標籤註冊表lib/cache/tags.ts)——所有標籤字串都在這裡。其他地方不得使用原始字串。
  2. 失效工具lib/cache/revalidate.ts)——所有 updateTag() 都在這裡。資料異動從此檔案匯入。
  3. 快取放在資料上,而非頁面上——"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); // 需要匯出精準失效工具
}

updateTagrevalidateTag 的比較

兩種 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 中。