Skip to content
Published on

Next.js 16 アーキテクチャ — Turbopack と Cache Components を深く見る

Authors

はじめに

Next.js は単なる React フレームワークを超え、レンダリング戦略とキャッシュ階層、さらにはビルドツールまでを一つにまとめたフルスタックプラットフォームへと進化しました。Next.js 16 に至り、二つの大きな変化が定着しました。一つは Rust で書かれたバンドラー Turbopack が既定かつ安定段階に入ったことであり、もう一つは部分プリレンダリング(PPR)と use cache を組み合わせた Cache Components モデルです。

本記事は「どの API を呼ぶか」よりも「フレームワークの中でリクエストがどう流れ、何がどこでレンダリングされ、結果がどの階層にキャッシュされるか」を図で解きほぐすことに集中します。バージョンごとに細かな挙動が変わり得るため、正確なフラグと既定値は常に公式ドキュメント(nextjs.org)で確認することをおすすめします。


1. App Router の全体構造

App Router はファイルシステムベースのルーティングをもう一段押し進め、ディレクトリ自体がルートセグメントになり、特殊ファイルが各セグメントの役割を定義します。

app/
├── layout.tsx          ← ルートレイアウト(全ページ共通、サーバーコンポーネント)
├── page.tsx            ← "/" ルートの入口
├── loading.tsx         ← Suspense 境界のフォールバック UI
├── error.tsx           ← エラー境界(クライアントコンポーネント)
├── not-found.tsx       ← 404 処理
├── (marketing)/        ← ルートグループ(URL に影響なし)
│   └── about/
│       └── page.tsx    ← "/about"
└── dashboard/
    ├── layout.tsx      ← ネストレイアウト(dashboard 配下で共通)
    ├── page.tsx        ← "/dashboard"
    └── [id]/
        └── page.tsx    ← "/dashboard/:id" 動的セグメント

各セグメントの特殊ファイルはレンダリングツリーの決まった位置に合成されます。レイアウトはネストし状態を保持し、その間に Suspense とエラー境界が自動的に挿入されます。

  ┌──────────────────────────────────────────────┐
  │                RootLayout                      │
  │  ┌──────────────────────────────────────────┐ │
  │  │            DashboardLayout                 │ │
  │  │  ┌────────────────┐  ┌──────────────────┐ │ │
  │  │  │  <ErrorBoundary>│  │ <Suspense>       │ │ │
  │  │  │   error.tsx    │  │   loading.tsx    │ │ │
  │  │  │      ▼          │  │      ▼            │ │ │
  │  │  │   page.tsx ────────────▶ レンダー結果   │ │ │
  │  │  └────────────────┘  └──────────────────┘ │ │
  │  └──────────────────────────────────────────┘ │
  └──────────────────────────────────────────────┘

レイアウトは子が変わっても再マウントされません。これがページ遷移時にサイドバーやヘッダーの状態を保持する鍵です。


2. サーバーコンポーネントとクライアントコンポーネントの境界

App Router の最も根本的な概念は **React Server Components(RSC)**です。既定ではすべてのコンポーネントがサーバーコンポーネントであり、インタラクションが必要な箇所でのみ "use client" で境界を引きます。

サーバーコンポーネント(既定)       クライアントコンポーネント("use client")
────────────────────────           ──────────────────────────────
DB/ファイル/秘密への直接アクセス O   ブラウザ API / イベントハンドラ O
async/await データ取得          O   useState / useEffect フック     O
バンドルに JS を含む            X   バンドルに JS を含む            O
イベントハンドラ                X   サーバーの秘密へのアクセス       X

境界の核心ルールは「いったんクライアントなら、その下はすべてクライアント」です。ただし、サーバーコンポーネントをクライアントコンポーネントの children/props として差し込むパターンで、この境界を回避できます。

// server-page.tsx (サーバーコンポーネント)
import ClientShell from './client-shell'
import ServerWidget from './server-widget'

export default async function Page() {
  const data = await fetchData() // サーバーでのみ実行
  return (
    <ClientShell>
      {/* ServerWidget はサーバーでレンダーされ RSC ペイロードとして渡される */}
      <ServerWidget data={data} />
    </ClientShell>
  )
}
// client-shell.tsx
'use client'
import { useState } from 'react'

export default function ClientShell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false)
  // children(= ServerWidget)はすでに直列化された RSC ツリーで、再レンダーされない
  return <div onClick={() => setOpen((v) => !v)}>{open && children}</div>
}

この合成パターンは「インタラクションの殻はクライアント、重いデータレンダリングはサーバー」という理想的な分離を可能にします。

直列化の境界

サーバーからクライアントへ props を渡すときは、直列化可能な値のみが越えられます。関数、クラスインスタンス、Date を超える複雑なオブジェクトは境界を越えられません。サーバーアクション("use server")だけが、関数を境界の向こうから参照可能にする例外です。

[サーバーコンポーネント] ──直列化可能な props──▶ [クライアントコンポーネント]
       │                                              ▲
       └──── "use server" アクション参照 ─────────────┘
              (関数そのものではなく参照 ID を渡す)

3. レンダリングモデル: SSR / SSG / ISR / PPR

Next.js は一つのコードベースの中で複数のレンダリング戦略を混在させられます。要点は「このコンテンツは静的か動的か、そしていつ作り直されるか」です。

┌─────────┬──────────────────────┬───────────────────┬───────────────┐
│ 戦略    │ 生成タイミング        │ キャッシュ         │ 適する場合     │
├─────────┼──────────────────────┼───────────────────┼───────────────┤
│ SSG     │ ビルド時              │ CDN 恒久          │ ブログ/文書    │
│ ISR     │ ビルド + 定期再生成   │ CDN + revalidate  │ 商品/ニュース  │
│ SSR     │ 毎リクエスト          │ なし(既定)       │ 個別化/リアル  │
│ PPR     │ 静的な殻 + 動的な穴   │ 殻は CDN、穴は     │ 混在ページ     │
│         │                       │ リクエスト時ストリーム │            │
└─────────┴──────────────────────┴───────────────────┴───────────────┘

PPR(部分プリレンダリング)の直観

従来、一つのページは「すべて静的」か「すべて動的」のどちらかである必要がありました。PPR はこの二分法を崩します。静的な**殻(shell)**をビルド時に作って即座に返し、動的な部分だけを Suspense 境界で示してリクエスト時にストリーミングで埋めます。

リクエスト ──▶ [静的な殻を即送信(CDN キャッシュ)]
                ┌────────────────────────────────┐
                │ ヘッダー/ナビ/レイアウト(静的)  │  ◀── TTFB 非常に速い
                │  ┌──────────────────────────┐  │
                │  │ <Suspense>               │  │
                │  │   動的領域(リクエスト時) │  │  ◀── ストリーミングで
                │  │   例: ユーザーカート、推薦 │  │      続いて到着
                │  └──────────────────────────┘  │
                │ フッター(静的)                │
                └────────────────────────────────┘

ユーザーは静的な殻をほぼ即座に見て、動的な穴は準備でき次第埋まります。静的と動的の利点を一つのページで同時に享受する形です。


4. Cache Components — PPR と use cache の結合

Next.js 16 のキャッシュモデルは明示性を強調する方向で整理されました。過去の暗黙的キャッシュが混乱を招いたため、何をキャッシュするかを開発者が明示的に宣言する方式へ重心が移りました。その中心に use cache ディレクティブがあります。

// 関数単位のキャッシュ
async function getProducts(category: string) {
  'use cache'
  const res = await fetch(`https://api.example.com/products/${category}`)
  return res.json()
}
// コンポーネント単位のキャッシュ + 再検証タグ
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

async function ProductList({ category }: { category: string }) {
  'use cache'
  cacheLife('hours')        // キャッシュ寿命プロファイル
  cacheTag(`cat-${category}`) // タグベースの無効化
  const products = await getProducts(category)
  return <ul>{/* ... */}</ul>
}

