Next.js 15 × React 19を本番投入して6ヶ月、キャッシュ設計で痛い目を見た話

「ドキュメント通りに動かない」を何度経験したことか。社内管理画面をNext.js 15 + React 19に移行して気づいた、キャッシュ・Server Actions・PPRの落とし穴をまとめました。

先日、社内の管理画面をNext.js 15 + React 19に全面移行したんですが、正直なめてました。ドキュメント通りに動かない箇所、キャッシュの挙動の変化、Server Actionsの落とし穴…6ヶ月運用してようやく「わかってきた感」が出てきたので、同じ苦しみを繰り返してほしくないと思って書いています。

基本的な新機能の紹介はReact 19・Next.js 15の新機能を実装例で完全解説に任せて、この記事では「本番で使って初めてわかった話」に絞ります。

キャッシュ設計が根本から変わっていた

Next.js 15でもっとも「やられた」のがキャッシュの仕様変更です。14まではデフォルトでfetch()の結果がキャッシュされていましたが、15からはデフォルトがno-storeになりました。これ、見落としてると本番で気づいたときのダメージが大きい。

// Next.js 14まで:デフォルトでキャッシュされてた
const data = await fetch('https://api.example.com/products');

// Next.js 15以降:明示的にキャッシュ指定が必要
const data = await fetch('https://api.example.com/products', {
  next: { revalidate: 60 }, // 60秒キャッシュ
});

// または完全に静的にしたいなら
const data = await fetch('https://api.example.com/products', {
  cache: 'force-cache',
});

うちのチームでは移行直後に、トップページのAPIコール数が約4倍になりました。本番APMのグラフを見て「なんかアクセス増えた?」と思ったらキャッシュが全部外れてたという…。アラートで気づけたのでマジで助かりましたが、SPIKEが来てたら危なかった。

キャッシュ戦略をチームで整理し直した結果、こういう方針に落ち着きました。

データ種別キャッシュ方針実装
商品マスタ・静的コンテンツ長期キャッシュrevalidate: 3600
ユーザー固有データキャッシュなしcache: 'no-store'
在庫・価格などリアルタイム系短期キャッシュrevalidate: 30
管理画面フォームキャッシュなしcache: 'no-store'

この方針を決めた上で、lib/fetch.tsにラッパーとして切り出して用途別の関数を用意しました。「このAPIどのキャッシュ戦略使ってたっけ?」という無駄な認知負荷がかなり減るので、地味に便利です。

// lib/fetch.ts
export async function fetchStatic<T>(url: string): Promise<T> {
  const res = await fetch(url, { cache: 'force-cache' });
  if (!res.ok) throw new Error(`Failed to fetch: ${url}`);
  return res.json();
}

export async function fetchRevalidate<T>(
  url: string,
  revalidate: number = 60
): Promise<T> {
  const res = await fetch(url, { next: { revalidate } });
  if (!res.ok) throw new Error(`Failed to fetch: ${url}`);
  return res.json();
}

export async function fetchDynamic<T>(url: string): Promise<T> {
  const res = await fetch(url, { cache: 'no-store' });
  if (!res.ok) throw new Error(`Failed to fetch: ${url}`);
  return res.json();
}

Server Actions、便利だけど罠がある

React 19のServer Actionsはフォーム処理の実装が本当に楽になりました。ただ、本番運用してみると「あ、これ考慮漏れてた」という箇所がいくつか出てきたんですよね。

一番ハマったのがServer Actionsの再実行問題です。useActionState(旧useFormState)を使う場合、エラー時の状態管理を正しくやらないとユーザーが気づかないまま二重送信されます。

// app/actions/order.ts
'use server'

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const OrderSchema = z.object({
  productId: z.string().min(1),
  quantity: z.number().min(1).max(100),
});

type ActionState = {
  success: boolean;
  error?: string;
  data?: unknown;
};

export async function createOrder(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // バリデーション
  const parsed = OrderSchema.safeParse({
    productId: formData.get('productId'),
    quantity: Number(formData.get('quantity')),
  });

  if (!parsed.success) {
    return {
      success: false,
      error: parsed.error.errors[0].message,
    };
  }

  try {
    // 冪等性キーを必ず設定する(これ大事)
    const idempotencyKey = formData.get('idempotencyKey') as string;
    
    await db.orders.create({
      data: {
        ...parsed.data,
        idempotencyKey,
      },
    });

    revalidatePath('/orders');
    return { success: true };
  } catch (error) {
    // DBのユニーク制約でキャッチ → 二重送信を防げる
    if (error instanceof PrismaClientKnownRequestError && 
        error.code === 'P2002') {
      return { success: true }; // 冪等な成功として返す
    }
    return {
      success: false,
      error: '注文の作成に失敗しました。もう一度お試しください。',
    };
  }
}
// components/OrderForm.tsx
'use client'

