Next.js 16の`cacheComponents`:App Routerキャッシュから91,000ページの移行

大規模なeコマースカタログをNext.js 14のApp Routerで約18ヶ月間運用していたとき、Next.js 16がリリースされました。91,247ページです。商品リスト、カテゴリツリー、編集コンテンツ、14市場にわたるローカライズされたバリエーション。旧キャッシュモデル(Server Componentsがデフォルトでキャッシュされるモデル)は、古いデータのバグとrevalidateTagスパゲッティの地雷原になっていました。Next.jsチームがcacheComponentsと、Next.js 15でのデフォルト非キャッシュへのシフト(v16で継続して洗練)を発表したとき、私たちはそれが時期だと知りました。これがその移行の物語です:何が機能したのか、何が機能しなかったのか、そしてその先側のパフォーマンス数値です。

目次

Next.js 16 cacheComponents: Migrating 91,000 Pages from App Router Caching

実際に抱えていたキャッシング問題

状況を説明しましょう。Next.js 14のApp Routerでは、Server Componentsのfetchリクエストがデフォルトでキャッシュされていました。Data Cacheはデプロイ間で永続化されました。Full Route CacheはビルドタイムでレンダリングされたHTMLとRSCペイロードを保存しました。そしてクライアント側のRouter Cacheはプリフェッチされたセグメントを...予想されるより長い期間保持していました。

91,000ページのサイトでは、このデフォルト「すべてキャッシュ」アプローチは2つのカテゴリの問題を生み出しました:

どこもかしこも古いデータ。 商品価格がヘッドレスCMS(この場合はSanity)で更新されましたが、キャッシュされたfetchの結果は残ったままでした。47個の異なるサーバーアクションに散らばったrevalidateTag呼び出しがありました。1つのタグを見落とすと? 顧客は昨日の価格を見ます。実際に#cache-crimesというSlackチャネルがあり、コンテンツチームが古いページを報告していました。

地獄のようなビルド時間。 91,000ページの完全な静的生成には3時間以上かかりました。ほとんどのページにrevalidate: 3600でISRに移行しましたが、ISR、Data Cache、オンデマンド再検証間の相互作用は、本当に理解するのが難しかったです。新しい開発者はチームの最初の2週間を単にキャッシングレイヤーを理解するのに費やしていました。

心的モデルの税金

ここで人々が過小評価していると思うのは:暗黙的キャッシングの認知コストです。キャッシングがデフォルトで、オプトアウトするとき、新しいコンポーネントごとに「これはキャッシュされるべき?」と聞く必要があり、答えが「いいえ」の場合は正しいディレクティブを追加することを忘れないでいてください。非キャッシングがデフォルトで、オプトインするとき、アクティブにそれが必要なときだけキャッシングについて考えます。これは根本的に異なる(そしてより良い)心的モデルです。

Next.js 15と16で何が変わったか

Next.js 15は大きな哲学的転換でした。チームはデフォルトをひっくり返しました:

動作 Next.js 14 Next.js 15 Next.js 16
Server Componentsのfetch() デフォルトでキャッシュ デフォルトではキャッシュされない デフォルトではキャッシュされない
Route Handlers (GET) デフォルトでキャッシュ デフォルトではキャッシュされない デフォルトではキャッシュされない
Client Router Cache 30秒(動的)/ 5分(静的) ページセグメントで0秒 デフォルト0秒、設定可能
Full Route Cache 静的ルートで有効 同じ 同じ、cacheLifeの改善付き
コンポーネントレベルのキャッシング unstable_cache use cacheディレクティブ(実験的) cacheComponents API(安定)

Next.js 15はフラグの背後で実験的機能としてuse cacheディレクティブを導入しました。2025年初頭にリリースされたNext.js 16は、これをcacheComponents設定オプションと関連する"use cache"ディレクティブとして安定化させ、カスタムキャッシュプロファイルを定義するためのcacheLifeと、ターゲットされた無効化のためのcacheTagに沿っています。

重要な洞察:キャッシングはフレームワークの暗黙的な動作から、コンポーネントレベルでの明示的な開発者の選択に移行しました。 これは大規模サイトにとって非常に大きな変化です。

cacheComponentsを理解する

