Next.js App Router 本番環境:状態管理、キャッシュ、テストの課題
1. 進化の道筋:なぜ App Router が必要なのか
Pages Router の限界
App Router が登場する前、Next.js は Pages Router(pages/ ディレクトリ)をルーティングシステムとして採用していました。シンプルで直感的でしたが、アプリケーションの複雑さが増すにつれて、徐々に限界が明らかになりました:
- レイアウト構成の困難さ:ネストされたレイアウトのために HOC や手動ラッピングが必要
- 断片化されたデータ取得:
getServerSideProps、getStaticProps、getInitialPropsがコンポーネントロジックと分離 - 大きなクライアントバンドル:すべてのインタラクティブロジックをクライアントに送信する必要がある
- 高いハイドレーションコスト:静的コンテンツでもクライアント側の React ランタイム全体が必要
2022年、React 18 がサーバーコンポーネント(Server Components)を導入したことで、Next.js チームはルーティングシステムを再設計する機会を見出しました。こうして、App Router が誕生したのです。
5つのコアシフト
1. React サーバーコンポーネント(RSC)がデフォルト
これは最も根本的なパラダイムシフトを表します。App Router では:
// デフォルト:サーバーコンポーネント、サーバー上で実行
export default async function Page() {
const data = await db.query(); // データベースへの直接アクセス
return <div>{data}</div>;
}
// クライアントインタラクティブ性のための明示的なオプトイン
'use client'
export default function ClientPage() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}影響:
- 静的コンテンツでゼロクライアント JS が実現可能に
- サーバーコンポーネントがデータベースやファイルシステムに直接アクセス可能
- 開発者はサーバー/クライアントの境界を明確に理解する必要がある
2. レイアウトを持つファイルベースのネストルーティング
app/
├── layout.tsx # ルートレイアウト(全ページで共有)
├── page.tsx # ホームページ
├── dashboard/
│ ├── layout.tsx # Dashboard 専用レイアウト
│ ├── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /dashboard/settings影響:
- 自動レイアウトネスティング、組み込みの永続性(ルート変更時にレイアウトはアンマウントされない)
- よりクリーンなコード構成、特殊ファイル(
loading.tsx、error.tsx)の同一配置 - 状態管理の再考が必要(以下の課題を参照)
3. Suspense ファーストのストリーミングレンダリング
// ページは段階的にレンダリング可能、すべてのデータを待つ必要なし
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
);
}影響:
- TTFB の大幅な低下、知覚パフォーマンスの向上
- モニタリングメトリクスの調整が必要(TTFB の意味が薄れる)
4. Server Actions:API ルートなしのデータ変更
// コンポーネント内で直接サーバーロジックを定義
async function createPost(formData: FormData) {
'use server'
await db.post.create(...)
}
export default function Form() {
return <form action={createPost}>...</form>
}影響:
- ボイラープレートの削減、デフォルトで型安全
- テストがより複雑に(詳細は後述)
5. 新しいキャッシングアーキテクチャ
App Router は 4 層のキャッシングシステム(リクエストメモ化、データキャッシュ、フルルートキャッシュ、ルーターキャッシュ)を導入しました。これは強力な最適化ツールであると同時に、最大の混乱の原因でもあります。
現実チェック
2023年、Next.js 13.4 は App Router を正式に「安定版」とマークしました。しかし、開発者が実際のプロジェクトでこれらの機能を熱心に採用したとき、現実は期待と完全には一致しませんでした。コミュニティからの一般的な不満:
"なぜルートナビゲーション後に状態がリセットされないの?"
"本番環境でデータが古いまま、リフレッシュしても更新されない!"
"Server Actions をどうテストすればいい?Cypress も何をすべきかわからない!"
"結局 RSC を諦めて Route Handlers に戻った..."
これらは孤立した事例ではなく、頻繁で具体的、そして非常に現実的なものです。この記事は本番環境での経験に基づいて、これらの課題の根本原因を分析し、実行可能な解決策を提供します。
2. 課題 #1:状態管理の混乱
問題
2つのページ /page-a と /page-b があり、それぞれにクライアント側の状態(フォーム入力、スクロール位置など)があると想像してください。ユーザーが Page A から Page B に移動し、Page A に戻ったとき、Page A の状態がリセットされることを期待しますが、実際にはリセットされません。
さらに混乱させるのは:
- インターセプティングルート(Intercepting Routes)の状態が通常のルートと一貫性がない
- ログイン成功後、
redirect('/')が実行されても、ナビゲーションバーのログイン状態が手動リフレッシュなしで更新されない
根本原因
App Router のコア設計原則の1つは「レイアウトの永続性」です。同じ Layout 下のルート間を移動するとき、Layout コンポーネントはアンマウントと再マウントを行いません。これは UX とパフォーマンスの向上を目的とした「機能」ですが、状態の「粘着性」も引き起こします。
もう1つの一般的な原因は、サーバー/クライアントコンポーネントの境界が不明確であることです。開発者はサーバーコンポーネント内にクライアントコンポーネントをネストしながら、サーバーコンポーネントの出力がルート遷移中にキャッシュされて再利用される可能性があることに気づかないかもしれません。
解決策
1. 明確なコンポーネント境界の定義
状態を持つロジックをクライアントコンポーネントにカプセル化し、ルート変更時に適切にアンマウントされるようにします。
// 誤り:共有 Layout に状態を配置
// app/layout.tsx
'use client'
export default function Layout({ children }) {
const [count, setCount] = useState(0); // ルート変更時にリセットされない
return <div>{children}</div>;
}
// 正解:特定のページコンポーネントに状態を配置
// app/page-a/page.tsx
'use client'
export default function PageA() {
const [count, setCount] = useState(0); // PageA がナビゲート離脱時にアンマウント
return <div>Count: {count}</div>;
}2. key プロップで強制的に再マウント
ルート変更時にクライアントコンポーネントの状態をリセットする必要がある場合、key を変更して React に強制的に再マウントさせます:
// pathname を key として使用
import { usePathname } from 'next/navigation';
export default function Layout({ children }) {
const pathname = usePathname();
return <ClientComponent key={pathname} />;
}3. router.refresh() でサーバーデータを更新
問題が古いサーバー側データ(ログイン後のユーザー情報など)の場合、クライアントで router.refresh() を呼び出します:
import { useRouter } from 'next/navigation';
function LoginButton() {
const router = useRouter();
async function handleLogin() {
await loginAction();
router.refresh(); // サーバーコンポーネントを強制的にリフレッシュ
}
}3. 課題 #2:暴走するキャッシング
4層システム
Next.js のキャッシングは、開発者が最もつまずきやすい場所です。4層アーキテクチャを理解することが鍵です:
| メカニズム | 場所 | スコープ | 持続時間 |
|---|---|---|---|
| リクエストメモ化 | サーバー | 単一レンダー | レンダー完了まで |
| データキャッシュ | サーバー | クロスリクエスト/デプロイ | 永続的(再検証可能) |
| フルルートキャッシュ | サーバー | 静的ルート | 永続的(再検証可能) |
| ルーターキャッシュ | クライアント | ユーザーセッション | セッションまたは時間制限 |
- リクエストメモ化:単一レンダー中、同じ URL への重複 fetch リクエストは重複排除され、1つの実際のリクエストのみが送信されます。
- データキャッシュ:fetch の結果が永続的にキャッシュされ、ユーザーとリクエスト間で再利用されます。
- フルルートキャッシュ:静的にレンダリングされたページ(HTML と RSC Payload)がサーバー上でキャッシュされます。
- ルーターキャッシュ:クライアントが訪問したページの RSC Payload をメモリにキャッシュし、即座のナビゲーションを実現します。
よくある落とし穴
シナリオ 1:本番環境でデータが更新されない
// この時刻は開発環境では毎回のリフレッシュで更新される
// しかし本番環境では、ビルド時に凍結される!
export default async function Page() {
const time = new Date().toLocaleTimeString();
return <div>現在時刻: {time}</div>;
}なぜ? Next.js は、条件を満たすすべてのページをビルド時に静的に事前レンダリングしようとします。動的関数(cookies()、headers())や明示的な動的設定がない場合、ページは静的に生成されます。
シナリオ 2:クライアントナビゲーションで古いデータが表示される
ユーザーが Page A でデータを編集し、Page B に移動してから Page A に戻る—Page A はまだ古いデータを表示します!
なぜ? ルーターキャッシュがクライアントで Page A の RSC Payload をキャッシュし、サーバーから再取得せずに直接使用します。
シナリオ 3:Next.js 15 vs 14 の動作の違い
Next.js 15 はデフォルトの fetch キャッシング戦略を変更しました:
- 14 以前:デフォルト
cache: 'force-cache' - 15 以降:デフォルト
cache: 'no-store'
Next.js 15 へのアップグレードにより、以前キャッシュされていたページが動的レンダリングになり、パフォーマンスが低下する可能性があります。
ベストプラクティス
1. 明示的なキャッシュ設定
デフォルトに依存せず、キャッシングの意図を明示的に宣言します:
// 静的データ、1時間キャッシュ
fetch('https://api.example.com/static-data', {
next: { revalidate: 3600 }
});
// リアルタイムデータ、キャッシュなし
fetch('https://api.example.com/realtime-data', {
cache: 'no-store'
});2. revalidatePath/revalidateTag で積極的にキャッシュを無効化
データが変更されたとき(フォーム送信など)、積極的にキャッシュを無効化します:
// Server Action 内で
import { revalidatePath, revalidateTag } from 'next/cache'
async function updatePost(formData) {
'use server'
await db.post.update(...)
revalidatePath('/posts') // このパスを無効化
revalidateTag('posts') // このタグを持つすべてのキャッシュを無効化
}3. ルートセグメント設定
ページレベルで動的動作を宣言します:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // 動的レンダリングを強制
export const revalidate = 60; // 60秒ごとに再検証4. ルーターキャッシュの管理
Next.js はルーターキャッシュをクリアする直接的な API を提供していませんが、以下のことができます:
router.refresh()を使用して現在のルートデータを再取得- サーバーアクションで
revalidatePath()を呼び出す—これはサーバーとクライアントの両方のキャッシュに影響します
4. 課題 #3:Server Actions のテスト
課題
Server Actions を使用すると、別の API ルートを作成せずに React コンポーネント内で直接サーバーロジックを定義できます:
// app/actions.ts
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name');
await db.user.create({ data: { name } });
revalidatePath('/users');
}これによりコード構造が簡素化され、API ボイラープレートが削減されます。しかし、テストの課題を導入します:
- 純粋なフロントエンド環境でシミュレートできない:Server Actions はサーバー側で実行する必要があり、Jest で通常の関数のようにテストできません
- 従来の API テストワークフローが破綻:REST API のように Postman や curl でデバッグできません
- 依存関係のモックが困難:データベース接続やサードパーティサービスをテスト環境で分離することが難しい
テスト戦略
戦略 1:レイヤードアーキテクチャ—ビジネスロジックの抽出
ビジネスロジックを Server Actions から分離し、独立してテスト可能にします:
// lib/user-service.ts(純粋なビジネスロジック、テスト可能)
export async function createUserLogic(name: string) {
// バリデーションロジック
if (!name || name.length < 2) {
throw new Error('名前が短すぎます');
}
return await db.user.create({ data: { name } });
}
// app/actions.ts(薄いラッパー)
'use server'
import { createUserLogic } from '@/lib/user-service';
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
await createUserLogic(name);
revalidatePath('/users');
}
// __tests__/user-service.test.ts
import { createUserLogic } from '@/lib/user-service';
// データベースをモック
jest.mock('@/lib/db', () => ({
user: { create: jest.fn() }
}));
test('createUserLogic は名前の長さを検証する', async () => {
await expect(createUserLogic('a')).rejects.toThrow('名前が短すぎます');
});戦略 2:完全なフローのための E2E テスト
完全な Server Action フローのテストが必要なシナリオでは、Playwright または Cypress を使用します:
// e2e/user.spec.ts(Playwright)
import { test, expect } from '@playwright/test';
test('ユーザーは Server Action を介してアカウントを作成できる', async ({ page }) => {
await page.goto('/signup');
await page.fill('input[name="name"]', 'John Doe');
await page.click('button[type="submit"]');
// 結果を検証
await expect(page.locator('.success-message')).toBeVisible();
await expect(page).toHaveURL('/users');
});戦略 3:Server Actions の統合テスト
Node.js で Server Actions 自体を本当にテストする必要がある場合:
- テスト用の Next.js サーバーを起動
- fetch を使用してフォーム送信をシミュレート
- またはテストで直接インポートして呼び出す(適切な Node 環境セットアップが必要)
// Server Actions をサポートする環境で実行する必要がある
import { createUser } from '@/app/actions';
test('createUser はデータベースに保存する', async () => {
const formData = new FormData();
formData.set('name', 'Test User');
await createUser(formData);
const user = await db.user.findFirst({ where: { name: 'Test User' } });
expect(user).toBeDefined();
});5. RSC と Route Handlers の調整
いつどちらを使うか?
| 機能 | Server Actions | Route Handlers |
|---|---|---|
| ユースケース | フォーム送信、変更 | 外部 API、webhook、コンポーネント間の再利用 |
| コード構成 | コンポーネントと密結合 | 独立した API エンドポイント |
| 型安全性 | デフォルトで型安全 | 手動での型定義 |
| テストの難易度 | 高い | 低い(従来の API のよう) |
| キャッシュ制御 | 限定的 | 完全な制御 |
| 外部呼び出し | サポートなし | サポートあり |
実用的なガイドライン:
- フォーム送信、データ変更 → Server Actions を優先
- 外部システム用の API → Route Handlers を使用
- 複雑なクエリ、きめ細かいキャッシュ制御 → Route Handlers を使用
- Webhook、ファイルアップロード → Route Handlers を使用
統一データレイヤー
プロジェクトが成長するにつれて、データアクセスロジックが散在します:サーバーコンポーネントがデータベースに直接クエリし、Server Actions も同様に、Route Handlers も...
解決策:統一データレイヤー
// lib/data/users.ts
export async function getUsers() {
return await db.user.findMany();
}
export async function getUserById(id: string) {
return await db.user.findUnique({ where: { id } });
}
export async function createUser(data: CreateUserInput) {
return await db.user.create({ data });
}サーバーコンポーネント、Server Actions、Route Handlers のいずれでも、この統一レイヤーを通じてデータにアクセスします:
- ビジネスロジックの一元化
- キャッシング、ログ、認可などの横断的関心事の追加が容易
- テストが容易
6. 本番環境の可観測性
ストリーミングレンダリングの監視
App Router はデフォルトでストリーミングレンダリングを使用し、知覚パフォーマンスを向上させますが、監視を複雑にします:
- TTFB はもはや完全なページ読み込み時間を正確に反映しない
- 従来の APM ツールはストリーミングレスポンスを正しく追跡できない可能性がある
推奨事項:
- Web Vitals をサポートする監視ツールを使用(EdgeOne Pages Analytics、Datadog RUM)
- TTFB ではなく LCP(Largest Contentful Paint)に注目
- 重要なコンポーネントにカスタムパフォーマンスマークを追加
キャッシュの可視性
アプリケーションのキャッシング動作を理解することは、パフォーマンス最適化に不可欠です:
- Vercel デプロイメント:レスポンスヘッダー
x-vercel-cache: HIT/MISSを確認 - EdgeOne Pages デプロイメント:レスポンスヘッダー
Eo-Cache-Status: Cache HIT/MISSを確認 - セルフホスト:ミドルウェアまたは Route Handlers でキャッシュログの追加を検討
- 開発デバッグ:ブラウザ DevTools のネットワークパネルを使用してキャッシュヒットを観察
// シンプルなキャッシュログミドルウェア
export function middleware(request: NextRequest) {
const response = NextResponse.next();
console.log(`[Cache] ${request.url} - ${response.headers.get('x-cache') || 'MISS'}`);
return response;
}7. 結論
App Router と React サーバーコンポーネントは、React エコシステムの将来の方向性を表しています。現在の課題にもかかわらず、これらの問題は Next.js の反復とエコシステムの成熟とともに徐々に改善されています。
重要なポイント:
- 完全な移行を急がない:新しいプロジェクトで App Router を試す;既存のプロジェクトは Pages Router を維持することは完全に問題ありません
- 基礎を理解する:多くの「バグ」は新しいメンタルモデルの理解不足から生じます
- 公式アップデートをフォロー:Next.js チームは積極的に DX を改善しており、各バージョンには注目すべき変更があります
- コミュニティに参加:GitHub Issues、Twitter、Discord は情報とフィードバックのための優れたチャネルです
技術の進化には常に成長痛が伴います。鍵は、理想主義と実用主義のバランスを見つけることです。この記事が Next.js App Router の道のりでつまずきを減らすのに役立つことを願っています。
リソース: