Next.js 15 App RouterのStreaming UIで痛い目を見た話——Suspense粒度とキャッシュ設計の実務知見

Suspense入れまくったらUIがガクガクに…同じ経験ありませんか?管理ダッシュボードの本番リライトで気づいたStreaming UI設計の落とし穴と、PPR活用まで実装コード付きで共有します。

Suspenseの粒度を間違えると、体感速度がむしろ悪化する

先日、社内の管理ダッシュボードをNext.js 15のApp Routerベースでフルリライトしたんだけど、Streaming UIの設計でかなり痛い目を見た話を書いておく。

きっかけはシンプルで「ページ全体がローディングしてから表示されるのが遅く見える」という不満。Streaming UIを使えば、データが揃った部分から順番に表示できるじゃないか——と思ってSuspenseを入れまくったら、むしろUIがガクガク・カクカクになってUXが悪化した。

同じ経験した人いないですかね。Suspenseの境界をコンポーネント単位でバラバラに置くと、小さい要素が次々とポップインしてきて、ユーザーには「壊れたサイト」に見えるんですよね。正直、最初は「なぜ悪化してるんだ」と頭を抱えた。

結論から言うと、Suspenseの粒度は「意味のあるUI単位」で設計するのが正解だった。

// ❌ 悪い例:小さすぎるSuspense境界
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<Skeleton />}>
        <UserName />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <UserAvatar />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <UserStats />
      </Suspense>
    </div>
  );
}

// ✅ 良い例:意味のある単位でグループ化
export default function Dashboard() {
  return (
    <div>
      {/* ユーザー情報はまとめて1つのSuspense境界 */}
      <Suspense fallback={<UserSectionSkeleton />}>
        <UserSection />
      </Suspense>
      {/* 独立して遅いデータは別境界 */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <AnalyticsSection />
      </Suspense>
    </div>
  );
}

UserNameUserAvatarUserStatsは同じAPIから取得するデータを使ってる。それをバラバラにSuspenseで囲っても意味がない。むしろ3回ポップインが発生してガタつく。「細かく囲えば細かく表示できる」という直感がそもそも間違いだった。

PPR(Partial Prerendering)を本番投入して3ヶ月でわかったこと

Next.js 15でGA(安定版)になったPPR(Partial Prerendering)、うちのチームでも本格導入してみた。正直最初は懐疑的だったんだけど、使ってみると設計の考え方がガラッと変わった。

PPRの基本的な考え方はシンプルで、「静的な部分は事前レンダリング、動的な部分だけストリーミング」。まずnext.config.tsの設定から。

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

const nextConfig: NextConfig = {
  experimental: {
    ppr: true, // Next.js 15.1以降はデフォルトで'incremental'も選べる
  },
};

export default nextConfig;

ページコンポーネントでも個別に有効化できる。

// app/dashboard/page.tsx
export const experimental_ppr = true;

import { Suspense } from 'react';
import { StaticHeader } from './components/StaticHeader';
import { DynamicFeed } from './components/DynamicFeed';
import { DynamicFeedSkeleton } from './components/DynamicFeedSkeleton';

export default function DashboardPage() {
  return (
    <main>
      {/* ここはビルド時にHTMLとして生成される */}
      <StaticHeader />
      
      {/* ここだけリクエスト時にストリーミング */}
      <Suspense fallback={<DynamicFeedSkeleton />}>
        <DynamicFeed />
      </Suspense>
    </main>
  );
}

PPRで一番ハマったのは「何を静的にして何を動的にするか」の判断。cookies()headers()を呼んだ瞬間にそのコンポーネントは動的になる。うっかり静的にしたかったコンポーネントで認証トークンを参照してたりすると、全体が動的レンダリングになってPPRの恩恵がなくなる。これ、気づくまでにけっこう時間かかった。

ビルド時にnext buildで確認できるルートの分類ログを読む習慣をつけたほうがいい。

$ next build
...
Route (app)                              Size     First Load JS
 /                                   5.12 kB        102 kB
 /dashboard                          3.45 kB        112 kB PPR
 ƒ /api/analytics                      0 B            102 kB
 /about                              2.1 kB         104 kB

  (Static)   prerendered as static content
  (Partial)  prerendered as static HTML with dynamic server-rendered content  
ƒ  (Dynamic)  server-rendered on demand

がついてるとPPRが有効になっている証拠。最初これを見たとき、地味に感動した。ビルドログをちゃんと読む人間になれてよかった。

キャッシュ設計、「デフォルトでキャッシュされない」に慣れるまでが大変

Next.js 15になってfetch APIのデフォルト動作が変わった。以前(Next.js 13〜14時代)はfetchはデフォルトでキャッシュされてたけど、15からはデフォルトでキャッシュなしcache: 'no-store'相当)になった。