next.config.jscacheComponents機能は、"use cache"ディレクティブを通じてコンポーネントレベルのキャッシングを有効にします。基本的な設定は次のとおりです:

// next.config.js (Next.js 16)
const nextConfig = {
  experimental: {
    cacheComponents: true,
  },
};

module.exports = nextConfig;

有効にすると、任意の非同期Server Component、サーバーアクション、またはレイアウトファイルの上部に"use cache"を追加できます:

// app/products/[slug]/page.tsx
"use cache";

import { cacheLife, cacheTag } from 'next/cache';

export default async function ProductPage({ params }: { params: { slug: string } }) {
  cacheLife('products'); // カスタムキャッシュプロファイル
  cacheTag(`product-${params.slug}`);

  const product = await fetchProduct(params.slug);
  
  return (
    <div>
      <h1>{product.name}</h1>
      <ProductDetails product={product} />
      <DynamicPricing productId={product.id} /> {/* このコンポーネントはキャッシュされません */}
    </div>
  );
}

cacheLifeプロファイル

大規模サイトではここが興味深くなります。next.config.jsで名前付きキャッシュプロファイルを定義します:

const nextConfig = {
  experimental: {
    cacheComponents: true,
    cacheLife: {
      products: {
        stale: 300,      // 5分間古いものを提供
        revalidate: 3600, // 1時間後に再検証
        expire: 86400,    // 24時間後にハード削除
      },
      editorial: {
        stale: 3600,
        revalidate: 86400,
        expire: 604800,   // 7日
      },
      navigation: {
        stale: 86400,
        revalidate: 604800,
        expire: 2592000,  // 30日
      },
    },
  },
};

3層モデル(stalerevalidateexpire)は、stale-while-revalidateセマンティクスに適切にマップされます。staleウィンドウ中、キャッシュされたコンテンツはすぐに提供されます。staleの後だがexpireの前に、バックグラウンド再検証がキックインします。expireの後、キャッシュエントリは削除されます。

無効化のためのcacheTag

cacheTag関数は、古いrevalidateTagパターンを、より構成可能な何かに置き換えます:

import { revalidateTag } from 'next/cache';

// webhookハンドラまたはサーバーアクション内で:
export async function handleProductUpdate(productSlug: string) {
  revalidateTag(`product-${productSlug}`);
  revalidateTag('product-listing'); // リストページも無効化
}

この部分はNext.js 15から大きく変わっていませんが、cacheComponentsと一緒に使用するとはるかに良く機能します。なぜなら、不透明なフレームワークレベルのキャッシュを無効化しようとするのではなく、特定のキャッシュされたコンポーネントをタグ付けしているからです。

Next.js 16 cacheComponents: Migrating 91,000 Pages from App Router Caching - architecture

91,000ページの移行戦略

これを一度にはやりませんでした。14のロケール全体で91,000ページを持つ場合、ビッグバン移行は無謀でした。これは次のように分割しました:

フェーズ1:Next.js 16へアップグレード、キャッシュ変更なし(1~2週間目)

キャッシュを有効にせずに、Next.js 14.2から16.0にアップグレードしました。これだけでfetchリクエストがデフォルトでキャッシュされなくなったため、動作が変わります。TTFB回帰が予想され、実際にそうなりました:

  • 平均TTFBは製品ページで180msから340msに増加
  • オリジンサーバーロードが約60%増加(Sanity CDNは問題なく対応しましたが、カスタムAPIエンドポイントはそうではありません)
  • ISR再検証は実際に高速化されました(管理するキャッシュ状態が少なくなったため)

これは疑問を確認しました:暗黙的キャッシングに大きく依存していて、多くのページは本当にキャッシングが必要でした。ただし、明示的で意図的なキャッシングが必要でした。

フェーズ2:監査と分類ページ(3週間目)

アプリのすべてのルートを分類しました:

ページタイプ キャッシュ戦略 cacheLifeプロファイル
商品詳細ページ 42,000 商品タグでキャッシュ products(5分古い/1時間再検証)
カテゴリリストページ 3,200 カテゴリタグでキャッシュ products(5分古い/1時間再検証)
編集/ブログページ 8,400 積極的にキャッシュ editorial(1時間古い/24時間再検証)
ローカライズバリエーション 31,647 ベースページと同じ ベースから継承
アカウント/動的ページ 6,000 キャッシュなし N/A

