Next.jsのApp Routerとv16におけるキャッシュ戦略

著者: 山岸和利

Next.jsについてApp Routerが導入された必然性からv16におけるキャッシュ戦略の転換、そしてインフラストラクチャにおけるVercel依存 (ロックイン) の誤解について改めて整理し、備忘録として残しておこうと思います。

Next.jsの進化の変遷とApp Routerのコア概念

2016年の初期リリース時から今日に至るまで、Next.jsは単なるSSR (Server Side Rendering) のラッパーツールから「WebのOS」とも呼べるような包括的なフレームワークへと進化を遂げています。

初期 (v1〜) はファイルを配置すればHTMLが生成されるシンプルなものでしたが、動的ルーティングやAPI連携を行うには前段にExpressなどのカスタムサーバーを配置する必要がありました。その後、中期 (v9〜) においてgetStaticPropsなど導入によるSSG (静的サイト生成) やAPI Routesがサポートされ、BFF (Backend For Frontend) としての機能を内包するようになります。

そして現在 (v13以降) のApp RouterではReact Server Components (RSC) を前提としたアーキテクチャへと移行しました。

  • React Server Components (RSC): デフォルトでコンポーネントはサーバー側でレンダリングされ、クライアントにJavaScriptを送信しません。ブラウザでの実行が必要な場合のみ'use client'ディレクティブを付与します。
  • Server Actions: 従来のAPIエンドポイントを用意する方式とは異なり、フォームから直接サーバー側の関数を呼び出すことが可能になりました。
// フォームから直接DBを叩く関数を呼べる
async function create(formData: FormData) {
  'use server'

  await db.user.create({ name: formData.get('name') })
}

キャッシュ戦略の転換: v14の暗黙的マジックからv16の明示的制御へ

Next.jsにおける課題の一つとして、キャッシュの制御の難しさが挙げられていました。v14以前では、fetch リクエストはデフォルトで自動的にキャッシュされる(force-cache)仕様となっており、これが「データが更新されない」といったバグの温床になりやすいという問題がありました。バックエンドエンジニアの視点からも、どこでキャッシュが保持されているのかが不明瞭になりがちでした。

v15でfetchのデフォルト挙動がno-store (毎リクエスト再取得) に変更され、暗黙的なキャッシュによる混乱は一定の軽減を見せました。しかしルート設定との組み合わせは依然として複雑でした。

そこからv16で'use cache'ディレクティブを用いた明示的な制御へと回帰しています。これはRuby on Railsにおけるフラグメントキャッシュに近いアプローチであり、ページ全体ではなく関数やコンポーネント単位でキャッシュしたい箇所だけを明示的に指定する設計です。

v16で導入された3層のキャッシュディレクティブ

v16では以下の3つのディレクティブを使い分けることでデータの性質に応じた柔軟なキャッシュ戦略を実現しています。

1. 静的キャッシュ ('use cache')
全ユーザーで共有され、主にビルド時に生成される静的データ (商品情報、マスターデータなど) に用います。

async function getProduct(id) {
  'use cache'

  cacheTag(`product-${id}`)

  return db.products.find(id)
}

2. リモートキャッシュ ('use cache: remote')
全ユーザーで共有されますが、ランタイムで外部ストア (Redisなど) に保持される変動データ (価格情報、在庫など) に用います。cacheLifeを用いてTTL (有効期間) を設定します。

async function getPrice(id) {
  'use cache: remote'

  cacheTag(`price-${id}`)
  cacheLife({ expire: 300 }) // 5分保持

  return db.prices.get(id)
}

3. プライベートキャッシュ ('use cache: private')
ユーザー固有のデータ (推奨商品、個人設定など) に用います。このディレクティブ内でのみ cookies() の読み込みが許可されます。

async function getRecommendations(id) {
  'use cache: private'

  cacheLife({ expire: 60 })

  const sessionId = (await cookies()).get('session-id')?.value
  return db.recommendations.find({ productId: id, sessionId })
}

これらのディレクティブに加え、cacheTag()によるタグ付けとrevalidateTag()によるオンデマンドな無効化を組み合わせることで開発者が意図した通りのキャッシュライフサイクルを構築できるようになっています。

Turbopackによる開発体験の向上

開発ツールチェーンの面ではWebpackの後継として開発されたRust製のバンドラー「Turbopack」がv16でデフォルト有効化されました (v15でdev環境がstable化、v16でbuild環境がstable化)。

並列処理とキャッシュの最適化により、開発サーバーの起動時間やHMR (Hot Module Replacement) による更新反映が大幅に高速化されています。大規模なアプリケーション開発においてもコンパイルの待ち時間による思考の断絶が減り、快適な開発体験が提供されています。

インフラ非依存性(Vercelロックインの誤解)

Next.jsを採用するにあたり、「Vercelにロックインされるのではないか」という懸念を耳にすることがありますが、実際には標準的なNode.jsランタイムが動作する環境であればデプロイは可能です。

  • Docker Support: next.config.jsoutput: 'standalone'を設定することで依存関係を内包した極小のイメージを作成でき、AWS ECSやCloud Runなど任意のコンテナ環境で稼働させることができます。
  • Custom Cache Handlers: Next.js内部のキャッシュの保存先をデフォルトのファイルシステムからRedisやS3などの外部ストレージに変更するインターフェースが提供されています。これにより、Vercel以外のインフラ (ElastiCacheなど) を利用した場合でも、複数インスタンス間でのISR (Incremental Static Regeneration) の整合性を維持できます。
  • Custom Adapters: Cloudflare WorkersやAWS Lambda等の異なるランタイムへの変換レイヤーをオープン化する取り組み (RFC段階) も進められています。

まとめ

App Routerの導入によりバックエンドとフロントエンドの境界が再定義され、v16における'use cache'の導入によって、キャッシュはブラックボックスな魔法から「エンジニアが明確に制御可能な道具」へと洗練されました。

既存のインフラ資産 (DockerやAWS) を活かしながらこれらのアーキテクチャの恩恵を受けられる点は、Next.jsを技術選定のテーブルに乗せる十分な意義があると考えています。