use cache で包んだ領域はキャッシュ可能な静的領域として扱われ、包まずに動的データ(クッキー、ヘッダー、searchParams など)へアクセスする領域は自然に PPR の「動的な穴」になります。つまり Cache Components は PPR の静的/動的境界を use cache 宣言で決めるモデルです。

                Cache Components の判定フロー
  ┌───────────────────────────────────────────────────┐
  │ コンポーネント/関数に 'use cache' があるか?        │
  └───────────────┬───────────────────┬───────────────┘
            はい(あり)           いいえ(なし)
                 ▼                     ▼
        ┌────────────────┐    ┌────────────────────┐
        │ 静的な殻に含む  │    │ 動的データに         │
        │ CDN/ビルドキャッシュ │    │ アクセスする?       │
        └────────────────┘    │ (cookies/headers) │
                              └─────┬──────────┬────┘
                            はい ▼         いいえ ▼
                        ┌──────────────┐  ┌──────────────┐
                        │ 動的な穴      │  │ 静的に処理可能 │
                        │ リクエスト時   │  │               │
                        │ ストリーム     │  │               │
                        └──────────────┘  └──────────────┘

キャッシュ寿命とタグ無効化

cacheLife は「どれだけ長く」、cacheTag + revalidateTag は「いつ壊すか」を担当します。

import { revalidateTag } from 'next/cache'

export async function updateProduct(category: string) {
  'use server'
  await db.update(/* ... */)
  revalidateTag(`cat-${category}`) // そのタグが付いたキャッシュのみ無効化
}

5. リクエストのライフサイクル図

一つのリクエストが入ってレスポンスとして出るまでの全体の流れをまとめると次のようになります。

   ブラウザ
     │  GET /dashboard/42
 ┌─────────────────────────────────────────────────────────┐
 │                     Edge / CDN 階層                       │
 │  静的な殻のキャッシュ命中? ──はい──▶ 即殻を返す(ストリーム)│
 │         │ いいえ                                          │
 └─────────┼───────────────────────────────────────────────┘
 ┌─────────────────────────────────────────────────────────┐
 │                  Next.js サーバーランタイム               │
 │  1) ルートマッチング(app/dashboard/[id]/page.tsx)      │
 │  2) middleware 実行(認証/リダイレクト)                  │
 │  3) レイアウト → ページ RSC レンダー開始                  │
 │     ├─ 'use cache' 領域 → データキャッシュ照会            │
 │     └─ 動的領域 → データ取得(fetch/DB)                  │
 │  4) RSC ペイロード生成 + HTML ストリーミング              │
 └─────────┬───────────────────────────────────────────────┘
 ┌─────────────────────────────────────────────────────────┐
 │                  データ / キャッシュ階層                  │
 │  Data Cache(fetch 結果)· Full Route Cache(RSC)        │
 │  外部 DB / API / ファイルシステム                         │
 └─────────────────────────────────────────────────────────┘
   ブラウザ: HTML ストリーム受信 → ハイドレーション → 操作可能

6. キャッシュ階層の整理

Next.js には性格の異なる複数のキャッシュが層をなして積み重なっています。どの階層がどの単位をキャッシュするかを区別することがデバッグの出発点です。

┌────────────────────────────────────────────────────────────┐
│  1. Router Cache(クライアントメモリ)                       │
│     - 訪問したルートの RSC ペイロードをブラウザに保持        │
│     - 戻る/prefetch 時に即レンダー                          │
├────────────────────────────────────────────────────────────┤
│  2. Full Route Cache(サーバービルド/ランタイム)            │
│     - 静的ルートの RSC + HTML 結果                           │
├────────────────────────────────────────────────────────────┤
│  3. Data Cache(サーバー、永続)                             │
│     - fetch 結果 / 'use cache' 関数の結果                    │
│     - cacheLife / revalidateTag で制御                       │
├────────────────────────────────────────────────────────────┤
│  4. Request Memoization(単一リクエスト寿命)                │
│     - 同一リクエスト内の同一 fetch を重複排除                │
└────────────────────────────────────────────────────────────┘
キャッシュ場所寿命無効化方法
Router Cacheクライアントセッション/時間router.refresh、ナビゲーション
Full Route Cacheサーバーデプロイまで再デプロイ、revalidate
Data Cacheサーバー永続revalidateTag、revalidatePath
Request Memoサーバーリクエスト1回自動

7. Turbopack — Rust ベースの既定バンドラー

Next.js 16 では Turbopack が開発サーバーとビルドの既定バンドラーになりました。webpack 時代と比べ、開発サーバーの起動が約 4 倍、更新(レンダー)反映が約 50% 速いと公式に案内されてきました。正確な数値はプロジェクト規模とバージョンによって変わり得ます。

        webpack(従来)              Turbopack(Rust)
  ──────────────────────────    ──────────────────────────
  JS ベース、ほぼ単一スレッド    Rust マルチコア並列処理
  グラフ全体を再計算しがち       関数単位の増分キャッシュ
  大きなアプリほど起動が遅い     要求したルートのみオンデマンドコンパイル
  HMR は漸進的                  変更分のみ最小再計算