Next.js 15 × React 19を本番投入して6ヶ月、キャッシュ設計で痛い目を見た話でも詳しく書いてるけど、これで同じAPIに何度もリクエストが飛んで、コストとレイテンシが地味に爆発した。「あれ、なんか請求増えてない?」って気づいたときには遅かった。

2026年時点での正しいキャッシュ設計はこんな感じ:

// app/lib/api.ts

// 1. 長期間キャッシュしていいデータ(商品カテゴリ、設定情報など)
export async function getCategories() {
  const res = await fetch('https://api.example.com/categories', {
    next: { revalidate: 3600 }, // 1時間キャッシュ
  });
  return res.json();
}

// 2. 頻繁に変わるデータ(ユーザーのフィードなど)
export async function getUserFeed(userId: string) {
  const res = await fetch(`https://api.example.com/feed/${userId}`, {
    next: { revalidate: 60 }, // 1分キャッシュ
    // またはタグベース無効化
    next: { tags: [`feed-${userId}`] },
  });
  return res.json();
}

// 3. リアルタイムデータ(株価、チャットなど)
export async function getLiveData() {
  const res = await fetch('https://api.example.com/live', {
    cache: 'no-store',
  });
  return res.json();
}

タグベースの無効化(revalidateTag)は特に便利で、CMS連携やWebhook受信時にピンポイントでキャッシュを破棄できる。

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

export async function POST(request: NextRequest) {
  const { tag, secret } = await request.json();
  
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }
  
  revalidateTag(tag);
  return Response.json({ revalidated: true, tag });
}

CMSのコンテンツが更新されたらWebhookでこのエンドポイントを叩いて、関連するタグのキャッシュだけを無効化する。ページ全体のISR再生成じゃなくてコンポーネント単位でコントロールできるのが地味に便利で、個人的にはこれが今のNext.jsキャッシュ設計のいちばん好きな部分だったりする。

パフォーマンス計測:Streaming前後の数値

実際にStreaming UIを導入してどう変わったか、Lighthouseとリアルユーザー計測(Web Vitals)の数値を比較してみた。

xychart-beta
  title "Streaming UI導入前後のCore Web Vitals比較"
  x-axis ["LCP (ms)", "FCP (ms)", "TTI (ms)", "TBT (ms)"]
  y-axis "スコア" 0 --> 4000
  bar [3200, 2800, 3900, 450]
  bar [1100, 900, 2100, 120]

青: 導入前、赤: 導入後(実測値、管理ダッシュボード)

指標導入前導入後改善率
LCP3,200ms1,100ms65%改善
FCP2,800ms900ms67%改善
TTI3,900ms2,100ms46%改善
TBT450ms120ms73%改善

LCPが65%改善ってのはかなりインパクトあった。ただし、これは「適切なSuspense境界設計」と「PPRの組み合わせ」があってこその数値で、最初の失敗パターン(Suspenseを細かく置きすぎた状態)だと、LCPはむしろ悪化してた。「Streaming入れたのに遅くなった」という地獄の時期がちゃんとあった。

Streaming UIの全体的なデータフロー設計はこういう構造になっている:

sequenceDiagram
  participant Browser as ブラウザ
  participant Next as Next.js Server
  participant DB as データソース

  Browser->>Next: ページリクエスト
  Next-->>Browser: 静的HTML即時返却(PPR)
  Note over Browser: FCP発生(ユーザーが何かを見れる)
  
  par 並列データフェッチ
    Next->>DB: 高速API呼び出し
    Next->>DB: 低速API呼び出し
  end
  
  DB-->>Next: 高速レスポンス
  Next-->>Browser: 最初のSuspenseチャンク解決
  Note over Browser: 部分的なUIが表示
  
  DB-->>Next: 低速レスポンス
  Next-->>Browser: 残りのSuspenseチャンク解決
  Note over Browser: LCP発生(コンテンツ完成)

React Server Components完全ガイド2026|App Routerデータフェッチ設計でも触れてるけど、Server ComponentsとStreaming UIは切っても切れない関係にある。データフェッチをサーバー側に移すことで、クライアントに送るJSバンドルを削減しつつ、ストリーミングを活用できる。

Suspense + Error Boundaryの組み合わせで本番を安定させる