import { useActionState } from 'react';
import { createOrder } from '@/app/actions/order';
import { useId } from 'react';

export function OrderForm() {
  const idempotencyKey = useId(); // コンポーネントごとにユニークなキー
  const [state, action, isPending] = useActionState(createOrder, {
    success: false,
  });

  return (
    <form action={action}>
      <input type="hidden" name="idempotencyKey" value={idempotencyKey} />
      <input name="productId" required />
      <input name="quantity" type="number" min={1} max={100} required />
      
      {state.error && (
        <p role="alert" className="text-red-500">{state.error}</p>
      )}
      
      <button type="submit" disabled={isPending}>
        {isPending ? '処理中...' : '注文する'}
      </button>
    </form>
  );
}

useId()でコンポーネントレベルの冪等性キーを生成しているのがポイントで、これでページリロードなしの二重送信はほぼ防げます。完璧じゃないけど、実用上は十分かな。

もう一つ地味に困ったのが、Server Actionsにはボディサイズ1MBの制限があるという話。画像アップロードをServer Actions経由でやろうとしてハマりました。ファイルアップロードは素直にAPIルート(Route Handlers)に任せるのが正解です。

Partial Prerendering(PPR)を試してみた結果

PPRはNext.js 15で実験的機能から正式に昇格しました(2026年時点では安定版)。「静的部分は事前生成、動的部分は後からストリーム」というやつで、アーキテクチャをざっくり図にするとこんな感じです。

flowchart TD
    User[ユーザーリクエスト]
    CDN[CDN / Edge]
    Shell["静的Shell\n(即時配信)"]
    DynamicSlot1["Suspense境界\n(カート数量)"]
    DynamicSlot2["Suspense境界\n(レコメンド)"]
    Server[Next.js Server]
    DB[(DB / API)]

    User --> CDN
    CDN --> Shell
    Shell --> DynamicSlot1
    Shell --> DynamicSlot2
    DynamicSlot1 --> Server
    DynamicSlot2 --> Server
    Server --> DB
    DB --> Server
    Server --> DynamicSlot1
    Server --> DynamicSlot2

    style Shell fill:#e8f5e9,stroke:#4caf50
    style DynamicSlot1 fill:#fff3e0,stroke:#ff9800
    style DynamicSlot2 fill:#fff3e0,stroke:#ff9800

有効化はnext.config.tsに一行追加するだけです。

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};

export default nextConfig;

ページ側では、動的な部分をSuspenseで囲むだけです。

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { ProductDetail } from '@/components/ProductDetail';
import { CartCount } from '@/components/CartCount';
import { Recommendations } from '@/components/Recommendations';
import { ProductSkeleton } from '@/components/skeletons';

// この部分は静的生成される
export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div className="container">
      {/* ナビゲーション・フッターは静的 */}
      <header>
        <nav>...</nav>
        {/* カート数量は動的 */}
        <Suspense fallback={<span>...</span>}>
          <CartCount />
        </Suspense>
      </header>

      {/* 商品詳細(IDから決まるので静的プリレンダリング可能) */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetail productId={params.id} />
      </Suspense>

      {/* レコメンドは動的(ユーザー依存) */}
      <Suspense fallback={<div>Loading recommendations...</div>}>
        <Recommendations productId={params.id} />
      </Suspense>
    </div>
  );
}

導入後のCore Web Vitals、具体的にはLCPの変化を計測した結果がこちらです。

xychart-beta
    title "PPR導入前後のLCP比較(ms)"
    x-axis ["商品詳細", "カテゴリ", "検索結果", "トップページ"]
    y-axis "LCP (ms)" 0 --> 3000
    bar [2800, 2400, 3100, 1900]
    line [1200, 980, 1450, 720]

LCPが平均で約50〜55%改善しました。特に商品詳細ページは劇的で、静的Shellがエッジから即座に返るので体感速度が全然違う。これは正直予想以上でした。

ただし一点だけ注意が必要で、cookies()headers()を使うコンポーネントはPPRの静的部分に入れられません。設計をちゃんと考えないとPPRの恩恵が受けられなくなるので、認証周りは特に気をつけてください。