フェーズ3:cacheComponentsを有効にし、ディレクティブを追加(4~6週間目)

フラグを有効にし、"use cache"ディレクティブを追加し始めました。重要な決定:ほとんどのルートでページレベルでキャッシュしましたが、静的/動的コンテンツが混在するページではコンポーネントレベルでキャッシュしました。

製品ページの場合、製品情報と画像はキャッシュされましたが、価格設定コンポーネントと在庫ステータスはキャッシュされませんでした:

// components/ProductInfo.tsx
"use cache";

import { cacheLife, cacheTag } from 'next/cache';

export async function ProductInfo({ slug }: { slug: string }) {
  cacheLife('products');
  cacheTag(`product-${slug}`, 'product-info');
  
  const product = await getProduct(slug);
  
  return (
    <section>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ProductImages images={product.images} />
    </section>
  );
}
// components/DynamicPricing.tsx
// "use cache"ディレクティブなし -- 常に新鮮

export async function DynamicPricing({ productId }: { productId: string }) {
  const pricing = await getPricing(productId); // すべてのリクエストで価格設定APIにヒット
  
  return (
    <div className="pricing">
      <span className="price">${pricing.current}</span>
      {pricing.onSale && <span className="was-price">${pricing.original}</span>}
    </div>
  );
}

フェーズ4:Webhook統合(7週間目)

Sanity webhookを、正しいタグでrevalidateTagを呼び出すようにリワイアしました。これは実際に古い設定よりシンプルでした。なぜなら、タグは今、コード内で明示的であり、fetchオプション全体に散らばっていないからです。

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.json();
  const secret = request.headers.get('x-webhook-secret');
  
  if (secret !== process.env.REVALIDATION_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  switch (body._type) {
    case 'product':
      revalidateTag(`product-${body.slug.current}`);
      revalidateTag('product-listing');
      break;
    case 'category':
      revalidateTag(`category-${body.slug.current}`);
      revalidateTag('navigation');
      break;
    case 'article':
      revalidateTag(`article-${body.slug.current}`);
      break;
  }

  return new Response('OK', { status: 200 });
}

実装:ステップバイステップ

同様の移行を行っている場合、ここが実用的なプレイブックです。私たちがお勧めします(そして現在、Next.js開発プロジェクトでSocial Animalで使用しています):

ステップ1:フラグを有効にする

// next.config.js
module.exports = {
  experimental: {
    cacheComponents: true,
    cacheLife: {
      // 合理的なデフォルトで開始
      default: {
        stale: 60,
        revalidate: 900,
        expire: 86400,
      },
    },
  },
};

ステップ2:ホットパスを見つける

アナリティクスを使用して、最も多くのトラフィックを取得し、TTFBが最も重要なページを特定します。私たちにとっては、カテゴリページ(トラフィック多い、比較的安定したコンテンツ)と製品ページ(トラフィック多い、中程度に動的なコンテンツ)でした。

ステップ3:トップダウンで`"use cache"`を追加

レイアウトで開始します。ルートレイアウトがナビゲーションデータをフェッチする場合、最初にそれをキャッシュします。最も高い影響、最も低いリスクの変更です:

// app/layout.tsx
// 注意:レイアウトの"use cache"はレイアウトシェルをキャッシュします
// 子ページは依然として独立して時間通りにレンダリングします

import { Navigation } from '@/components/Navigation';

export default async function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Navigation /> {/* このコンポーネントは独自の"use cache"を持っています */}
        {children}
      </body>
    </html>
  );
}

ステップ4:モニタリングを設定

Vercelの組み込みアナリティクスとカスタムロギングを使用してキャッシュヒット率を追跡しました。cacheComponentsを有効にした最初の週の後、キャッシュヒット率はわずか34%でした。stale期間を調整した後、78%に登りました。

パフォーマンス結果とベンチマーク

30日間のVervel Pro プランでの測定後の実際の数値は次のとおりです:

メトリック 前(Next.js 14) フェーズ1後(v16、キャッシュなし) 完全移行後
平均TTFB(製品ページ) 180ms 340ms 95ms
平均TTFB(カテゴリページ) 220ms 410ms 72ms
平均TTFB(編集ページ) 150ms 280ms 45ms
P99 TTFB(すべてのページ) 1,200ms 2,100ms 380ms
ビルド時間(完全) 3時間12分 2時間48分 48分
Vercel関数呼び出し/日 2.4M 3.8M 1.1M
月別Vervel請求 ~$840 ~$1,200 ~$520
キャッシュヒット率 不明(暗黙) N/A 78%
古いコンテンツインシデント(#cache-crimes) 週8~12件 0 月1~2件

ビルド時間の改善は説明する価値があります。cacheComponentsを使用することで、ビルド時にすべての91,000ページを生成することから遠ざかりました。代わりに、上位5,000ページ(トラフィック別)だけを静的に生成し、残りをオンデマンドで生成させてキャッシングしました。cacheComponentsディレクティブは、これらのオンデマンドページが最初のアクセス後にキャッシュされることを意味し、cacheLifeが新鮮さを制御します。

Vervel請求の低下は重要でした。関数呼び出しが少ない(明示的なコンポーネントキャッシングのため)とビルド時間が短いことは、実際のコスト削減を意味していました。その約$320/月の削減はそれ自体で利益を得ます。

落とし穴と注意点

シリアライゼーション境界

"use cache"ディレクティブはシリアライゼーション境界を作成します。キャッシュされたコンポーネントにプロップとして渡されるすべてのものはシリアライズ可能である必要があります。プロップとしてコールバック関数またはReact要素を受け取るコンポーネントがいくつかありました。これらは即座に壊れました。修正は、代わりに構成パターンへの再構築でした:

// ❌ "use cache"で壊れる
"use cache";
export async function ProductCard({ product, onAddToCart }) {
  // onAddToCartは関数です。シリアライズ可能ではありません!
}

// ✅ 動作する
"use cache";
export async function ProductCard({ product }) {
  return (
    <div>
      <h2>{product.name}</h2>
      {/* AddToCartはClient Component、キャッシュされません */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

動的パラメータとキャッシュキー爆発

91,000ページで、それぞれが固有のパラムを持つ場合、キャッシュキースペースは膨大です。最初の週にVervelのエッジキャッシュ制限にヒットし、どのページが長いexpire値を取得するかについてより戦略的になる必要がありました。トラフィック低いロングテールページはキャッシュ期間が短くなりました。

`Date.now()`トラップ

"use cache"を使用するキャッシュ関数内でDate.now()またはnew Date()を呼び出すコンポーネントはそのタイムスタンプをキャッシュします。「最後に更新」ディスプレイで同じ時刻を数時間表示していることを発見しました。修正:時間に敏感なロジックをClient ComponentまたはキャッシュされていないServer Componentに移動します。

ネストされたキャッシュ境界

他のキャッシュされたコンポーネント内にキャッシュされたコンポーネントをネストする場合、内部キャッシュは独自のライフサイクルを持っています。これは強力ですが混乱します。チームの慣例を確立しました:明確な理由がない限り、ページレベルまたはコンポーネントレベルでキャッシュし、両方ではなく

cacheComponentsを使うべき場合と使うべきでない場合

使うべき場合:

  • 数百ページ以上のページがあり、ISRビルド時間が苦痛
  • コンテンツにセクション全体で異なる明確な新鮮さ要件がある
  • キャッシュされるもの対常に新鮮なものに対してきめ細かい制御が必要
  • Vercelまたはこの機能をサポートするプラットフォームで実行している
  • インフラコストを高トラフィックサイトで削減したい

使うべきでない場合:

  • サイトが十分に小さく、完全なSSGが正常に機能する
  • すべてのページが完全に動的です(ユーザー固有のコンテンツが至る所)
  • 次.jsキャッシングインフラストラクチャをサポートするホスティングプラットフォームではない
  • チームがNext.jsに新しい場合。最初に基本を理解してください

プロジェクトがこのレベルのキャッシング制御が必要かどうかを評価している場合、またはコンテンツの多いサイトにAstroのような異なるフレームワークがより適しているかもしれない場合、移行をコミットする前に考える価値があります。

複数のヘッドレスCMSソースからコンテンツが来るプロジェクトの場合、Next.js 16のcacheTagシステムはヘッドレスCMSアーキテクチャと素晴らしく機能します。各コンテンツタイプは独自の無効化チャネルを取得します。

FAQ

Next.js 16のcacheComponentsとは何ですか? cacheComponentsはNext.js 16の実験的な設定オプションで、Server Componentsの"use cache"ディレクティブを有効にします。これにより、どのコンポーネントをキャッシュすべきか明示的にマークでき、cacheLifeを使用してカスタムキャッシュプロファイルを定義できます。これは、Next.js 15で実験的だったuse cacheディレクティブの安定化された進化です。

cacheComponentsはISR(増分静的再生成)とどう違いますか? ISRはページ全体をキャッシュし、時間ベースのスケジュールで再検証します。cacheComponentsを使用すると、ページ内の個々のコンポーネントを、異なるキャッシュライフタイムでキャッシュできます。単一のページは、ヘッダーを24時間キャッシュでき、製品情報を1時間のキャッシュでき、価格設定をキャッシュできません。ISRはそれができません。これはページレベルでのすべてまたは何もないです。

cacheComponentsを使用するにはVervelに属する必要がありますか? いいえ、ですが、キャッシング基盤がしっかり統合されているため、Vervelでの体験が最高です。Self-hostedNext.jsデプロイメントはファイルシステムキャッシュアダプタでcacheComponentsを使用できますが、エッジディストリビューションの利点は得られません。NetlifyやCloudflareのようなプラットフォームがサポートを追加しています。しかし、2025年半ばの時点では、Vervelが最も完全な実装のままです。

Next.js 16でキャッシュされたコンポーネントを無効化するにはどうすればよいですか? cacheTag()をキャッシュされたコンポーネント内で使用してタグを割り当て、次にサーバーアクション、ルートハンドラー、またはwebhookエンドポイントからrevalidateTag('tag-name')を呼び出します。これはそのタグを持つすべてのキャッシュされたコンポーネントを無効化します。これはNext.js 15からの同じAPIですが、不透明なフレームワークレベルのキャッシュを無効化しようとするのではなく、明示的なキャッシュされたコンポーネントをタグ付けしているため、はるかに便利です。

cacheComponentsはVervel請求を削減しますか? 重大に削減できます。私たちの場合、キャッシュされたコンポーネント応答がキャッシュレイヤーから提供されていたため(サーバーレス関数の呼び出しではなく)、関数呼び出しが54%削減されました。ビルド時間の削減もビルド時間を節約します。実行距離はトラフィックパターンとキャッシュヒット率に基づいて異なります。現在の使用法でVervelの価格計算機を確認します。

シリアライズ不可能なプロップを受け取るコンポーネントに"use cache"を追加するとどうなりますか? ビルドエラーが表示されます。"use cache"ディレクティブはシリアライゼーション境界を作成するため、すべてのプロップはシリアライズ可能である必要があります(文字列、数値、プレーンオブジェクト、配列)。関数、React要素、クラスインスタンス、および他のシリアライズ不可能な値はビルドを失敗させます。コンポーネントを再構築してデータプロップのみを受け入れ、子Client Componentsで相互作用を処理します。

他のフレームワークのReact Server ComponentsでcacheComponentsを使用できますか? いいえ。cacheComponentsはReactのServer Componentsの上に構築されるNext.js固有の機能です。"use cache"ディレクティブ構文は最終的にReact標準になるかもしれませんが、cacheLifeプロファイルとcacheTagシステムはNext.js APIです。RemixやカスタムRSCセットアップのようなフレームワークを使用している場合、異なるキャッシング戦略が必要です。

大規模なNext.jsサイトをcacheComponentsに移行するのにどのくらい時間がかかりますか? 91,000ページのサイトで4人の開発者チーム用の完全な移行に7週間かかりました。より小さいサイト(10,000ページ以下)とより単純なデータモデルでは、おそらく1~2週間で実行できる場合があります。実際のコード変更は簡単です。時間は、キャッシング要件の監査、無効化フローのテスト、デプロイ後のキャッシュヒット率の監視に費やされます。一人で行きたくない場合は、お問い合わせください。これを何度かやっています。