FRONTEND2025-08-05📖 4分

Next.js 15 パフォーマンス最適化ガイド:Core Web Vitals改善の実践テクニック

Next.js 15 パフォーマンス最適化ガイド:Core Web Vitals改善の実践テクニック

Next.js 15の新機能を活用したパフォーマンス最適化のベストプラクティスを詳しく解説。Server Components、キャッシュ戦略、画像最適化からCore Web Vitals改善まで実践的なノウハウを徹底解説。

髙木 晃宏

代表 / エンジニア

👨‍💼

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 build

Dynamic 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のパフォーマンス改善でお困りの方は、お気軽にご相談ください。