Chapter 9: ストリーミング
前章でウォーターフォールを避ける方法を学びましたが,それでも一部のデータ取得はどうしても時間がかかる場合があります。この章では React Suspense と App Router の仕組みを活用し,遅い処理があっても画面を素早く表示するストリーミング戦略を学びます。
この章で学ぶこと
- ストリーミングとは何か,いつ使うのか
loading.tsxと React Suspense を使ったストリーミングの実装方法- ローディングスケルトンとは何か
- Route Groups(ルートグループ)とは何か,いつ使うのか
- アプリ内で Suspense境界 をどこに置くべきか
ストリーミングとは?
ストリーミングは,1つのルートを小さな「チャンク」に分割し,準備できた順にサーバーからクライアントへ段階的に送るデータ転送手法です。

これにより,遅いデータがページ全体の表示をブロックするのを防ぎ,ユーザーは全データが揃う前でもページの一部を見たり操作したりできます。

React のコンポーネントモデルと相性がよく,各コンポーネント=チャンクとして扱えます。
Next.js でストリーミングを実装する方法は2通りあります。
- ページ単位:
loading.tsx(Next.js が<Suspense>を自動で生成) - コンポーネント単位:自分で
<Suspense>を配置(より細かい制御)
loading.tsx でページ全体をストリーミング
/app/dashboard フォルダ内に loading.tsx を作成します。
http://localhost:3000/dashboard をリロードすると,「Loading…」が表示されます。

ここで起きていること:
loading.tsxは React Suspense を土台にした Next.js の特別なファイルで,ページ内容の読み込み中に表示する フォールバックUI を定義できます。<SideNav>は静的なので 即時表示 され,動的コンテンツの読み込み中でも操作できます。- ページのロード完了を待たずに 他ページへ遷移できる(中断可能ナビゲーション) ようになります。
テキストだけでなく,ローディングスケルトンを表示してより良い体験にしましょう。
ローディングスケルトンの追加
ローディングスケルトンは,本来のUIを簡略化したプレースホルダです。
loading.tsx に書いたUIは 静的ファイルに埋め込まれて先に配信され,その後にサーバーから動的コンテンツがストリーミングされます。
import DashboardSkeleton from '@/app/ui/skeletons';
export default function Loading() {
return <DashboardSkeleton />;
}
リロードすると,ダッシュボードのスケルトンが表示されます。

Route Groups でスケルトンの適用範囲を修正
現在の loading.tsx は /dashboard 配下全体(/invoices や /customers など)にも適用されます。
これを ダッシュボードの概要ページのみに限定するため,Route Group を使います。
/dashboard 内に /(overview) フォルダを作成し,loading.tsx と page.tsx をその中へ移動します。

Route Group は,URL には現れない括弧付きフォルダです。 例えば
/dashboard/(overview)/page.tsxのURLは依然として/dashboardのままです。 これで,loading.tsxは 概要ページのみに適用されます。
Route Group は,マーケ用 (marketing) とショップ用 (shop) のように,URLを変えずに論理的にファイルを分離したいときにも便利です。
コンポーネントをストリーミングする
ページ全体のストリーミングだけでなく,特定コンポーネント単位でのストリーミングも可能です。
Suspense は「ある条件(例:データの取得)が満たされるまでレンダリングを遅らせる」仕組みです。動的コンポーネントを <Suspense> で包み,読み込み中に表示するフォールバックを渡します。
前章の遅いリクエスト fetchRevenue() がページ全体を遅くしていました。
ページ全体をブロックせず,そのコンポーネントだけストリーミングするようにしましょう。
1) ページから fetchRevenue() を取り除く
// 変更前(抜粋)
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // fetchRevenue は削除
export default async function Page() {
const revenue = await fetchRevenue() // ← この行を削除
const latestInvoices = await fetchLatestInvoices();
// ...
}
2) <RevenueChart /> を <Suspense> で包む
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
export default async function Page() {
const latestInvoices = await fetchLatestInvoices();
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
}
3) <RevenueChart> 側で自分のデータを取得
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
// ...
export default async function RevenueChart() { // 非同期化し,props を削除
const revenue = await fetchRevenue(); // コンポーネント内で取得
const chartHeight = 350;
const { yAxisLabels, topLabel } = generateYAxis(revenue);
if (!revenue || revenue.length === 0) {
return <p className="mt-4 text-gray-400">No data available.</p>;
}
return (
// ...
);
}
これでページを更新すると,カードや最新請求はすぐ表示され,<RevenueChart> は スケルトンを出しつつ後追いでロードされます。

<LatestInvoices> をストリーミング
fetchLatestInvoices()をページから<LatestInvoices>コンポーネント内に移動します。<LatestInvoices>を<Suspense>で包み,フォールバックに<LatestInvoicesSkeleton>を渡します。
ダッシュボードページ(抜粋)
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData } from '@/app/lib/data'; // fetchLatestInvoices は削除
import { Suspense } from 'react';
import {
RevenueChartSkeleton,
LatestInvoicesSkeleton,
} from '@/app/ui/skeletons';
export default async function Page() {
// const latestInvoices = await fetchLatestInvoices() は削除
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
</main>
);
}
<LatestInvoices> コンポーネント
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices } from '@/app/lib/data';
export default async function LatestInvoices() { // props を削除
const latestInvoices = await fetchLatestInvoices();
return (
// ...
);
}
複数カードの読み込み順を整える(グルーピング)
<Card> をそれぞれ個別に Suspense で包むと,バラバラにポップインする「はね」感が出ることがあります。
これを避けたい場合,カード群をラッパーコンポーネントでまとめてストリーミングしましょう。
(例:<SideNav> → カード群 → その後に他セクション…のような段階的表示)
ページ側の変更
- 既存の
<Card>群を削除 fetchCardData()呼び出しを削除- ラッパーの
<CardWrapper />と<CardsSkeleton />をインポート <CardWrapper />を Suspense で包む
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
RevenueChartSkeleton,
LatestInvoicesSkeleton,
CardsSkeleton,
} from '@/app/ui/skeletons';
export default async function Page() {
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<CardsSkeleton />}>
<CardWrapper />
</Suspense>
</div>
{/* ほかのセクション… */}
</main>
);
}
ラッパー側でまとめて取得
// ...
import { fetchCardData } from '@/app/lib/data';
// ...
export default async function CardWrapper() {
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return (
<>
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</>
);
}
リロードすると,カードが同時にふわっと表示されるはずです。複数コンポーネントを同時に見せたいときに有効なパターンです。
Suspense 境界をどこに置くか?
配置はアプリの性質や意図する体験に依存します。
- ページ全体を
loading.tsxで包む → 実装は簡単だが,どれか1つが遅いと全体が待ちになりがち - すべてのコンポーネントを個別に包む → ポップインが多発して視覚的に落ち着かない場合あり
- セクションごとに包む → ほどよい分割だが,ラッパーの設計が必要
一般的には,データ取得を「必要なコンポーネントの中」へ寄せ,そのコンポーネントを Suspense で包むのが扱いやすいです。 ただし,アプリの要件によってはセクション単位やページ単位のストリーミングでも問題ありません。 試して最適解を見つけることを恐れずに,Suspense を活用してみてください。
この先へ
ストリーミングと Server Components により,データ取得とローディング状態を柔軟に扱えます。 次章では,ストリーミングを前提に設計された新しいレンダリングモデル Partial Prerendering を学びます。