React Server Componentsの設計については以前の記事で詳しく書いたので、そちらも参考にしてもらえると。

テスト戦略:Server ComponentsとServer Actionsのテストどうするか問題

Server Componentsのテストは正直まだベストプラクティスが固まりきってない感じがします。うちのチームの現時点での方針を共有しておきます。

Server Actionsは通常の非同期関数なので、ユニットテストは素直に書けます。

// __tests__/actions/order.test.ts
import { createOrder } from '@/app/actions/order';
import { prismaMock } from '@/lib/prisma-mock';

describe('createOrder', () => {
  it('バリデーションエラーを返す', async () => {
    const formData = new FormData();
    formData.set('productId', ''); // 空文字でバリデーションエラー
    formData.set('quantity', '1');
    formData.set('idempotencyKey', 'test-key-1');

    const result = await createOrder({ success: false }, formData);

    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });

  it('正常に注文を作成する', async () => {
    prismaMock.orders.create.mockResolvedValueOnce({ id: '1' } as any);

    const formData = new FormData();
    formData.set('productId', 'prod-123');
    formData.set('quantity', '2');
    formData.set('idempotencyKey', 'test-key-2');

    const result = await createOrder({ success: false }, formData);

    expect(result.success).toBe(true);
    expect(prismaMock.orders.create).toHaveBeenCalledTimes(1);
  });

  it('冪等性キーの重複では成功を返す', async () => {
    const prismaError = new PrismaClientKnownRequestError(
      'Unique constraint failed',
      { code: 'P2002', clientVersion: '5.0.0' }
    );
    prismaMock.orders.create.mockRejectedValueOnce(prismaError);

    const formData = new FormData();
    formData.set('productId', 'prod-123');
    formData.set('quantity', '2');
    formData.set('idempotencyKey', 'duplicate-key');

    const result = await createOrder({ success: false }, formData);

    // 二重送信は成功として扱う
    expect(result.success).toBe(true);
  });
});

問題はServer Components自体のテストで、現時点では@testing-library/reactの実験的サポートを使うか、E2EはPlaywrightに任せるかという二択になってます。Playwright活用については以前詳しく書いたので参考に。

個人的には、Server Componentsのユニットテストより「重要なユーザーフローをPlaywrightでE2E保護する」方向にシフトする方が現実的だと今は思っています。Server Componentsはデータフェッチとレンダリングがセットになってるから、ユニットテストしようとするとモック地獄になりやすいんですよね。

flowchart LR
    subgraph "テスト戦略 2026"
        direction TB
        E2E["Playwright E2E\n重要ユーザーフロー"]
        Integration["Integration Test\nRoute Handlers / API"]
        Unit["Unit Test\nServer Actions / Utils"]
        
        E2E -->|カバレッジ優先度| Integration
        Integration -->|カバレッジ優先度| Unit
    end
    
    style E2E fill:#e3f2fd,stroke:#2196f3
    style Integration fill:#f3e5f5,stroke:#9c27b0
    style Unit fill:#e8f5e9,stroke:#4caf50

まだ検証中の部分もあるので、もっといいやり方を見つけたらまた書きます。

まとめ

6ヶ月運用して、Next.js 15 + React 19は「思ったより大きな変化がある」という感想です。要点をまとめると:

  1. キャッシュのデフォルト挙動が変わったfetch()のキャッシュはデフォルトno-store。明示的な設定を忘れずに。用途別にラッパー関数を用意すると管理しやすい
  2. Server Actionsは冪等性を必ず考慮:二重送信対策のidempotencyキー実装は必須。ファイルアップロードはRoute Handlersに任せる
  3. PPRは効果が大きい:LCP50%改善は実測値。ただしcookies()/headers()を使う箇所の設計に注意
  4. テスト戦略はまだ進化中:Server Actionsはユニットテスト可能、Server Componentsはプロダクション品質なPlaywright E2Eで補完するのが現実的
  5. 移行は一括より段階的に:キャッシュ設計の変更でAPIコール増大リスクがあるので、APMで監視しながら段階移行を強く推奨

まだNext.js 14を使っているなら、まずnext.config.tsoutput設定の確認と、主要なfetch()呼び出しにキャッシュ設定が明示されているかのチェックから始めてみてください。そこさえ押さえておけば移行時のサプライズがかなり減ります。

皆さんのチームではPPRの導入検討してますか?実際に導入した方がいれば、どのページから試したか聞いてみたいです。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事