Next.js: ストリーミング & Suspense

最終更新:2026-08-26

遅延レンダリングにより、ユーザーは待機中に空白の画面を見つめる必要がなくなります。ページは生成されながら表示され、Time to First Byte (TTFB) が 60% 削減されます。

1. 学習目標



2. フロントエンドアーキテクトの実話

(1) 課題: リクエストのウォーターフォールによりページ読み込みが 3 倍遅くなる

Charlie は TaskFlow チームのフロントエンドアーキテクトです。Dashboard ページの読み込みに 4.2 秒かかることを発見しました:

「ページには 5 つのデータカード、1 つのアクティビティリスト、1 つの統計グラフが含まれています。すべてのデータはページコンポーネント内でシーケンシャルに await で読み込まれます。1 つの遅い API 呼び出しがページ全体を停止させ、ユーザーは空白の画面を見つめて何もできません。」

問題 所要時間 原因
ユーザーリスト (200 ms) + アイテム数 (300 ms) 500 ms シリアル待機
統計グラフ (800 ms) 800 ms バックエンドの遅い処理
アクティビティフロー (400 ms) 400 ms クロスサービス クエリ
合計 TTFP 1,700 ms すべてシリアル

(2) ストリーミング + Suspense の解決策

ページを複数の Suspense バウンダリに分割し、各データブロックをストリーミングで独立して読み込みます。

TSX
export default function DashboardPage() {
  return (
    <div>
      <h1>概要</h1>
      <Suspense fallback={<SkeletonCards />}>
        <UserCards />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  )
}

(3) 効果

観点 最適化前 (シリアル) 最適化後 (ストリーミング)
ファーストバイト時間 1,700 ms 50 ms (静的シェル)
ファーストビューのインタラクティブ性 4.2s 1.1s
大きなブロックのブロッキング すべて ✅ なし
ユーザー体験 4 秒間の空白画面 スケルトン画面 → ブロックごとに表示


3. Suspense バウンダリ分割戦略

(1) ページの 3 つの読み込みレイヤー

100%
graph TB
    A[ページ] --> B[レイヤー1: 静的シェル<br/>layout + header<br/>即時プレビュー]
    A --> C[レイヤー2: ワイヤーフレーム表示<br/>loading.tsx<br/>~200ms]
    A --> D[レイヤー3: コンテンツ<br/>Suspense バウンダリ<br/>ブロックごとにストリーミング到着]

    B --> E[ユーザーはページ構造を確認]
    C --> F[ユーザーはプレースホルダーアニメーションを確認]
    D --> G[順番にコンテンツを埋めていく]

    style B fill:#d4edda
    style C fill:#fff3cd
    style D fill:#cce5ff
レイヤー メカニズム 表示時間 ユーザーの知覚
静的シェル Layout リアルタイム ページフレームワーク
スケルトン画面 loading.tsx ~200 ms 読み込みアニメーション
コンテンツ Suspense + fallback ブロックごとにレンダリング 段階的読み込み

(2) シリアル vs. パラレルデータ読み込み

TSX
// ❌ シリアルウォーターフォール — 遅い
export default async function SlowPage() {
  const users = await fetch('https://api.example.com/users').then(r => r.json())
  const projects = await fetch('https://api.example.com/projects').then(r => r.json())
  const analytics = await fetch('https://api.example.com/analytics').then(r => r.json())
  return <Dashboard users={users} projects={projects} analytics={analytics} />
}

// ✅ パラレル Suspense — 速い
export default function FastPage() {
  return (
    <div>
      <Suspense fallback={<SkeletonCards />}><UserCards /></Suspense>
      <Suspense fallback={<SkeletonCards />}><ProjectCards /></Suspense>
      <Suspense fallback={<ChartSkeleton />}><AnalyticsChart /></Suspense>
    </div>
  )
}

▶ サンプル: Suspense コンポーネントの実装

Output:

TEXT 📖 参照専用
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }> | }>
TSX
// components/UserCards.tsx — 独立した Suspense バウンダリ
export default async function UserCards() {
  // 遅いクエリをシミュレート
  const users = await new Promise<{ name: string; email: string }[]>((resolve) =>
    setTimeout(() => resolve([
      { name: 'Alice', email: 'alice@taskflow.io' },
      { name: 'Bob', email: 'bob@taskflow.io' },
      { name: 'Charlie', email: 'charlie@taskflow.io' }
    ]), 2000)
  )

  return (
    <div style={{ display: 'flex', gap: '1rem' }}>
      {users.map((u) => (
        <div key={u.email} style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: 8 }}>
          <h3>{u.name}</h3>
          <p>{u.email}</p>
        </div>
      ))}
    </div>
  )
}

