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
アーキテクチャの要点
正しいキャッシュ実装には、基盤となる要素が 3 つあります。初日から 3 つすべてを構築しましょう。最初に適切に整えておくより、後から追加するほうがはるかに困難です。
- タグレジストリ(
lib/cache/tags.ts)— すべてのタグ文字列をここに集約します。それ以外の場所では、生の文字列を使いません。 - 再検証ユーティリティ(
lib/cache/revalidate.ts)— すべてのupdateTag()呼び出しをここに集約します。ミューテーションはこのファイルからインポートします。 - ページではなくデータにキャッシュを配置する —
"use cache"はデータ取得関数またはキャッシュされた子コンポーネントに配置します。ページコンポーネントは Suspense 境界を構成し、データ取得は子コンポーネントが行います。
この 3 つを整えたら、あとは一貫して適用するだけです。
ステップ 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);
}
<Suspense> だけでは、searchParams の変更のみでクライアントサイドナビゲーションが発生した場合に、フォールバックは再表示されません。検索またはフィルターパラメーターを含むすべてのページで、SuspenseOnSearchParams(テンプレート:assets/SuspenseOnSearchParams.tsx)を使用してください。
ステップ 6 — パーソナライズされたコンテンツを扱う
cookies() / headers() / auth() はキャッシュ境界の外側で読み取り、
値をプロパティとして渡します。引数は自動生成されるキャッシュキーの一部となるため、
ユーザーごとに独自のエントリーが作成されます。これらの API を
"use cache" 関数の内側で呼び出すと、エラーが発生するか、正しく動作しません。
キャッシュの外側で読み取り、内側でキャッシュするパターンの詳細と、
例外的なケースである "use cache: private" については、
references/personalized-content.md を参照してください。
ステップ 7 — ミューテーションと無効化を連携させる
ミューテーションでは再検証ユーティリティを呼び出し、updateTag()を
直接呼び出すことはありません。これにより、キャッシュレイヤーが機械的に
動作し、1 つのファイルで監査できるようになります。また、オブザーバビリティ
(ロギング、トレーシング)を 1 か所に追加できます。
// 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 の違い
用途に応じて使い分ける 2 つの API です。
| API | 効果 | 呼び出し元 |
|---|---|---|
updateTag(tag) | 即時 — 同じリクエストで最新のデータを参照します | サーバーアクション(revalidate.ts 経由) |
revalidateTag(tag, "max") | バックグラウンドで古いデータを返しながら再検証 — 次のリクエストで最新のデータを参照します | ルートハンドラー、Webhook |
revalidateTag では、常に 2 番目の引数を指定します(stale-while-revalidate には
"max"、即時に完全失効させるには { expire: 0 })。引数を 1 つだけ指定する形式は
非推奨で、一部の構成では何も起こらずに処理されます。
よくある間違い
キャッシュが期待どおりに動作しない場合は、次の項目を順番に確認してください。最初の 6 項目で
ほとんどの問題を特定できます。ほかの項目を確認してから、next buildを実行してください。完全な
デバッグ手順と最終確認チェックリストについては、references/debugging-and-checklist.md を参照してください。
| 症状または問題の兆候 | 修正 |
|---|---|
| 関数がリクエストごとにキャッシュされず実行される | "use cache"がawaitの後にあります — 最初の文に移動してください。 |
| キャッシュされた関数がエラーになる、またはユーザーごとに誤ったデータを返す | cookies() / headers() / auth() を外側に移動し、値を引数として渡してください。 |
updateTagを呼び出しても何も起こらない | タグ文字列の誤記、または一致するタグがcacheTagに登録されていません。 |
| ミューテーションは完了するが、リストに古いデータが表示される | 書き込み前に再検証ユーティリティが呼び出されているか、まったく呼び出されていません。 |
| 1 つのセクションだけが変更されたのに、ページ全体が再レンダリングされる | 動的な子コンポーネントがキャッシュされた親の内側にあります — <Suspense>で分割してください。 |
| ナビゲーション時にフィルター UI に読み込み状態が表示されない | 通常の<Suspense>を使っています — SuspenseOnSearchParamsに切り替えてください。 |
| 静的になると想定したページが動的としてマークされる | next buildを実行し、ルートのソースツリーで漏れ出した動的 API を追跡してください。 |
| ページコンポーネントがデータを直接取得している | 取得処理をキャッシュされた子コンポーネントに移動してください。ページの役割は取得ではなく、オーケストレーションです。 |
完全なデバッグ手順と最終確認チェックリストについては、
references/debugging-and-checklist.md を参照してください。完成した実装の静的な部分を
ユーザーのプロジェクトと照合して確認するには、scripts/audit.mjs <project-root>を実行します。
使い方とチェック内容はREADME.mdに記載されています。