Streaming UIで見落としがちなのがエラーハンドリング。非同期でデータを取ってくる以上、どこかのフェッチが失敗したときにアプリ全体を落とさないための設計が必要になる。これ、最初に考慮してなくて本番でヒヤッとした。

// app/components/ErrorBoundary.tsx
'use client';

import { Component, ReactNode } from 'react';

interface Props {
  fallback: ReactNode;
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: { componentStack: string }) {
    // エラーモニタリングサービスに送信
    console.error('Streaming chunk error:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}
// app/dashboard/page.tsx での使い方
export default function DashboardPage() {
  return (
    <main>
      <StaticHeader />
      
      {/* エラーが起きたらフォールバックを表示、他のセクションは正常動作 */}
      <ErrorBoundary fallback={<AnalyticsErrorState />}>
        <Suspense fallback={<AnalyticsSkeleton />}>
          <AnalyticsSection />
        </Suspense>
      </ErrorBoundary>
      
      <ErrorBoundary fallback={<FeedErrorState />}>
        <Suspense fallback={<FeedSkeleton />}>
          <FeedSection />
        </Suspense>
      </ErrorBoundary>
    </main>
  );
}

Next.jsにはApp Routerでerror.tsxという規約もあるけど、これはページ全体のエラー境界になる。セクション単位でエラーを分離したいときは、上記のようなErrorBoundaryコンポーネントを組み合わせるのが実務的な解。「アナリティクスが落ちてもフィードは見える」という状態を保てるのは、ユーザー体験としてかなり大事だと思う。

あと、まだ検証中なんだけど、React 19で追加されたuse()フックとの組み合わせが面白い。use()はSuspenseと統合されていて、Promiseを直接コンポーネントで「await」できる。

// React 19のuse()を使ったパターン
import { use } from 'react';

// Server ComponentでPromiseをpropsとして渡す
export default function Page() {
  const dataPromise = fetchSomeData(); // awaitしない
  
  return (
    <Suspense fallback={<Skeleton />}>
      <DataComponent dataPromise={dataPromise} />
    </Suspense>
  );
}

// Client ComponentでPromiseを消費
'use client';
function DataComponent({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise); // ここでSuspenseが発火
  return <div>{data.title}</div>;
}

Jest・Vitest・Playwrightの使い分け|2026年テスト戦略完全ガイドでも触れてるけど、Streaming UIのテストはPlaywrightとの組み合わせが現時点で最も実用的。waitForSelectorでSuspenseが解決されるのを待てるので、E2Eで実際のローディング体験を検証できる。

// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';

test('ダッシュボードのStreaming UIが正しく表示される', async ({ page }) => {
  await page.goto('/dashboard');
  
  // 静的ヘッダーは即座に表示
  await expect(page.getByRole('banner')).toBeVisible();
  
  // SkeletonがまずレンダリングされSuspense中であることを確認
  await expect(page.getByTestId('analytics-skeleton')).toBeVisible();
  
  // データが来たらコンテンツに切り替わる
  await expect(page.getByTestId('analytics-section')).toBeVisible({
    timeout: 5000
  });
  
  // Skeletonが消えている
  await expect(page.getByTestId('analytics-skeleton')).not.toBeVisible();
});

まとめ

Next.js 15のStreaming UIとキャッシュ設計、3ヶ月かけてようやく落ち着いてきた。整理するとこんな感じ:

  • Suspenseの粒度は「意味のあるUI単位」で設計する。コンポーネント単位でバラバラに置くと、ポップインがひどくてUX悪化する
  • PPRはビルドログのマークで確認習慣を。動的関数の混入に気づかず、気づいたらSSRになってたというパターンを防ぐ
  • Next.js 15のデフォルトはキャッシュなし。fetch呼び出しに意図的なnext.revalidatetagsを設定しないと、APIへのリクエストが爆増する
  • ErrorBoundaryとSuspenseはセットで使う。ストリーミング中のエラーを分離することで、一部障害が全体に波及しない設計になる
  • テストはPlaywrightのSuspense解決待ちが実用的。Jestだけでは非同期UIの挙動検証が難しい

次のステップとして試してみたいのは、React 19のuse()フックをさらに活用したデータフェッチのリファクタリングと、Server Actions × Optimistic UIの組み合わせ。Optimistic UIとStreaming UIの相性は正直まだ検証中だけど、ユーザー操作への即時フィードバックとバックグラウンド同期の組み合わせはかなり体験がよくなりそうな予感がしている。

皆さんのチームではStreaming UIどう設計してますか? Suspense境界の粒度に悩んだ経験があればぜひ聞いてみたいです。

U

Untanbaby

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

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

関連記事