Output:

TEXT 📖 参照専用
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
TSX
// components/SkeletonCards.tsx — ワイヤーフレーム表示 fallback
export default function SkeletonCards() {
  return (
    <div style={{ display: 'flex', gap: '1rem' }}>
      {[1, 2, 3].map((i) => (
        <div key={i} style={{
          width: 200, height: 100, borderRadius: 8,
          background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
          backgroundSize: '200% 100%',
          animation: 'shimmer 1.5s infinite'
        }} />
      ))}
    </div>
  )
}


4. loading.tsx と自動 Suspense

(1) ファイル規約

ファイル 目的 Suspense のスコープ
app/dashboard/loading.tsx ページルート全体をラップ page.tsx の全コンテンツ
app/dashboard/settings/loading.tsx settings セグメントのみ settings/page.tsx
100%
graph TB
    A[app/dashboard/] --> B[layout.tsx<br/>ルートレイアウト]
    A --> C[loading.tsx<br/>ページレベルの Suspense]
    A --> D[page.tsx<br/>ページコンテンツ]
    D --> E{ページ内}
    E --> F[<Suspense><SlowWidget/></Suspense>]
    E --> G[<Suspense><AnotherWidget/></Suspense>]

    C --> H[loading.tsx の fallback を表示]
    D --> I[page.tsx のコンテンツを表示]
    F --> J[独立したストリーミング読み込み]

    style C fill:#fff3cd
    style F fill:#cce5ff
    style G fill:#cce5ff

(2) loading.tsx の設計

TSX
// app/dashboard/loading.tsx — ページレベルのスケルトン画面
export default function DashboardLoading() {
  return (
    <div style={{ padding: '2rem' }}>
      <div style={{ height: 32, width: 200, background: '#eee', borderRadius: 4, marginBottom: '2rem' }} />
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
        {[1, 2, 3].map((i) => (
          <div key={i} style={{ height: 120, background: '#f5f5f5', borderRadius: 8 }} />
        ))}
      </div>
      <div style={{ height: 300, background: '#f5f5f5', borderRadius: 8, marginTop: '2rem' }} />
    </div>
  )
}

▶ サンプル: ネストされた loading.tsx 階層

TEXT 📖 参照専用
app/dashboard/              ← loading.tsx ページ全体
├── layout.tsx               ← ナビゲーションバー(即時プレビュー)
├── loading.tsx              ← ページスケルトン画面
├── page.tsx                 ← Dashboard コンテンツ
├── projects/                ← サブルート
│   ├── loading.tsx          ← プロジェクトリストのみのスケルトン画面
│   └── page.tsx             ← プロジェクトリスト
└── settings/
    └── loading.tsx          ← 設定スケルトン画面
TSX
// app/dashboard/projects/loading.tsx — プロジェクトリストのみの読み込み状態
export default function ProjectsLoading() {
  return (
    <div>
      {[1, 2, 3, 4].map((i) => (
        <div key={i} style={{
          height: 64, marginBottom: 8, borderRadius: 6,
          background: 'linear-gradient(90deg, #e8e8e8 0%, #f5f5f5 50%, #e8e8e8 100%)',
          backgroundSize: '200% 100%',
          animation: 'shimmer 1.5s ease-in-out infinite'
        }} />
      ))}
    </div>
  )
}


5. ネストされた Suspense とフォールバックの設計

(1) ネスト戦略

100%
graph TB
    A[ページ] --> B[外側 Suspense<br/>fallback: ページ構造]
    A --> C[内側 Suspense 1<br/>fallback: カードテンプレート]
    A --> D[内側 Suspense 2<br/>fallback: グラフフレームワーク]
    D --> E[より深いレベル Suspense<br/>fallback: マイクロフレームワーク]

    B -->|即時表示| F[ページ構造]
    C -->|~500ms| G[ユーザーカード]
    D -->|~800ms| H[統計グラフ]
    E -->|~1200ms| I[グラフ詳細]

    style B fill:#f8d7da
    style C fill:#fff3cd
    style D fill:#cce5ff
    style E fill:#d4edda
ネストレベル 推奨フォールバック 表示時間 情報密度
外側 (ページ) 大きなプレースホルダー 即時 低 (構造)
中間 (コンポーネント) コンポーネント形状のスケルトン ~500 ms 中 (概要)
内側 (詳細) 小さなプレースホルダー + マイクロアニメーション ~1200 ms 高 (コンテンツ)

