mcpField
N
nextjs-cache-architecture

nextjs-cache-architecture

github.com/mohamed-hossam1開発105.0k 回インストール22 週間前に更新
リポジトリ

nextjs-cache-architecture について

Next.js 16+ App Router プロジェクトのキャッシュ設計を支援するスキルです。タグ管理、再検証、データ取得、レンダリング境界を扱い、テンプレートとトラブルシューティングも提供します。

主な機能

  1. 01キャッシュタグの一元管理
  2. 02再検証ユーティリティの集約
  3. 03データ取得関数のキャッシュ
  4. 04Suspense 境界のパターン
  5. 05テンプレートと問題解決ガイド

活用シーン

  1. 01新規プロジェクトのキャッシュ設計
  2. 02ミューテーションへのキャッシュ無効化追加
  3. 03個人向けまたはフィルター付きページの処理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mohamed-hossam1/nextjs-skills --skill nextjs-cache-architecture

インストール後、Claude Code で/nextjs-cache-architectureを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

overlay.yaml
assets/
revalidate.ts
SuspenseOnSearchParams.tsx
tags.ts
evals/
evals.json
references/
core-concepts.md
debugging-and-checklist.md
migration-from-unstable-cache.md
personalized-content.md
scripts/
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.ts
  • assets/revalidate.ts → lib/cache/revalidate.ts
  • assets/SuspenseOnSearchParams.tsx → components/SuspenseOnSearchParams.tsx

アーキテクチャの要点

正しいキャッシュ実装には、基盤となる要素が 3 つあります。初日から 3 つすべてを構築しましょう。最初に適切に整えておくより、後から追加するほうがはるかに困難です。

  1. タグレジストリ(lib/cache/tags.ts)— すべてのタグ文字列をここに集約します。それ以外の場所では、生の文字列を使いません。
  2. 再検証ユーティリティ(lib/cache/revalidate.ts)— すべての updateTag() 呼び出しをここに集約します。ミューテーションはこのファイルからインポートします。
  3. ページではなくデータにキャッシュを配置する — "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に記載されています。

コメント