TL;DR
- Server Componentsでクライアントバンドルを50%以上削減可能
- 適切なキャッシュ戦略でTTFBを300ms以下に
- next/imageとnext/fontで画像・フォント最適化を自動化
- Streaming SSRでFCPを大幅改善
はじめに:なぜパフォーマンスが重要か
Googleの調査によると、ページ読み込み時間が1秒から3秒に増加すると、直帰率は32%上昇します。5秒になると90%に達します。
さらに、Core Web Vitalsは2021年からGoogle検索のランキング要因となっています。パフォーマンスは「あれば良い」ではなく「必須」の時代です。
私たちのチームでは、Next.js 15への移行とパフォーマンス最適化により、以下の改善を達成しました:
- LCP: 4.2秒 → 1.8秒(57%改善)
- FID: 180ms → 45ms(75%改善)
- CLS: 0.25 → 0.05(80%改善)
- 直帰率: 45% → 28%(38%改善)
本記事では、これらの改善を実現した具体的なテクニックを解説します。
Next.js 15の新機能とパフォーマンス
Turbopackの活用
Next.js 15では、Turbopackが安定版となりました。開発時のビルド速度が大幅に向上します。
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
}
}Turbopackのメリット:
- 初回コンパイル:最大76%高速化
- HMR(Hot Module Replacement):最大96%高速化
- メモリ使用量:30%削減
Partial Prerendering(PPR)
Next.js 15の目玉機能。静的コンテンツと動的コンテンツを1つのページで最適に組み合わせます。
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { ProductInfo } from './ProductInfo';
import { ProductReviews } from './ProductReviews';
import { RecommendedProducts } from './RecommendedProducts';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* 静的にプリレンダリング */}
<ProductInfo productId={params.id} />
{/* 動的コンテンツはストリーミング */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendedSkeleton />}>
<RecommendedProducts productId={params.id} />
</Suspense>
</div>
);
}// next.config.js
module.exports = {
experimental: {
ppr: true
}
};Server Componentsの最適化
クライアントコンポーネントの最小化
Server Componentsはデフォルトです。'use client'は本当に必要な場合のみ使用します。
// ❌ 悪い例:全体をClient Componentにしている
'use client';
import { useState } from 'react';
export default function ProductPage({ product }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<img src={product.image} alt={product.name} />
{/* インタラクティブな部分はここだけ */}
<div>
<button onClick={() => setQuantity(q => q - 1)}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(q => q + 1)}>+</button>
</div>
</div>
);
}// ✅ 良い例:インタラクティブな部分だけをClient Componentに分離
// app/products/[id]/page.tsx (Server Component)
import { getProduct } from '@/lib/products';
import { QuantitySelector } from './QuantitySelector';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<img src={product.image} alt={product.name} />
{/* Client Componentは必要最小限 */}
<QuantitySelector productId={product.id} />
</div>
);
}
// app/products/[id]/QuantitySelector.tsx (Client Component)
'use client';
import { useState } from 'react';
export function QuantitySelector({ productId }: { productId: string }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<button onClick={() => setQuantity(q => Math.max(1, q - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(q => q + 1)}>+</button>
</div>
);
}コンポーネント分割の指針
Server Component に適している:
✅ データフェッチ
✅ バックエンドリソースへのアクセス
✅ 機密情報(APIキー等)の使用
✅ 大きな依存関係の使用
✅ 静的なUI
Client Component に適している:
✅ イベントハンドラ(onClick, onChange等)
✅ useState, useEffect等のHooks
✅ ブラウザAPI(localStorage, geolocation等)
✅ カスタムHooksの使用データフェッチングとキャッシュ戦略
fetch APIの拡張オプション
Next.js 15では、fetch APIが拡張されキャッシュを細かく制御できます。
// キャッシュ戦略の例
// 1. 静的データ(ビルド時に取得、再検証なし)
const staticData = await fetch('https://api.example.com/static', {
cache: 'force-cache' // デフォルト
});
// 2. 動的データ(毎リクエストで取得)
const dynamicData = await fetch('https://api.example.com/dynamic', {
cache: 'no-store'
});
// 3. 時間ベースの再検証(ISR)
const revalidatedData = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // 1時間ごとに再検証
});
// 4. タグベースの再検証
const taggedData = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
});
// revalidateTag('products') で手動再検証unstable_cache の活用
データベースクエリなど、fetch以外のデータソースをキャッシュします。
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
const getCachedUser = unstable_cache(
async (userId: string) => {
return db.user.findUnique({
where: { id: userId },
include: { posts: true }
});
},
['user'], // キャッシュキー
{
revalidate: 3600, // 1時間
tags: ['user'] // 再検証用タグ
}
);
export default async function UserProfile({ params }: { params: { id: string } }) {
const user = await getCachedUser(params.id);
// ...
}並列データフェッチ
// ❌ 悪い例:直列フェッチ(遅い)
export default async function Dashboard() {
const user = await getUser(); // 500ms
const posts = await getPosts(); // 500ms
const comments = await getComments(); // 500ms
// 合計: 1500ms
}
// ✅ 良い例:並列フェッチ(速い)
export default async function Dashboard() {
const [user, posts, comments] = await Promise.all([
getUser(), // 500ms
getPosts(), // 500ms
getComments() // 500ms
]);
// 合計: 500ms(最も遅いものに依存)
}画像最適化
next/image の完全活用
import Image from 'next/image';
// 基本的な使用法
export function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1920}
height={1080}
priority // LCP画像には必須
quality={85}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
// レスポンシブ画像
export function ResponsiveImage() {
return (
<Image
src="/product.jpg"
alt="Product"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'cover' }}
/>
);
}
// 外部画像の最適化
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/uploads/**'
}
],
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384]
}
};画像の遅延読み込み戦略
// ファーストビュー以外の画像は遅延読み込み
export function ProductGallery({ images }: { images: string[] }) {
return (
<div className="grid grid-cols-3 gap-4">
{images.map((src, index) => (
<Image
key={src}
src={src}
alt={`Product image ${index + 1}`}
width={400}
height={400}
// 最初の画像以外は遅延読み込み
loading={index === 0 ? 'eager' : 'lazy'}
priority={index === 0}
/>
))}
</div>
);
}フォント最適化
next/font の活用
// app/layout.tsx
import { Inter, Noto_Sans_JP } from 'next/font/google';
// 可変フォントを使用(ファイルサイズ削減)
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
// 日本語フォント(必要なウェイトのみ)
const notoSansJP = Noto_Sans_JP({
subsets: ['latin'],
weight: ['400', '700'], // 必要なウェイトのみ指定
display: 'swap',
variable: '--font-noto-sans-jp',
preload: true
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
<body>{children}</body>
</html>
);
}/* globals.css */
body {
font-family: var(--font-noto-sans-jp), var(--font-inter), sans-serif;
}ローカルフォントの使用
import localFont from 'next/font/local';
const myFont = localFont({
src: [
{
path: './fonts/MyFont-Regular.woff2',
weight: '400',
style: 'normal'
},
{
path: './fonts/MyFont-Bold.woff2',
weight: '700',
style: 'normal'
}
],
display: 'swap',
preload: true
});バンドルサイズ削減
bundle-analyzer の活用
npm install @next/bundle-analyzer// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
});
module.exports = withBundleAnalyzer({
// その他の設定
});ANALYZE=true npm run buildDynamic Import の活用
import dynamic from 'next/dynamic';
// 重いコンポーネントは動的インポート
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false // クライアントサイドのみ
});
// 条件付きインポート
const AdminPanel = dynamic(() => import('@/components/AdminPanel'), {
loading: () => <AdminSkeleton />
});
export default function Dashboard({ isAdmin }: { isAdmin: boolean }) {
return (
<div>
<HeavyChart data={chartData} />
{isAdmin && <AdminPanel />}
</div>
);
}Tree Shaking の最適化
// ❌ 悪い例:全体インポート
import { format, parse, addDays, subDays, differenceInDays } from 'date-fns';
// ✅ 良い例:必要な関数のみインポート
import format from 'date-fns/format';
import addDays from 'date-fns/addDays';
// ❌ 悪い例:lodash全体
import _ from 'lodash';
_.map(items, fn);
// ✅ 良い例:必要な関数のみ
import map from 'lodash/map';
map(items, fn);package.json の sideEffects
{
"sideEffects": [
"*.css",
"*.scss"
]
}Streaming SSR と Suspense
Streaming の仕組み
従来のSSR:
サーバー: [データ取得 → HTML生成] ────────────────────────▶ [完全なHTML送信]
ブラウザ: [受信] → [表示]
Streaming SSR:
サーバー: [シェル送信] → [データ取得しながら順次送信] ──────▶
ブラウザ: [シェル表示] → [順次更新] ──────────────────────▶実践的なStreaming実装
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { DashboardSkeleton } from './DashboardSkeleton';
export default function DashboardPage() {
return (
<div className="dashboard">
{/* 即座に表示(静的部分) */}
<header>
<h1>Dashboard</h1>
<nav>{/* ナビゲーション */}</nav>
</header>
{/* 優先度高:まず表示したい */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
{/* 優先度中:次に表示 */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* 優先度低:最後でOK */}
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</div>
);
}
// 各コンポーネントは async Server Component
async function DashboardStats() {
const stats = await fetchStats(); // 独立してデータ取得
return <StatsDisplay data={stats} />;
}
async function RevenueChart() {
const data = await fetchRevenueData();
return <Chart data={data} />;
}
async function RecentTransactions() {
const transactions = await fetchTransactions();
return <TransactionTable data={transactions} />;
}loading.tsx の活用
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="dashboard">
<header>
<h1>Dashboard</h1>
<nav>{/* ナビゲーション */}</nav>
</header>
<div className="animate-pulse">
<div className="h-32 bg-gray-200 rounded mb-4" />
<div className="h-64 bg-gray-200 rounded mb-4" />
<div className="h-96 bg-gray-200 rounded" />
</div>
</div>
);
}Core Web Vitals の改善
LCP(Largest Contentful Paint)
// 1. priority属性でLCP画像を優先読み込み
<Image src="/hero.jpg" priority />
// 2. プリロードヒント
// app/layout.tsx
export const metadata = {
other: {
'link': [
{ rel: 'preload', href: '/hero.jpg', as: 'image' }
]
}
};
// 3. フォントのプリロード
const font = Inter({
preload: true,
display: 'swap'
});
// 4. クリティカルCSSのインライン化(自動)
// Next.jsは自動でファーストビューに必要なCSSをインライン化FID / INP(First Input Delay / Interaction to Next Paint)
// 1. 重い処理は Web Worker に移動
// workers/heavyTask.ts
self.onmessage = (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
};
// 2. useTransition で優先度制御
'use client';
import { useTransition, useState } from 'react';
export function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value); // 高優先度:入力は即座に反映
startTransition(() => {
// 低優先度:検索結果は遅延可
setResults(filterItems(value));
});
};
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</div>
);
}
// 3. useDeferredValue で表示の遅延
import { useDeferredValue } from 'react';
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => filterItems(deferredQuery), [deferredQuery]);
return <ResultsList results={results} />;
}CLS(Cumulative Layout Shift)
// 1. 画像のサイズを明示
<Image
src="/product.jpg"
width={400}
height={300}
alt="Product"
/>
// 2. 動的コンテンツのスペース確保
export function AdBanner() {
return (
<div style={{ minHeight: '250px' }}> {/* 最小高さを確保 */}
<Suspense fallback={<AdSkeleton />}>
<Ad />
</Suspense>
</div>
);
}
// 3. フォントのFOUT対策
const font = Inter({
display: 'swap',
adjustFontFallback: true // フォールバックフォントのサイズ調整
});
// 4. Skeletonの正確なサイズ
export function CardSkeleton() {
return (
<div className="w-full h-[200px] bg-gray-200 rounded animate-pulse" />
);
}パフォーマンス計測
Next.js Analytics
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}カスタムメトリクス
// app/components/WebVitals.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
// 分析サービスに送信
console.log(metric);
// Google Analyticsに送信
window.gtag?.('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_label: metric.id,
non_interaction: true
});
});
return null;
}実践的なチェックリスト
デプロイ前の確認事項
□ Lighthouse スコア
- Performance: 90以上
- Accessibility: 90以上
- Best Practices: 90以上
- SEO: 90以上
□ Core Web Vitals
- LCP: 2.5秒以下
- FID/INP: 100ms以下
- CLS: 0.1以下
□ バンドルサイズ
- First Load JS: 100KB以下(目標)
- 不要なパッケージがないか確認
□ 画像最適化
- next/image を使用
- 適切なサイズとフォーマット
- LCP画像にpriority属性
□ フォント最適化
- next/font を使用
- display: 'swap' 設定
- 必要なウェイトのみ読み込みまとめ
Next.js 15のパフォーマンス最適化は、以下の3つの柱で構成されます:
1. サーバーサイドの最適化
- Server Componentsでクライアントバンドルを削減
- 適切なキャッシュ戦略でTTFBを改善
- Streaming SSRでFCPを向上
2. クライアントサイドの最適化
- Dynamic Importで初期読み込みを軽量化
- useTransition/useDeferredValueでINPを改善
- 画像・フォントの最適化
3. 継続的な計測と改善
- Core Web Vitalsの定期モニタリング
- bundle-analyzerでバンドルサイズを監視
- 実ユーザーメトリクス(RUM)の収集
パフォーマンス最適化は一度で完了するものではありません。継続的な計測と改善のサイクルを回すことが重要です。
リソース
Next.jsのパフォーマンス改善でお困りの方は、お気軽にご相談ください。