▶ サンプル: 3 レベルのネストされた Suspense

Output:

TEXT 📖 参照専用
Diagram of nested Suspense: outer fallback (page shell) + inner fallback (section loading).
TSX
// app/analytics/page.tsx — ネストされた Suspense の実践
import { Suspense } from 'react'

function SummarySkeleton() { return <div style={{ height: 100, background: '#eee' }} /> }
function ChartSkeleton() { return <div style={{ height: 300, background: '#f5f5f5' }} /> }
function DetailSkeleton() { return <div style={{ height: 60, background: '#fafafa' }} /> }

export default function AnalyticsPage() {
  return (
    <div>
      <h1>分析レポート</h1>

      {/* 外側: 概要カード */}
      <Suspense fallback={<SummarySkeleton />}>
        <SummaryCards />
      </Suspense>

      {/* 中間: グラフ */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      {/* 内側: 詳細リスト */}
      <Suspense fallback={<DetailSkeleton />}>
        <TopProjects />
      </Suspense>
    </div>
  )
}

async function SummaryCards() {
  await new Promise((r) => setTimeout(r, 500))
  return <div>今月の収入: $120,000 • ユーザー数: 15,230 • プロジェクト: 342</div>
}

async function RevenueChart() {
  await new Promise((r) => setTimeout(r, 1000))
  return <div style={{ height: 300, background: '#e8f4f8' }}>[グラフ: 月次収益トレンド]</div>
}

async function TopProjects() {
  await new Promise((r) => setTimeout(r, 1500))
  return <div>トッププロジェクト: TaskFlow (45%), WebApp (30%), Mobile (25%)</div>
}

Output:

TEXT 📖 参照専用
Renders static shell immediately. Dynamic sections show "Loading..." fallback until data loads.
Visible content: 分析レポート | }> | }>


6. React 19 の use() フックで Promise を読み取る

use()await の比較

機能 await (サーバーコンポーネント) use() (クライアントコンポーネント)
場所 サーバーコンポーネントのみ クライアントコンポーネント ('use client' を含む)
ブロッキング動作 コンポーネントのレンダリングをブロック Promise をスロー → Suspense がキャッチ
型シグネチャ const data = await promise const data = use(promise)
再サブミット 自動 (RSC 再実行) 手動でトリガーが必要
TSX
// ✅ サーバーコンポーネント: await
async function ServerProfile({ id }: { id: string }) {
  const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json())
  return <div>{user.name}</div>
}

// ✅ クライアントコンポーネント: use()
'use client'
import { use } from 'react'

function ClientProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise)
  return <div>{user.name}</div>
}

(2) クライアントコンポーネントでの use() によるストリーミング

TSX
// components/StreamingProfile.tsx — use() + Suspense
'use client'
import { use } from 'react'

interface User { name: string; email: string; bio: string }

function UserProfile({ promise }: { promise: Promise<User> }) {
  const user = use(promise)
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.bio}</p>
    </div>
  )
}

// ページで使用
import { Suspense } from 'react'

export default function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = use(params) // params も Promise
  const userPromise = fetch(`https://api.example.com/users/${id}`).then(r => r.json())

  return (
    <Suspense fallback={<div>ユーザープロフィールを読み込み中...</div>}>
      <UserProfile promise={userPromise} />
    </Suspense>
  )
}
💡 ヒント: Next.js 16 では、paramssearchParams は両方とも Promise です。await (RSC) または use() (クライアント) を使用してアンラップする必要があります。

▶ サンプル: use() による無限スクロールフィードの実装

Output:

TEXT 📖 参照専用
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
TSX
// components/InfinitePosts.tsx
'use client'
import { use, useState, useTransition } from 'react'

interface Post { id: number; title: string }

async function fetchPosts(page: number): Promise<Post[]> {
  const res = await fetch(`/api/posts?page=${page}&limit=10`)
  return res.json()
}

export default function InfinitePosts({ initialPromise }: { initialPromise: Promise<Post[]> }) {
  const [page, setPage] = useState(1)
  const [postsPromise, setPostsPromise] = useState(initialPromise)
  const [isPending, startTransition] = useTransition()

  const posts = use(postsPromise)

  const loadMore = () => {
    startTransition(() => {
      setPage((p) => p + 1)
      setPostsPromise(fetchPosts(page + 1))
    })
  }

  return (
    <div>
      {posts.map((post) => <div key={post.id}>{post.title}</div>)}
      <button onClick={loadMore} disabled={isPending}>
        {isPending ? '読み込み中...' : 'もっと読み込む'}
      </button>
    </div>
  )
}