Turbopack の核心はリクエストベースの増分コンパイルきめ細かなキャッシュです。アプリ全体を前もって全部ビルドせず、実際にアクセスしたルートとモジュールのみコンパイルし、変更が生じたら影響を受けた部分のみ再計算します。

   ファイル変更(button.tsx)
   ┌──────────────────────────────┐
   │ 依存グラフで影響を追跡         │
   │ button.tsx → toolbar.tsx →    │
   │             page.tsx          │
   └──────────────┬───────────────┘
   影響を受けたノードのみ無効化して再計算
   (グラフの残りはキャッシュのまま)
   HMR パッチをブラウザへ送信

8. 移行と落とし穴

App Router と Cache Components へ移るときによくぶつかる点を整理します。

8.1 非同期のデータ API

リクエスト単位のデータアクセス API(cookiesheadersparamssearchParams など)が非同期で扱われる方向に整理されました。同期でアクセスしていたコードは await を付ける必要があります。

// 変更後のパターン
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  return <div>{id}</div>
}

8.2 明示的なキャッシュへの移行

過去には fetch が既定でキャッシュされるなど暗黙的な挙動がありました。新モデルでは、キャッシュしたいなら use cache で明示する考え方に切り替えるのが安全です。「キャッシュされるだろう」という仮定の代わりに「キャッシュすると宣言したか」を確認してください。

8.3 よくある落とし穴チェックリスト

[ ] クライアントコンポーネントにサーバー専用モジュール(fs, db)を import していないか
[ ] サーバー→クライアントの props がすべて直列化可能か(関数/クラスを除く)
[ ] 'use client' 境界を上に置きすぎてバンドルが肥大化していないか
[ ] 動的データ(cookies/headers)アクセス領域を Suspense で包んだか
[ ] revalidateTag のタグ文字列が cacheTag と正確に一致するか
[ ] 環境変数の NEXT_PUBLIC_ 接頭辞の有無を混同していないか

8.4 境界の位置が性能を左右する

"use client" をツリー上部(レイアウト)に置くと、その下のすべてがクライアントバンドルに引き込まれます。インタラクションが必要な葉ノードに近く境界を下ろすことが、バンドルサイズと初期ロードに決定的です。

悪い例                          良い例
─────────────                   ─────────────
RootLayout("use client")      RootLayout(server)
   └─ すべてクライアントバンドル     └─ Page(server)
                                      └─ LikeButton("use client")
                                         ← インタラクションの葉のみクライアント

9. 実務適用シナリオ

典型的なダッシュボードページを Cache Components の観点で設計してみます。

/dashboard/[id]
 ├─ Layout(静的、'use cache')           ← ナビ/サイドバー
 ├─ ProfileHeader(動的: cookies 使用)    ← ユーザー別、Suspense の穴
 ├─ StatsPanel('use cache'、cacheLife)   ← 5分キャッシュ、共有統計
 └─ ActivityFeed(動的: リアルタイム)      ← ストリーミング、Suspense の穴

こうすればナビと共有統計は CDN/キャッシュから即座に降りてきて、ユーザー別・リアルタイム領域のみリクエスト時に埋まります。一つのページが静的の速さと動的の新鮮さを同時に持つ構造です。


おわりに

Next.js 16 のアーキテクチャは「明示性」と「増分性」という二つのキーワードで要約できます。Cache Components はキャッシュを暗黙から明示へ引き上げ予測可能にし、Turbopack はビルドを全体再計算から増分計算へ変えて開発体験を高めました。PPR は静的と動的の長年の二分法を一つのページの中で溶かしました。

要点はツールではなく考え方です。「この領域は静的か動的か」「キャッシュすると宣言したか」「境界を葉に近く置いたか」を習慣のように問えば、フレームワークの抽象がむしろ明快な設計言語になってくれます。細かな挙動と既定値はバージョンによって変わり得るため、実際の適用時には公式ドキュメントを併せて確認することをおすすめします。


参考資料