Next.js App Routerパフォーマンス:SSRからストリーミングレンダリングまでの7つのキー最適化戦略
前端工程
ファーストペイントが3秒から5秒に、App Routerはどうしたのか
Pages RouterからApp Routerに移行し、RSC(React Server Components)でページが速くなると思っていたら、逆に遅くなった。DevToolsにはウォーターフールリクエストが並び、LCPは2秒から5秒に急上昇、CLSもレッド。App Routerは銀の弾丸ではない——ストリーミングレンダリング、RSC、キャッシュメカニズムは能動的なチューニングが必要で、デフォルト設定はPages Routerより遅い可能性がある。
本記事はSSR最適化から出発し、ストリーミングレンダリング→RSC最適化→キャッシュ戦略→バンドルスリム化→Core Web Vitals→データフェッチ→ルートプリフェッチの7つのキー最適化戦略を完了し、App Routerが真にパフォーマンス優位性を発揮するようにする。
App Routerパフォーマンスコア概念
| 概念 | 説明 |
|---|---|
| React Server Components (RSC) | サーバーコンポーネント、ゼロクライアントJS、サーバーで直接レンダリング |
| Streaming SSR | ストリーミングSSR、チャンクHTML転送、ファーストペイント高速化 |
| Suspense Boundary | Suspense境界、ストリーミングレンダリングのチャンク粒度を制御 |
| Static/Dynamic Rendering | 静的/動的レンダリング、ビルド時vsリクエスト時 |
| ISR (Incremental Static Regeneration) | インクリメンタル静的再生成、定期的に静的ページを更新 |
| Route Segment Config | ルートセグメント設定、各ルートのレンダリングとキャッシュ動作を制御 |
| Partial Prerendering (PPR) | 部分プリレンダリング、静的シェル+動的コンテンツのハイブリッド |
| Turbopack | 新ビルドツール、Webpackより700倍高速(開発モード) |
レンダリングモード比較
静的レンダリング(SSG):
ビルド時HTML生成 → CDNキャッシュ → ユーザーが直接取得 → TTFB極低
動的レンダリング(SSR):
リクエスト時HTML生成 → ストリーミング転送 → ユーザーが段階的にコンテンツを見る → TTFB中程度
ストリーミングレンダリング(Streaming SSR):
リクエスト時レンダリング開始 → Suspenseチャンキング → 静的部分を先に送信 → 動的部分は準備完了後に追加
部分プリレンダリング(PPR):
ビルド時静的シェル生成 → リクエスト時動的コンテンツ充填 → SSG速度+SSRリアルタイム性
問題分析:App Routerパフォーマンスの5つの課題
- ウォーターフールデータフェッチ:Server Componentsのネストによるシリアルデータフェッチ、TTFBが線形増加
- クライアントJS肥大化:"use client"の誤用で多くのコンポーネントがクライアントバンドルに含まれる
- キャッシュ無効化の混乱:fetchキャッシュ、Routerキャッシュ、Full Route Cacheの3層キャッシュの調整が困難
- 不適切なストリーミング粒度:Suspense境界が多すぎると複数往復、少なすぎるとストリーミングの利点を失う
- Core Web Vitalsの退化:RSCのHTMLサイズが大きく、CLS/LCP/FIDがPages Routerより悪化する可能性
ステップバイステップ:7つのキー最適化戦略
戦略1:ストリーミングレンダリングとSuspense境界設計
// app/page.tsx
import { Suspense } from 'react';
export default function HomePage() {
return (
<div>
<HeroSection />
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</div>
);
}
function ProductGridSkeleton() {
return (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-gray-200 h-48 rounded-lg" />
<div className="bg-gray-200 h-4 mt-2 rounded w-3/4" />
<div className="bg-gray-200 h-4 mt-1 rounded w-1/2" />
</div>
))}
</div>
);
}
// app/components/ProductGrid.tsx
async function ProductGrid() {
const products = await fetchProducts();
return (
<div className="grid grid-cols-4 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
戦略2:RSCデータフェッチ最適化——並列リクエスト
// ❌ シリアルフェッチ:合計時間 = fetchA + fetchB + fetchC
async function Page() {
const dataA = await fetchA();
const dataB = await fetchB(dataA.id);
const dataC = await fetchC();
return <Dashboard a={dataA} b={dataB} c={dataC} />;
}
// ✅ 並列フェッチ:合計時間 = max(fetchA, fetchB, fetchC)
async function Page() {
const [dataA, dataB, dataC] = await Promise.all([
fetchA(),
fetchB(),
fetchC(),
]);
return <Dashboard a={dataA} b={dataB} c={dataC} />;
}
// ✅ より良い:Suspense境界で分離、独立ストリーミング
async function Page() {
return (
<div>
<Suspense fallback={<SkeletonA />}>
<ComponentA />
</Suspense>
<Suspense fallback={<SkeletonB />}>
<ComponentB />
</Suspense>
<Suspense fallback={<SkeletonC />}>
<ComponentC />
</Suspense>
</div>
);
}
async function ComponentA() {
const dataA = await fetchA();
return <DashboardA data={dataA} />;
}
async function ComponentB() {
const dataB = await fetchB();
return <DashboardB data={dataB} />;
}
async function ComponentC() {
const dataC = await fetchC();
return <DashboardC data={dataC} />;
}
戦略3:3層キャッシュ調整
// 1. fetchレベルキャッシュ
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: {
revalidate: 3600, // ISR: 毎時再検証
tags: ['products'], // オンデマンド無効化
},
});
return res.json();
}
// 2. ルートセグメント設定
// app/products/page.tsx
export const revalidate = 3600; // ページレベルISR
export const dynamic = 'force-static'; // 強制静的レンダリング
export const dynamicParams = true; // 動的パラメータを許可
// 3. オンデマンドキャッシュ無効化
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
if (body.tag) {
revalidateTag(body.tag);
}
if (body.path) {
revalidatePath(body.path);
}
return Response.json({ revalidated: true, now: Date.now() });
}
戦略4:クライアントバンドルスリム化
// ❌ lodash全体がクライアントにバンドルされる
'use client';
import { debounce } from 'lodash';
export function SearchInput() {
const handleChange = debounce((e) => { ... }, 300);
return <input onChange={handleChange} />;
}
// ✅ 軽量代替を使用または必要な関数のみインポート
'use client';
import { useDeferredValue, useState } from 'react';
export function SearchInput() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
// ✅ より良い:重いインタラクションはServer Componentに、クライアントは最小限のインタラクションのみ
async function ProductList({ query }: { query: string }) {
const products = await searchProducts(query);
return (
<div>
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
// 最小クライアントコンポーネント
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
export function SearchForm() {
const router = useRouter();
const searchParams = useSearchParams();
return (
<form>
<input
defaultValue={searchParams.get('q') || ''}
onChange={(e) => {
router.push(`/products?q=${e.target.value}`);
}}
/>
</form>
);
}
戦略5:Core Web Vitals最適化
// LCP最適化:重要リソースのプリロード
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
other: {
'preconnect': 'https://api.example.com',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
</head>
<body>
{children}
</body>
</html>
);
}
// CLS最適化:画像プレースホルダー
import Image from 'next/image';
function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
placeholder="blur"
blurDataURL="/hero-blur.jpg"
sizes="(max-width: 768px) 100vw, 1200px"
/>
);
}
// INP最適化:メインスレッドブロックの削減
'use client';
import { useTransition } from 'react';
export function FilterButton({ filter, onFilter }: { filter: string; onFilter: (f: string) => void }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => onFilter(filter))}
className={isPending ? 'opacity-50' : ''}
>
{filter}
</button>
);
}
戦略6:データフェッチパターン最適化
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const products = await fetchTopProducts();
return products.map((p) => ({ id: p.id }));
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
if (!product) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={product.id} />
</Suspense>
</div>
);
}
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 600, tags: [`product-${id}`] },
});
if (!res.ok) return null;
return res.json();
}
async function fetchTopProducts() {
const res = await fetch('https://api.example.com/products/top', {
next: { revalidate: 3600, tags: ['products'] },
});
return res.json();
}
async function RelatedProducts({ productId }: { productId: string }) {
const products = await fetch(`https://api.example.com/products/${productId}/related`, {
next: { revalidate: 300 },
}).then(r => r.json());
return (
<div className="grid grid-cols-4 gap-4">
{products.map((p: any) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
戦略7:ルートプリフェッチとプリロード
// app/components/Navigation.tsx
import Link from 'next/link';
export function Navigation() {
return (
<nav>
<Link href="/products" prefetch={true}>
製品
</Link>
<Link href="/about" prefetch={false}>
概要
</Link>
<Link href="/contact">
お問い合わせ
</Link>
</nav>
);
}
// ビューポート対応プリフェッチ
'use client';
import { useRouter } from 'next/navigation';
export function usePrefetchOnHover(href: string) {
const router = useRouter();
const handleMouseEnter = () => {
router.prefetch(href);
};
return { onMouseEnter: handleMouseEnter };
}
function SmartLink({ href, children }: { href: string; children: React.ReactNode }) {
const { onMouseEnter } = usePrefetchOnHover(href);
return (
<Link href={href} onMouseEnter={onMouseEnter}>
{children}
</Link>
);
}
落とし穴ガイド
落とし穴1:Server ComponentでクライアントAPIを使用
// ❌ 誤り:Server ComponentでuseStateを使用
async function Counter() {
const [count, setCount] = useState(0); // コンパイルエラー!
return <div>{count}</div>;
}
// ✅ 正しい:Server + Clientコンポーネントに分割
async function CounterWrapper() {
const initialValue = await fetchCount();
return <CounterClient initial={initialValue} />;
}
'use client';
function CounterClient({ initial }: { initial: number }) {
const [count, setCount] = useState(initial);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
落とし穴2:fetchキャッシュのデフォルト動作変更
// ❌ 誤り:App Routerはfetchをデフォルトでキャッシュ(Pages Routerと異なる)
async function getData() {
const res = await fetch('https://api.example.com/data');
// デフォルトでキャッシュ!データはリアルタイムで更新されない
return res.json();
}
// ✅ 正しい:キャッシュ戦略を明示的に宣言
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store', // 常に最新データをフェッチ
});
return res.json();
}
// ✅ またはISRを使用
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }, // 60秒後に再検証
});
return res.json();
}
落とし穴3:Suspense境界が多すぎてハイドレーション遅延
// ❌ 誤り:小さなコンポーネントごとにSuspense
<Suspense fallback={<S />}><UserName /></Suspense>
<Suspense fallback={<S />}><UserEmail /></Suspense>
<Suspense fallback={<S />}><UserAvatar /></Suspense>
// 3回のネットワーク往復!
// ✅ 正しい:関連コンポーネントは1つのSuspense境界を共有
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile /> {/* UserName, UserEmail, UserAvatarを含む */}
</Suspense>
落とし穴4:"use client"の乱用によるバンドル肥大化
// ❌ 誤り:ページ全体をクライアントコンポーネントとしてマーク
'use client';
export default function ProductsPage() {
// ページ全体とすべての子コンポーネントがクライアントバンドルに
return <ProductList />;
}
// ✅ 正しい:インタラクティブ部分のみクライアントコンポーネント
export default async function ProductsPage() {
const products = await fetchProducts(); // サーバー側フェッチ
return (
<div>
<h1>製品一覧</h1> {/* Server Component */}
<ProductGrid products={products} /> {/* Server Component */}
<AddToCartButton /> {/* 'use client' — これだけがクライアントを必要とする */}
</div>
);
}
落とし穴5:動的ルートにgenerateStaticParams未設定
// ❌ 誤り:動的ルートにgenerateStaticParamsがなく、毎回SSR
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return <div>{product.name}</div>;
}
// ✅ 正しい:generateStaticParamsを追加してSSGを実現
export async function generateStaticParams() {
const products = await fetchAllProducts();
return products.map((p) => ({ id: p.id }));
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return <div>{product.name}</div>;
}
エラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決方法 |
|---|---|---|---|
| 1 | Functions cannot be passed directly to Client Components |
Server Componentが関数をClient Componentに渡している | 関数ロジックをClient Component内に移動 |
| 2 | Route "/xxx" used \cookies()` but is statically generated` |
静的ページで動的APIを呼び出し | export const dynamic = 'force-dynamic'を追加 |
| 3 | Hydration mismatch |
サーバーとクライアントのレンダリング内容が不一致 | Date.now()、random等を確認、suppressHydrationWarningを使用 |
| 4 | Maximum call stack size exceeded |
循環依存または無限再レンダリング | useEffect依存を確認、循環state更新を回避 |
| 5 | fetch failed with status 500 |
APIリクエスト失敗しキャッシュされた | cache: 'no-store'を使用またはエラー処理を追加 |
| 6 | Module not found: Can't resolve 'xxx' |
クライアントコンポーネントがNode.jsモジュールをインポート | Node.jsロジックをServer ComponentまたはRoute Handlerに移動 |
| 7 | useSearchParams() should be wrapped in Suspense |
useSearchParamsがSuspenseでラップされていない | コンポーネントの外側にSuspense boundaryを追加 |
| 8 | Static generation timeout |
generateStaticParamsの実行タイムアウト | データフェッチを最適化、staticGenerationTimeoutを増加 |
| 9 | The "use cache" directive requires experimental flag |
実験的キャッシュAPIを使用 | next.config.jsでexperimental.useCacheを有効化 |
| 10 | Turbopack build error |
Turbopackが一部Webpack loaderと非互換 | 開発はTurbopack、本番はWebpackまたは依存をアップグレード |
高度な最適化
1. Partial Prerendering(PPR)
// app/page.tsx — PPRを有効化(Next.js 15 experimental)
export const experimental_ppr = true;
export default function HomePage() {
return (
<div>
{/* 静的シェル — ビルド時にレンダリング、CDNキャッシュ */}
<Header />
<HeroBanner />
{/* 動的コンテンツ — リクエスト時にレンダリング、ストリーミング */}
<Suspense fallback={<PersonalizedFeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
{/* 静的コンテンツ — ビルド時にレンダリング */}
<Footer />
</div>
);
}
async function PersonalizedFeed() {
const user = await getCurrentUser();
const feed = await fetchPersonalizedFeed(user.id);
return <Feed items={feed} />;
}
2. React Cacheと単一フェッチ保証
import { cache } from 'react';
export const getProduct = cache(async (id: string) => {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 600 },
});
return res.json();
});
// 複数のコンポーネントがgetProduct(id)を呼び出しても、リクエストは1回のみ
async function ProductHeader({ id }: { id: string }) {
const product = await getProduct(id);
return <h1>{product.name}</h1>;
}
async function ProductPrice({ id }: { id: string }) {
const product = await getProduct(id); // キャッシュヒット、新しいリクエストなし
return <span>{product.price}</span>;
}
3. Script読み込み戦略最適化
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
{children}
<Script
src="https://analytics.example.com/script.js"
strategy="lazyOnload"
/>
<Script
src="https://cdn.example.com/chat-widget.js"
strategy="afterInteractive"
/>
</body>
</html>
);
}
比較分析
| 次元 | App Router | Pages Router | Remix | Nuxt 3 | SvelteKit |
|---|---|---|---|---|---|
| RSCサポート | ✅ネイティブ | ❌ | ❌ | ❌ | ❌ |
| ストリーミングレンダリング | ✅ネイティブ | ❌ | ✅ | ✅ | ✅ |
| 静的生成 | ✅SSG/ISR | ✅SSG/ISR | ⚠️限定的 | ✅ | ✅ |
| キャッシュレイヤー | ✅3層 | ⚠️1層 | ⚠️1層 | ⚠️2層 | ⚠️2層 |
| バンドル最適化 | ✅RSCゼロJS | ⚠️フル | ⚠️ | ✅ | ✅コンパイル最適化 |
| 学習曲線 | ⭐急 | ⭐緩やか | ⭐中程度 | ⭐中程度 | ⭐中程度 |
| エコシステム成熟度 | ⭐高い | ⭐非常に高い | ⭐中程度 | ⭐高い | ⭐中程度 |
| 開発体験 | ⭐Turbopack | ⭐Webpack | ⭐Vite | ⭐Vite | ⭐Vite |
まとめ:App Routerのパフォーマンスは自動的には得られない——よりきめ細かな制御を与えるが、レンダリングモデルのより深い理解を要求する。2026年の最適化パス:まずSuspense境界でストリーミングレンダリングを実装(ファーストペイント50%+改善)→次に"use client"の乱用をクリーンアップ(バンドル30%+削減)→3層キャッシュ戦略を調整→最後にPPRで究極の静的シェル+動的コンテンツの組み合わせを実現。重要原則:Server優先、並列優先、キャッシュ優先。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- Hash計算:/ja/encode/hash
- JWTデコード:/ja/encode/jwt-decode
ブラウザローカルツールを無料で試す →
#Next.js#App Router#性能优化#SSR#流式渲染#2026#React