Output:

TEXT 📖 参照専用
Renders: InfinitePosts page with interactive UI elements.


7. AI SDK StreamText 統合

(1) streamText ストリーミングアーキテクチャ

100%
graph LR
    A[ユーザーメッセージ] --> B[Route Handler<br/>POST /api/chat]
    B --> C[AI SDK streamText]
    C --> D[LLM プロバイダ<br/>OpenAI / Anthropic]
    D -->|Flow Token| E[ReadableStream]
    E --> F[クライアントコンポーネント<br/>useChat フック]
    F --> G[タイプライター効果]

    style B fill:#cce5ff
    style C fill:#d4edda
    style F fill:#fff3cd
コンポーネント 機能 インストール
ai コアライブラリ streamText 関数 npm install ai
@ai-sdk/openai OpenAI プロバイダ npm install @ai-sdk/openai
useChat クライアントフック ai パッケージに含まれる

(2) サーバーサイドストリーミングルート

TS
// app/api/chat/route.ts — AI ライブチャット API
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function POST(req: Request) {
  const { messages } = await req.json()

  const result = streamText({
    model: openai('gpt-4o'),
    system: 'あなたは TaskFlow の AI アシスタントです。プロジェクト管理に関する質問に答えてください。',
    messages
  })

  return result.toDataStreamResponse()
}

▶ サンプル: クライアントサイドのタイプライター効果

Output:

TEXT 📖 参照専用
TypeScript code executed successfully.
TSX
// components/ChatBox.tsx
'use client'
import { useChat } from 'ai/react'

export default function ChatBox() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()

  return (
    <div style={{ maxWidth: 600, margin: '0 auto' }}>
      <div style={{ height: 400, overflowY: 'auto', border: '1px solid #ccc', padding: '1rem' }}>
        {messages.map((m) => (
          <div key={m.id} style={{
            textAlign: m.role === 'user' ? 'right' : 'left',
            marginBottom: '1rem'
          }}>
            <strong>{m.role === 'user' ? 'あなた' : 'AI'}:</strong>
            <p>{m.content}</p>
          </div>
        ))}
        {isLoading && <p>AI が入力中...</p>}
      </div>

      <form onSubmit={handleSubmit} style={{ display: 'flex', marginTop: '1rem' }}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="質問を入力してください..."
          style={{ flex: 1, padding: '0.5rem' }}
        />
        <button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem' }}>
          送信
        </button>
      </form>
    </div>
  )
}

Output:

TEXT 📖 参照専用
Renders a list by mapping over messages, displaying each m.
🔥 よくある間違い: useChat のデフォルトは POST /api/chat です。カスタム API エンドポイントを指定するには、api オプションを渡します: useChat({ api: '/api/ai/chat' })



8. 完全なサンプル: AI 分析ダッシュボード + ストリーミングデータ

TSX
// app/dashboard/page.tsx — フローダッシュボード + AI 分析
import { Suspense } from 'react'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

// フレーム表示モジュール
function MetricSkeleton() {
  return <div style={{ height: 100, background: '#f0f0f0', borderRadius: 8 }} />
}

function ChartSkeleton() {
  return <div style={{ height: 300, background: '#f5f5f5', borderRadius: 8 }} />
}

// 遅いデータコンポーネント
async function TeamMetrics() {
  const metrics = await new Promise<{ members: number; projects: number; tasks: number }>(
    (resolve) => setTimeout(() => resolve({ members: 12, projects: 45, tasks: 230 }), 1500)
  )
  return (
    <div style={{ display: 'flex', gap: '1rem' }}>
      <div>👥 {metrics.members} メンバー</div>
      <div>📁 {metrics.projects} プロジェクト</div>
      <div>✅ {metrics.tasks} タスク</div>
    </div>
  )
}

async function ActivityChart() {
  const data = await new Promise<number[]>((r) => setTimeout(() => r([30, 45, 78, 92, 55, 88, 120]), 2000))
  return (
    <div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.5rem', height: 200 }}>
      {data.map((v, i) => (
        <div key={i} style={{ height: v, width: 40, background: '#4f46e5', borderRadius: '4px 4px 0 0' }} />
      ))}
    </div>
  )
}

