Next.jsストリーミングSSR実践:Suspenseからプログレッシブレンダリングまで5つのプロダクションパターン
従来のSSRのTTFBがどんどん遅くなる理由
ECサイトのトップページで従来のSSRを使っていると、初回画面のTTFBが800msから3秒に悪化——データフェッチがウォーターフォール式だからだ:まずユーザー情報、次に商品リスト、次にレコメンドデータ、最後にレビュー。**コンポーネントツリー全体が最も遅いリクエストの完了を待たないと最初のバイトを返せない。**ユーザーは白い画面を見つめ、LCPが爆発する。
Next.js 15のStreaming SSRはSuspenseバウンダリでHTMLをチャンク化してストリーミング送信——静的シェルを先に送り、動的部分は準備でき次第追加する。本記事ではSuspenseバウンダリレイアウト→ストリーミングHTMLとloading.tsx→プログレッシブハイドレーション→並列データフェッチ→プロダクション級エラーバウンダリの5つのプロダクションパターンを解説し、TTFBを60%以上削減する。
Streaming SSRコア概念
| 概念 | 説明 |
|---|---|
| Streaming SSR | ストリーミングサーバーサイドレンダリング、HTMLをチャンク化して転送、全データの準備完了を待たない |
| Suspense | React並行機能、非同期ロード境界を宣言、Streamingと組み合わせてチャンクレンダリングを実現 |
| Progressive Rendering | プログレッシブレンダリング、ページコンテンツを段階的に表示、ユーザーがまず使えるコンテンツを見る |
| Hydration | ハイドレーション、サーバーHTMLとクライアントJSを関連付け、ページをインタラクティブにする |
| React Server Components (RSC) | サーバーコンポーネント、ゼロクライアントJS、ネイティブStreaming対応 |
| App Router | Next.js 15ルーティングシステム、ネイティブStreaming SSRとRSC対応 |
従来のSSR vs Streaming SSR
従来のSSR:
リクエスト到達 → 全データ待機 → 完全なHTML生成 → 一括送信 → TTFB = 最も遅いリクエスト時間
Streaming SSR:
リクエスト到達 → 静的シェルを即座に送信 → Suspenseチャンク化 → 各チャンクのデータ準備完了後に追加 → TTFB ≈ 0
問題分析:従来のSSRの5つの課題
- ウォーターフォールデータフェッチ:コンポーネントのネストによる直列fetch、TTFB = fetchA + fetchB + fetchC、線形増加
- ブロッキングレンダリング:ツリー全体が最も遅いノードの完了を待つ必要がある、高速データが低速データに引きずられる
- ハイドレーションボトルネック:大規模ページの一括ハイドレーション、メインスレッドの長時間ブロック、INP指標の悪化
- エラーアバランシェ:1つのデータソースの失敗がページ全体の500エラーを引き起こす、フォールバック能力なし
- 粗いキャッシュ粒度:ページ全体のキャッシュは静的/動的部分を区別できない、頻繁な無効化でヒット率が低い
ステップバイステップ:5つのプロダクションパターン
パターン1:Suspenseバウンダリレイアウトとストリーミングレンダリング
// app/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="min-h-screen bg-gray-50">
<DashboardHeader />
<main className="container mx-auto px-4 py-6">
<Suspense fallback={<StatsCardsSkeleton />}>
<StatsCards />
</Suspense>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
<Suspense fallback={<ActivityFeedSkeleton />}>
<ActivityFeed />
</Suspense>
</main>
</div>
);
}
// app/components/StatsCards.tsx
async function StatsCards() {
const stats = await fetchDashboardStats();
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard title="総収入" value={`¥${stats.revenue.toLocaleString()}`} change={stats.revenueChange} />
<StatCard title="注文数" value={stats.orders.toLocaleString()} change={stats.ordersChange} />
<StatCard title="ユーザー数" value={stats.users.toLocaleString()} change={stats.usersChange} />
<StatCard title="コンバージョン率" value={`${stats.conversionRate}%`} change={stats.conversionChange} />
</div>
);
}
function StatCard({ title, value, change }: { title: string; value: string; change: number }) {
const isPositive = change >= 0;
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
<p className={`text-sm mt-2 ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{isPositive ? '↑' : '↓'} {Math.abs(change)}%
</p>
</div>
);
}
// app/components/skeletons.tsx
export function StatsCardsSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
<div className="bg-gray-200 h-4 rounded w-1/2" />
<div className="bg-gray-200 h-8 rounded w-3/4 mt-3" />
<div className="bg-gray-200 h-4 rounded w-1/3 mt-3" />
</div>
))}
</div>
);
}
export function RevenueChartSkeleton() {
return (
<div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
<div className="bg-gray-200 h-6 rounded w-1/3" />
<div className="bg-gray-200 h-64 rounded mt-4" />
</div>
);
}
export function RecentOrdersSkeleton() {
return (
<div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
<div className="bg-gray-200 h-6 rounded w-1/3" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 mt-4">
<div className="bg-gray-200 h-4 rounded w-1/4" />
<div className="bg-gray-200 h-4 rounded w-1/4" />
<div className="bg-gray-200 h-4 rounded w-1/6" />
<div className="bg-gray-200 h-4 rounded w-1/6" />
</div>
))}
</div>
);
}
export function ActivityFeedSkeleton() {
return (
<div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
<div className="bg-gray-200 h-6 rounded w-1/4" />
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex gap-3 mt-4">
<div className="bg-gray-200 h-10 w-10 rounded-full" />
<div className="flex-1">
<div className="bg-gray-200 h-4 rounded w-1/2" />
<div className="bg-gray-200 h-3 rounded w-3/4 mt-2" />
</div>
</div>
))}
</div>
);
}
パターン2:ストリーミングHTMLとloading.tsxフォールバック
// app/products/loading.tsx
export default function ProductsLoading() {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse">
<div className="bg-gray-200 h-10 rounded w-1/4" />
<div className="bg-gray-200 h-6 rounded w-1/2 mt-4" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-8">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="bg-white rounded-lg shadow-sm overflow-hidden animate-pulse">
<div className="bg-gray-200 h-48" />
<div className="p-4">
<div className="bg-gray-200 h-5 rounded w-3/4" />
<div className="bg-gray-200 h-4 rounded w-1/2 mt-2" />
<div className="bg-gray-200 h-8 rounded w-1/3 mt-3" />
</div>
</div>
))}
</div>
</div>
</div>
);
}
// app/products/page.tsx
import { Suspense } from 'react';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '商品一覧 - マイストア',
description: '厳選商品をご覧ください',
};
export default function ProductsPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">商品一覧</h1>
<p className="text-gray-600 mt-2">厳選商品をご覧ください</p>
<div className="mt-8">
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
</div>
<div className="mt-12">
<Suspense fallback={<CategoryNavSkeleton />}>
<CategoryNav />
</Suspense>
</div>
</div>
</div>
);
}
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
interface ProductPageProps {
params: Promise<{ id: string }>;
}
export default async function ProductDetailPage({ params }: ProductPageProps) {
const { id } = await params;
const product = await getProduct(id);
if (!product) {
notFound();
}
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<ProductGallery images={product.images} />
<ProductInfo product={product} />
</div>
<div className="mt-12">
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={product.id} />
</Suspense>
</div>
<div className="mt-12">
<Suspense fallback={<RelatedProductsSkeleton />}>
<RelatedProducts category={product.category} excludeId={product.id} />
</Suspense>
</div>
</div>
</div>
);
}
パターン3:プログレッシブハイドレーションとダイナミックインポート
// app/components/InteractiveMap.tsx
'use client';
import { useState, useEffect } from 'react';
interface MapProps {
center: { lat: number; lng: number };
markers: Array<{ lat: number; lng: number; label: string }>;
}
export function InteractiveMap({ center, markers }: MapProps) {
const [MapLib, setMapLib] = useState<React.ComponentType<MapProps> | null>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
const el = document.getElementById('map-container');
if (el) observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isVisible) return;
import('./MapRenderer').then((mod) => {
setMapLib(() => mod.MapRenderer);
});
}, [isVisible]);
if (!isVisible || !MapLib) {
return (
<div id="map-container" className="bg-gray-100 rounded-lg h-96 flex items-center justify-center">
<p className="text-gray-400">地図を読み込み中...</p>
</div>
);
}
return <MapLib center={center} markers={markers} />;
}
// app/components/HeavyChart.tsx
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./ChartRenderer'), {
loading: () => (
<div className="bg-gray-100 rounded-lg h-80 animate-pulse flex items-center justify-center">
<span className="text-gray-400">チャートを読み込み中...</span>
</div>
),
ssr: false,
});
export function HeavyChart({ data }: { data: ChartData[] }) {
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold mb-4">データ分析</h3>
<Chart data={data} />
</div>
);
}
// app/components/CommentSection.tsx
import { Suspense } from 'react';
export function CommentSection({ postId }: { postId: string }) {
return (
<section className="mt-8">
<h2 className="text-2xl font-bold mb-4">コメント</h2>
<Suspense fallback={<CommentFormSkeleton />}>
<CommentForm postId={postId} />
</Suspense>
<Suspense fallback={<CommentListSkeleton />}>
<CommentList postId={postId} />
</Suspense>
</section>
);
}
// クライアントインタラクティブコンポーネント:遅延ロード
'use client';
import { useState, useTransition } from 'react';
function CommentForm({ postId }: { postId: string }) {
const [content, setContent] = useState('');
const [isPending, startTransition] = useTransition();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
await submitComment(postId, content);
setContent('');
});
};
return (
<form onSubmit={handleSubmit} className="mb-6">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full border rounded-lg p-3 focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="コメントを書く..."
/>
<button
type="submit"
disabled={isPending || !content.trim()}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isPending ? '送信中...' : 'コメント投稿'}
</button>
</form>
);
}
パターン4:並列データフェッチとPromise.all
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default async function DashboardPage() {
return (
<div className="min-h-screen bg-gray-50 p-6">
<Suspense fallback={<OverviewSkeleton />}>
<DashboardOverview />
</Suspense>
</div>
);
}
async function DashboardOverview() {
const [users, orders, revenue] = await Promise.all([
fetchUsers(),
fetchOrders(),
fetchRevenue(),
]);
return (
<div>
<div className="grid grid-cols-3 gap-6">
<UserStats users={users} />
<OrderStats orders={orders} />
<RevenueStats revenue={revenue} />
</div>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Suspense fallback={<ChartSkeleton />}>
<TrendChart data={revenue} />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentActivity orders={orders} />
</Suspense>
</div>
</div>
);
}
// lib/data-fetching.ts
import { cache } from 'react';
interface FetchOptions {
revalidate?: number;
tags?: string[];
}
function createFetchOptions(options?: FetchOptions): RequestInit {
return {
next: {
revalidate: options?.revalidate ?? 60,
tags: options?.tags ?? [],
},
};
}
export const fetchUsers = cache(async () => {
const res = await fetch('https://api.example.com/users', createFetchOptions({
revalidate: 300,
tags: ['users'],
}));
if (!res.ok) throw new Error('Failed to fetch users');
return res.json() as Promise<User[]>;
});
export const fetchOrders = cache(async () => {
const res = await fetch('https://api.example.com/orders', createFetchOptions({
revalidate: 60,
tags: ['orders'],
}));
if (!res.ok) throw new Error('Failed to fetch orders');
return res.json() as Promise<Order[]>;
});
export const fetchRevenue = cache(async () => {
const res = await fetch('https://api.example.com/revenue', createFetchOptions({
revalidate: 300,
tags: ['revenue'],
}));
if (!res.ok) throw new Error('Failed to fetch revenue');
return res.json() as Promise<RevenueData[]>;
});
export const fetchProduct = cache(async (id: string) => {
const res = await fetch(`https://api.example.com/products/${id}`, createFetchOptions({
revalidate: 600,
tags: [`product-${id}`],
}));
if (!res.ok) return null;
return res.json() as Promise<Product>;
});
export const fetchDashboardStats = cache(async () => {
const res = await fetch('https://api.example.com/dashboard/stats', createFetchOptions({
revalidate: 30,
tags: ['dashboard', 'stats'],
}));
if (!res.ok) throw new Error('Failed to fetch dashboard stats');
return res.json() as Promise<DashboardStats>;
});
// app/products/page.tsx — 並列フェッチ + Suspense分離
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<div>
<Suspense fallback={<FeaturedProductsSkeleton />}>
<FeaturedProducts />
</Suspense>
<Suspense fallback={<NewArrivalsSkeleton />}>
<NewArrivals />
</Suspense>
<Suspense fallback={<SaleProductsSkeleton />}>
<SaleProducts />
</Suspense>
</div>
);
}
async function FeaturedProducts() {
const products = await fetch('https://api.example.com/products/featured', {
next: { revalidate: 600, tags: ['featured'] },
}).then(r => r.json());
return <ProductGrid products={products} />;
}
async function NewArrivals() {
const products = await fetch('https://api.example.com/products/new', {
next: { revalidate: 300, tags: ['new-arrivals'] },
}).then(r => r.json());
return <ProductGrid products={products} />;
}
async function SaleProducts() {
const products = await fetch('https://api.example.com/products/sale', {
next: { revalidate: 60, tags: ['sale'] },
}).then(r => r.json());
return <ProductGrid products={products} />;
}
パターン5:プロダクション級Streaming SSRとエラーバウンダリ
// app/components/ErrorBoundary.tsx
'use client';
import { Component, type ReactNode } from 'react';
interface ErrorBoundaryProps {
fallback?: ReactNode;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<h3 className="text-red-800 font-semibold">読み込みに失敗しました</h3>
<p className="text-red-600 text-sm mt-2">{this.state.error?.message}</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg text-sm"
>
リトライ
</button>
</div>
);
}
return this.props.children;
}
}
// app/error.tsx — ルートレベルエラーバウンダリ
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[Streaming SSR Error]', error.digest, error.message);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-800">ページの読み込みエラー</h2>
<p className="text-gray-600 mt-2">エラーコード:{error.digest || 'UNKNOWN'}</p>
<button
onClick={reset}
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-lg"
>
再読み込み
</button>
</div>
</div>
);
}
// app/page.tsx — プロダクション級コンポジション
import { Suspense } from 'react';
import { ErrorBoundary } from './components/ErrorBoundary';
export default function HomePage() {
return (
<div className="min-h-screen">
<Header />
<main>
<HeroSection />
<ErrorBoundary fallback={<ProductGridFallback />}>
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<RecommendationsFallback />}>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<NewsletterFallback />}>
<Suspense fallback={<NewsletterSkeleton />}>
<Newsletter />
</Suspense>
</ErrorBoundary>
</main>
<Footer />
</div>
);
}
// app/components/fallbacks.tsx
export function ProductGridFallback() {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<p className="text-yellow-700">商品データを一時的に読み込めません。後でもう一度お試しください。</p>
<a href="/products" className="text-blue-600 underline mt-2 inline-block">
すべての商品を見る
</a>
</div>
);
}
export function RecommendationsFallback() {
return (
<div className="text-center py-8 text-gray-500">
<p>パーソナライズされたおすすめは一時的に利用できません</p>
</div>
);
}
export function NewsletterFallback() {
return (
<div className="text-center py-4 text-gray-400">
<p>ニュースレター購読は一時的に利用できません</p>
</div>
);
}
よくある落とし穴
落とし穴1:Suspenseバウンダリの粒度が細かすぎる
// ❌ 誤り:小さなコンポーネントごとにSuspenseを設定、複数のネットワークラウンドトリップが発生
<Suspense fallback={<Skeleton />}><UserName /></Suspense>
<Suspense fallback={<Skeleton />}><UserEmail /></Suspense>
<Suspense fallback={<Skeleton />}><UserAvatar /></Suspense>
<Suspense fallback={<Skeleton />}><UserBio /></Suspense>
// 4回のストリーミングラウンドトリップ——TTFBが逆に悪化
// ✅ 正しい:関連コンポーネントでSuspenseバウンダリを共有
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile /> {/* 内部にUserName, UserEmail, UserAvatar, UserBioを含む */}
</Suspense>
落とし穴2:asyncコンポーネントでエラー処理がない
// ❌ 誤り:async Server Componentにエラー処理がない
async function ProductList() {
const products = await fetchProducts(); // fetchが失敗するとページ全体が500
return <div>{products.map(p => <Card key={p.id} />)}</div>;
}
// ✅ 正しい:ErrorBoundary + Suspenseと組み合わせる
<ErrorBoundary fallback={<ProductListFallback />}>
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
</ErrorBoundary>
// ✅ より良い:コンポーネント内部でtry-catch
async function ProductList() {
try {
const products = await fetchProducts();
return <div>{products.map(p => <Card key={p.id} />)}</div>;
} catch (error) {
console.error('ProductList fetch failed:', error);
return <ProductListFallback />;
}
}
落とし穴3:loading.tsxとSuspenseの同時使用による二重スケルトン
// ❌ 誤り:loading.tsxとSuspense fallbackが同時に有効
// app/products/loading.tsx — ルート遷移時に表示
export default function Loading() {
return <FullPageSkeleton />;
}
// app/products/page.tsx — ページ内にもSuspenseがある
export default function ProductsPage() {
return (
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
);
}
// ユーザーが見るもの:FullPageSkeleton → ProductGridSkeleton → 実際のコンテンツ(2回の点滅)
// ✅ 正しい:loading.tsxはルートレベルのみ、ページ内はSuspense
// loading.tsxを削除し、layout.tsxでSuspenseを使用
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<ProductsNav />
<Suspense fallback={<ProductsPageSkeleton />}>
{children}
</Suspense>
</div>
);
}
落とし穴4:ダイナミックインポートでssr: falseを設定せずハイドレーションエラー
// ❌ 誤り:ブラウザAPIに依存するコンポーネントでSSRを無効化していない
import dynamic from 'next/dynamic';
const Map = dynamic(() => import('./Map'), {
loading: () => <div>読み込み中...</div>,
// ssr: false がない!Map内部でwindow/documentを使用、SSRでエラー
});
// ✅ 正しい:ブラウザ専用コンポーネントはSSRを無効化
const Map = dynamic(() => import('./Map'), {
loading: () => <MapSkeleton />,
ssr: false,
});
落とし穴5:Promise.allで1つの失敗が全てを失敗させる
// ❌ 誤り:Promise.all — 1つの失敗で全てが失敗
const [users, orders, revenue] = await Promise.all([
fetchUsers(), // これが失敗すると、ordersとrevenueも取得できない
fetchOrders(),
fetchRevenue(),
]);
// ✅ 正しい:Promise.allSettled + Suspense分離
const results = await Promise.allSettled([
fetchUsers(),
fetchOrders(),
fetchRevenue(),
]);
const users = results[0].status === 'fulfilled' ? results[0].value : [];
const orders = results[1].status === 'fulfilled' ? results[1].value : [];
const revenue = results[2].status === 'fulfilled' ? results[2].value : [];
// ✅ より良い:各データソースを独立したSuspenseバウンダリに配置、完全に分離
<Suspense fallback={<UsersSkeleton />}>
<UsersSection />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<OrdersSection />
</Suspense>
<Suspense fallback={<RevenueSkeleton />}>
<RevenueSection />
</Suspense>
エラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決方法 |
|---|---|---|---|
| 1 | Hydration failed because the server rendered HTML didn't match |
サーバーとクライアントのレンダリング内容が不一致 | Date/Random/ブラウザAPIを確認、suppressHydrationWarningを使用 |
| 2 | Functions cannot be passed directly to Client Components |
Server ComponentからClient Componentに関数を渡している | ロジックをClient Componentに移動、またはServer Actionを使用 |
| 3 | Route "/xxx" used cookies() but is statically generated |
静的ページで動的APIを呼び出している | export const dynamic = 'force-dynamic'を追加 |
| 4 | useSearchParams() should be wrapped in Suspense |
useSearchParamsがSuspenseでラップされていない | 外側にSuspense boundaryを追加 |
| 5 | Maximum call stack size exceeded |
Suspenseのネストによる無限ループ | asyncコンポーネントがSuspense fallbackで再帰参照していないか確認 |
| 6 | Text content did not match |
ストリーミングHTMLとハイドレーション内容が不一致 | Suspense fallbackの構造が実際のコンテンツと一致するか確認 |
| 7 | Cannot read properties of null (reading 'useContext') |
クライアントHookがServer Componentで使用されている | 'use client'ディレクティブを追加、またはコンポーネントを分割 |
| 8 | Module not found: Can't resolve 'fs' |
クライアントコンポーネントがNode.jsモジュールをインポート | Node.jsロジックをServer ComponentまたはRoute Handlerに移動 |
| 9 | Streaming chunk error: invalid byte sequence |
ストリーミング転送中のエンコーディング問題 | APIがUTF-8を返すことを確認、プロキシ設定を確認 |
| 10 | Abort signal timeout |
データフェッチのタイムアウト | タイムアウト時間を延長、またはReact.cacheで重複リクエストを回避 |
高度な最適化
1. Partial Prerendering(PPR)で静的シェル+動的コンテンツを実現
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default nextConfig;
// app/page.tsx — PPRモード
import { Suspense } from 'react';
export const experimental_ppr = true;
export default function HomePage() {
return (
<div className="min-h-screen">
{/* 静的シェル — ビルド時にレンダリング、CDNキャッシュ、TTFB ≈ 0 */}
<Header />
<HeroBanner />
<StaticCategories />
{/* 動的コンテンツ — リクエスト時にストリーミングレンダリング */}
<Suspense fallback={<PersonalizedFeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
{/* 静的コンテンツ — ビルド時にレンダリング */}
<Footer />
</div>
);
}
async function PersonalizedFeed() {
const user = await getCurrentUser();
const feed = await fetchPersonalizedFeed(user.id);
return <FeedGrid items={feed} />;
}
2. React Cacheとリクエスト重複排除
// lib/cached-fetch.ts
import { cache } from 'react';
export const getProduct = cache(async (id: string): Promise<Product | null> => {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 600, tags: [`product-${id}`] },
});
if (!res.ok) return null;
return res.json();
});
// 複数のコンポーネントがgetProduct('123')を呼び出しても、リクエストは1回だけ
async function ProductHeader({ id }: { id: string }) {
const product = await getProduct(id);
if (!product) return null;
return <h1 className="text-2xl font-bold">{product.name}</h1>;
}
async function ProductPrice({ id }: { id: string }) {
const product = await getProduct(id);
if (!product) return null;
return <span className="text-lg text-red-600">¥{product.price}</span>;
}
async function ProductStock({ id }: { id: string }) {
const product = await getProduct(id);
if (!product) return null;
return <span className={product.inStock ? 'text-green-600' : 'text-red-600'}>
{product.inStock ? '在庫あり' : '在庫切れ'}
</span>;
}
3. ストリーミング転送パフォーマンスモニタリング
// app/components/StreamingReporter.tsx
'use client';
import { useEffect } from 'react';
interface StreamingMetrics {
ttfb: number;
firstChunk: number;
lastChunk: number;
totalChunks: number;
}
export function StreamingReporter() {
useEffect(() => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') {
const nav = entry as PerformanceNavigationTiming;
const metrics: StreamingMetrics = {
ttfb: nav.responseStart - nav.requestStart,
firstChunk: nav.responseStart - nav.fetchStart,
lastChunk: nav.responseEnd - nav.fetchStart,
totalChunks: Math.round(nav.transferSize / 16384),
};
if (metrics.ttfb > 1000) {
console.warn('[Streaming SSR] 遅いTTFB:', metrics);
}
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'streaming_ssr_metrics', {
ttfb: metrics.ttfb,
first_chunk: metrics.firstChunk,
last_chunk: metrics.lastChunk,
});
}
}
}
});
observer.observe({ entryTypes: ['navigation'] });
return () => observer.disconnect();
}, []);
return null;
}
比較分析
| 項目 | 従来のSSR | Streaming SSR | ISR | CSR | SSG |
|---|---|---|---|---|---|
| TTFB | ⚠️高い(最も遅いデータを待つ) | ✅低い(静的シェルを先に) | ✅非常に低い(CDN) | ⚠️中程度(空のHTML) | ✅非常に低い(CDN) |
| 初回描画 | ⚠️一括 | ✅プログレッシブ | ✅即時 | ❌遅い(JSを待つ) | ✅即時 |
| リアルタイムデータ | ✅対応 | ✅対応 | ⚠️遅延あり | ✅対応 | ❌非対応 |
| SEO | ✅完全なHTML | ✅完全なHTML | ✅完全なHTML | ❌空のHTML | ✅完全なHTML |
| サーバー負荷 | ⚠️高い | ⚠️中程度 | ✅低い | ✅非常に低い | ✅非常に低い |
| エラー分離 | ❌ページ全体の失敗 | ✅チャンク単位のフォールバック | ❌ページ全体の失敗 | ✅コンポーネント単位 | ❌ビルド失敗 |
| キャッシュ粒度 | ⚠️ページ全体 | ✅チャンク単位 | ✅ページ全体 | ⚠️API単位 | ✅ページ全体 |
| ユースケース | リアルタイム動的ページ | 複雑な混合ページ | コンテンツサイト | 管理画面 | 静的コンテンツサイト |
まとめ:Streaming SSRは単なる技術的アップグレードではなく、レンダリングパラダイムの転換である——「全データの準備を待ってから応答する」から「ユーザーが見られるコンテンツを先に届ける」へ。2026年のプロダクション実践パス:まずSuspenseバウンダリで重要コンテンツブロックを分割(TTFB -60%+)→次にloading.tsxでルートレベルのフォールバックを実装→ダイナミックインポートでプログレッシブハイドレーションを実現(INP -40%+)→Promise.all + 独立Suspenseで並列フェッチを実現→最後にErrorBoundaryでプロダクション級のフォールトトレランスを実現。核心原則:ストリーミングできるならブロックしない、並列できるなら直列にしない、フォールバックできるなら500にしない。
オンラインツールおすすめ
- JSONフォーマッター:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- Hash計算:/ja/encode/hash
ブラウザローカルツールを無料で試す →