Next.js: ストリーミング & Suspense
最終更新:2026-08-26
遅延レンダリングにより、ユーザーは待機中に空白の画面を見つめる必要がなくなります。ページは生成されながら表示され、Time to First Byte (TTFB) が 60% 削減されます。
1. 学習目標
- Suspense バウンダリの分割戦略(ページ → スケルトン画面 → コンテンツ)
loading.tsxファイル規約と自動 Suspense ラッピング- ストリーミング読み込み順序の設計とネストされた Suspense フォールバック
- React 19 の
use()フック: コンポーネント内で Promise を消費する - AI SDK
streamTextによるタイプライター効果付きテキスト生成 - ストリームレンダリングと PPR 静的シェルを組み合わせたハイブリッドモード
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 バウンダリに分割し、各データブロックをストリーミングで独立して読み込みます。
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 つの読み込みレイヤー
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. パラレルデータ読み込み
// ❌ シリアルウォーターフォール — 遅い
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:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }> | }>
// 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:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
// 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 |
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 の設計
// 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 階層
app/dashboard/ ← loading.tsx ページ全体
├── layout.tsx ← ナビゲーションバー(即時プレビュー)
├── loading.tsx ← ページスケルトン画面
├── page.tsx ← Dashboard コンテンツ
├── projects/ ← サブルート
│ ├── loading.tsx ← プロジェクトリストのみのスケルトン画面
│ └── page.tsx ← プロジェクトリスト
└── settings/
└── loading.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) ネスト戦略
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:
Diagram of nested Suspense: outer fallback (page shell) + inner fallback (section loading).
// 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:
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 再実行) | 手動でトリガーが必要 |
// ✅ サーバーコンポーネント: 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() によるストリーミング
// 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>
)
}
params と searchParams は両方とも Promise です。await (RSC) または use() (クライアント) を使用してアンラップする必要があります。
▶ サンプル: use() による無限スクロールフィードの実装
Output:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
// 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:
Renders: InfinitePosts page with interactive UI elements.
7. AI SDK StreamText 統合
(1) streamText ストリーミングアーキテクチャ
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) サーバーサイドストリーミングルート
// 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:
TypeScript code executed successfully.
// 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:
Renders a list by mapping over messages, displaying each m.
useChat のデフォルトは POST /api/chat です。カスタム API エンドポイントを指定するには、api オプションを渡します: useChat({ api: '/api/ai/chat' })。
8. 完全なサンプル: AI 分析ダッシュボード + ストリーミングデータ
// 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>
)
}
// 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()
}
// 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>
)
}
❓ よくある質問
loading.tsx はファイル規約で、ページ全体に自動的に Suspense バウンダリを作成し、実装が簡単です。<Suspense> コンポーネントはページ内の細かい制御に使用され、任意の数の独立したデータブロックを並列ストリーミング用にラップできます。use() が await より優れているのはどんな場合ですか?use() はクライアントコンポーネントで使用でき、コンポーネント自身が Promise への依存を宣言し、最も近い Suspense バウンダリが読み込み状態を処理します。フロントエンドで非同期操作をトリガーする必要があるシナリオ(「もっと読み込む」のクリックなど)に適しています。streamText と OpenAI API の直接呼び出しの違いは何ですか?streamText は SSE (Server-Sent Events) プロトコル、バックプレッシャー制御、トークンカウント、エラーリトライを自動的に処理します。OpenAI API を直接呼び出すと、ReadableStream とレスポンス形式を手動で処理する必要があります。<Suspense> でラップされます。PPR は静的部分を事前生成し、動的部分はオンデマンドでレンダリングされます。この 2 つは完璧に補完し合います。📖 まとめ
- Suspense はページを独立したストリーミングブロックに分割し、シリアルリクエストがページ全体をブロックするのを防ぎます
loading.tsxファイルは自動的にルートページの Suspense を作成し、フォールバックはスケルトン画面を表示します- ネストされた Suspense 戦略: 外側のスケルトン (即時) → 中間の概要 (~500 ms) → 内側の詳細 (~1200 ms)
- React 19 の
use()フックはクライアントコンポーネントで Promise を消費し、Suspense と組み合わせて使用します - AI SDK の
streamText+useChatでタイプライター効果付きのストリーミング AI 会話を実現 - ストリームレンダリングと PPR 静的シェルの組み合わせ: 静的部分の事前レンダリング + 動的部分のストリーミング
📝 練習問題
-
基本問題 (⭐): 1 つのページに 3 つの Suspense バウンダリを実装し、ユーザーリスト、プロジェクト統計、アクティビティログをそれぞれ読み込み、各バウンダリに異なる遅延 (500 ms / 1000 ms / 1500 ms) を設定してください。
-
応用問題 (⭐⭐): AI SDK の
streamTextを使用して翻訳アシスタント API ルートを作成し、クライアントサイドでuseChatを使用して翻訳結果を 1 文字ずつ表示するタイプライター効果を実装してください。 -
発展問題 (⭐⭐⭐): マルチレベルのネストされたダッシュボードページを実装してください: 外側の
loading.tsxがページ全体のスケルトンビューを表示し、ページ内に 3 層のネストされた Suspense(統計カード → グラフ → 詳細リスト)を配置します。最も外側のレイヤーはクリック時にuse()フックを使用して追加データを読み込みます。