export default async function DashboardPage() {
  const session = await auth()
  if (!session) redirect('/login')

  return (
    <div>
      <h1>TaskFlow 概要</h1>
      <p>おかえりなさい、{session.user!.name}</p>

      <Suspense fallback={<MetricSkeleton />}>
        <TeamMetrics />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <ActivityChart />
      </Suspense>
    </div>
  )
}
TS
// app/api/chat/route.ts — AI 分析アシスタント
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function POST(req: Request) {
  const { messages } = await req.json()

  const result = streamText({
    model: openai('gpt-4o-mini'),
    system: 'あなたはプロジェクト管理 AI アシスタントです。提供されたプロジェクトデータに基づいて分析と推奨事項を提供します。回答は簡潔にしてください。',
    messages
  })

  return result.toDataStreamResponse()
}
TSX
// components/ChatPanel.tsx
'use client'
import { useChat } from 'ai/react'

export default function ChatPanel() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()

  return (
    <div style={{ position: 'fixed', bottom: 0, right: 20, width: 380, border: '1px solid #ccc', borderRadius: '8px 8px 0 0' }}>
      <div style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', borderRadius: '8px 8px 0 0' }}>
        AI 分析アシスタント
      </div>
      <div style={{ height: 300, overflowY: 'auto', padding: '0.5rem' }}>
        {messages.map((m) => (
          <div key={m.id} style={{ marginBottom: '0.5rem' }}>
            <strong>{m.role === 'user' ? '自分' : 'AI'}:</strong>
            <p style={{ margin: 0 }}>{m.content}</p>
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit} style={{ display: 'flex', borderTop: '1px solid #eee' }}>
        <input value={input} onChange={handleInputChange} placeholder="プロジェクトについて質問..." style={{ flex: 1, padding: '0.5rem', border: 'none' }} />
        <button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', border: 'none' }}>送信</button>
      </form>
    </div>
  )
}
💻 効果の説明: ページは最初にスケルトンビューを表示 → 0.5 秒後にメトリクスカードが表示 → 1 秒後に棒グラフが表示 → 右側に AI パネルが表示され、リアルタイム会話が可能で、コンテンツは文字ごとにストリーミング表示されます。


❓ よくある質問

Q Suspense と loading.tsx の違いは何ですか?
A loading.tsx はファイル規約で、ページ全体に自動的に Suspense バウンダリを作成し、実装が簡単です。<Suspense> コンポーネントはページ内の細かい制御に使用され、任意の数の独立したデータブロックを並列ストリーミング用にラップできます。
Q ストリームレンダリングは SEO に影響しますか?
A いいえ。検索エンジンのクローラー(Googlebot)は最終的な HTML が完了するまで待ってからインデックスします。ストリーミングコンテンツは後から注入されるのではなく、段階的に読み込まれます。Next.js の RSC ペイロードにより、クローラーは完全なコンテンツを確認できます。
Q use()await より優れているのはどんな場合ですか?
A use() はクライアントコンポーネントで使用でき、コンポーネント自身が Promise への依存を宣言し、最も近い Suspense バウンダリが読み込み状態を処理します。フロントエンドで非同期操作をトリガーする必要があるシナリオ(「もっと読み込む」のクリックなど)に適しています。
Q AI SDK の streamText と OpenAI API の直接呼び出しの違いは何ですか?
A streamText は SSE (Server-Sent Events) プロトコル、バックプレッシャー制御、トークンカウント、エラーリトライを自動的に処理します。OpenAI API を直接呼び出すと、ReadableStream とレスポンス形式を手動で処理する必要があります。
Q ストリーミングレンダリングと PPR (Partial Prerendering) はどのように連携しますか?
A PPR の静的シェルは外側のレイアウトと静的コンテンツで構成され、内部の動的部分は <Suspense> でラップされます。PPR は静的部分を事前生成し、動的部分はオンデマンドでレンダリングされます。この 2 つは完璧に補完し合います。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): 1 つのページに 3 つの Suspense バウンダリを実装し、ユーザーリスト、プロジェクト統計、アクティビティログをそれぞれ読み込み、各バウンダリに異なる遅延 (500 ms / 1000 ms / 1500 ms) を設定してください。

  2. 応用問題 (⭐⭐): AI SDK の streamText を使用して翻訳アシスタント API ルートを作成し、クライアントサイドで useChat を使用して翻訳結果を 1 文字ずつ表示するタイプライター効果を実装してください。

  3. 発展問題 (⭐⭐⭐): マルチレベルのネストされたダッシュボードページを実装してください: 外側の loading.tsx がページ全体のスケルトンビューを表示し、ページ内に 3 層のネストされた Suspense(統計カード → グラフ → 詳細リスト)を配置します。最も外側のレイヤーはクリック時に use() フックを使用して追加データを読み込みます。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%