Next.js パフォーマンス最適化の実務パターン|業務で即実装できるテクニック集

React / Next.js

Next.js パフォーマンス最適化の実務パターン|業務で即実装できるテクニック集

Last Updated: 2024

はじめに

Next.jsを使ったプロジェクトは増え続けていますが、本番環境でのパフォーマンス低下に悩むチームも多いはずです。適切な最適化がないと、Lighthouseスコアは低下し、ユーザーの離脱率も上がります。本記事では、実務で即導入できるNext.jsのパフォーマンス最適化パターンを、具体的なコード例とともに紹介します。

簡易的な解説|Next.jsのパフォーマンス最適化とは

Next.jsは標準で様々な最適化機能を提供していますが、プロジェクト固有の要件に合わせてカスタマイズが必要です。主な最適化対象は以下の通りです:

  • 画像最適化:Next.js Image コンポーネントによる自動フォーマット変換と遅延読み込み
  • バンドルサイズ削減:動的インポートとコード分割
  • キャッシング戦略:ISR(Incremental Static Regeneration)やServer-Side Caching
  • フォント最適化:next/fontによるセルフホストフォント
  • スクリプト最適化:外部スクリプトの読み込み戦略

これらを組み合わせることで、Core Web Vitals(LCP、FID、CLS)を改善し、検索順位向上につながります。

業務でのユースケース

実際の業務環境では、以下のようなシナリオでパフォーマンス最適化が求められます。

ケース1:ECサイトの商品一覧ページの高速化

数千の商品画像を扱うECサイトでは、すべての画像を最初から読み込むと初期表示時間が数秒単位で遅延します。ページネーション、遅延読み込み、画像の自動フォーマット変換が必須です。

ケース2:メディアサイトの大量記事配信

日々更新されるニュースサイトでは、すべての記事を静的生成するのは非現実的です。ISRを使って一度キャッシュされた記事は高速に配信しつつ、新記事はオンデマンド生成する必要があります。

ケース3:SaaSアプリケーションのダッシュボード

複数のチャート、テーブル、フォームが同居するダッシュボードでは、すべてのコンポーネントを初期読み込みすると不要なJavaScriptが増加します。動的インポートとRoute Segmentsを活用した部分的な読み込みが有効です。

実装コード|実務レベルの具体例

パターン1:画像最適化とLazyLoading

ECサイトの商品一覧では、以下のコンポーネントを使用します。

// components/ProductCard.tsx
import Image from 'next/image';
import { useState } from 'react';

interface Product {
  id: string;
  name: string;
  image: string;
  price: number;
}

export function ProductCard({ product }: { product: Product }) {
  const [isLoading, setIsLoading] = useState(true);

  return (
    <div className=\"product-card\">
      <div className=\"image-container\">
        <Image
          src={product.image}
          alt={product.name}
          width={300}
          height={300}
          quality={75}
          priority={false}
          loading=\"lazy\"
          onLoadingComplete={() => setIsLoading(false)}
          className={isLoading ? 'blur-sm' : ''}
          sizes=\"(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw\"
        />
      </div>
      <h3>{product.name}</h3>
      <p className=\"price\">¥{product.price}</p>
    </div>
  );
}

重要なポイント:

  • quality={75}:JPEG品質を75%に設定し、ファイルサイズを削減
  • loading=\"lazy\":ViewportIntersection APIで遅延読み込み
  • sizes属性:レスポンシブ画像で不要な大きなサイズの読み込みを防止
  • blur-sm:画像読み込み中のプレースホルダー効果

パターン2:ISRを使った記事ページのキャッシング

ニュースサイトでは、記事は頻繁に更新されないため、ISRで一度キャッシュすると効率的です。

// app/articles/[slug]/page.tsx
import { notFound } from 'next/navigation';

interface Article {
  id: string;
  slug: string;
  title: string;
  content: string;
  publishedAt: string;
}

async function getArticle(slug: string): Promise<Article> {
  try {
    const res = await fetch(`https://api.example.com/articles/${slug}`, {
      next: { revalidate: 3600 } // 1時間ごとに再検証
    });
    
    if (!res.ok) {
      return null;
    }
    
    return res.json();
  } catch (error) {
    console.error('Article fetch error:', error);
    return null;
  }
}

export async function generateStaticParams() {
  // ビルド時に人気の記事のみ事前生成
  const res = await fetch('https://api.example.com/articles/popular?limit=50');
  const articles: Article[] = await res.json();
  
  return articles.map((article) => ({
    slug: article.slug,
  }));
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const article = await getArticle(params.slug);
  
  if (!article) {
    return { title: 'Not Found' };
  }
  
  return {
    title: article.title,
    description: article.content.substring(0, 160),
    openGraph: {
      title: article.title,
      description: article.content.substring(0, 160),
    }
  };
}

export default async function ArticlePage({ params }: { params: { slug: string } }) {
  const article = await getArticle(params.slug);
  
  if (!article) {
    notFound();
  }
  
  return (
    <article className=\"article\">
      <h1>{article.title}</h1>
      <time dateTime={article.publishedAt}>
        {new Date(article.publishedAt).toLocaleDateString('ja-JP')}
      </time>
      <div className=\"content\" dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

このアプローチのメリット:

  • 初回アクセス時に記事が静的生成され、キャッシュされる
  • revalidate: 3600により、1時間ごとにバックグラウンド再生成
  • 生成済みページはCDNから超高速配信
  • 新記事も自動的にオンデマンド生成される

パターン3:動的インポートとコード分割

ダッシュボードのような複雑なUIでは、すべてのコンポーネントを初期読み込みしません。

// app/dashboard/page.tsx
'use client';

import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import { LoadingSpinner } from '@/components/LoadingSpinner';

// 重いコンポーネントを動的インポート
const AnalyticsChart = dynamic(() => import('@/components/AnalyticsChart'), {
  loading: () => <LoadingSpinner />,
  ssr: false // クライアント側のみで描画
});

const RevenueTable = dynamic(() => import('@/components/RevenueTable'), {
  loading: () => <LoadingSpinner />,
});

const UserBehavior = dynamic(() => import('@/components/UserBehavior'), {
  loading: () => <LoadingSpinner />,
  ssr: false
});

export default function DashboardPage() {
  return (
    <div className=\"dashboard\">
      <h1>ダッシュボード</h1>
      
      <div className=\"grid\">
        <Suspense fallback={<LoadingSpinner />}>
          <AnalyticsChart />
        </Suspense>
        
        <Suspense fallback={<LoadingSpinner />}>
          <RevenueTable />
        </Suspense>
        
        <Suspense fallback={<LoadingSpinner />}>
          <UserBehavior />
        </Suspense>
      </div>
    </div>
  );
}

パターン4:フォント最適化

次のフォント設定は、外部のGoogle Fontsサーバーではなくセルフホスト提供します。

// app/layout.tsx
import { Noto_Sans_JP } from 'next/font/google';

const notoSansJP = Noto_Sans_JP({
  subsets: ['latin', 'japanese'],
  weight: ['400', '700'],
  variable: '--font-noto-sans-jp',
  fallback: ['system-ui', 'sans-serif'],
  preload: true,
  display: 'swap'
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang=\"ja\" className={notoSansJP.variable}>
      <head>
        {/* その他のメタタグ */}
      </head>
      <body>
        {children}
      </body>
    </html>
  );
}

display: 'swap'を使用することで、フォント読み込み中もテキストが表示されます。

パターン5:キャッシング戦略の実装

APIレスポンスのキャッシュを戦略的に管理します。

// lib/api.ts
import { cache } from 'react';

export const getUser = cache(async (userId: string) => {
  const res = await fetch(`https://api.example.com/users/${userId}`, {
    next: { revalidate: 3600 } // 1時間キャッシュ
  });
  
  if (!res.ok) {
    throw new Error('User fetch failed');
  }
  
  return res.json();
});

export const getProductCategories = cache(async () => {
  const res = await fetch('https://api.example.com/categories', {
    next: { revalidate: 86400 } // 24時間キャッシュ
  });
  
  if (!res.ok) {
    throw new Error('Categories fetch failed');
  }
  
  return res.json();
});

export const getRealtimeData = cache(async () => {
  const res = await fetch('https://api.example.com/realtime', {
    next: { revalidate: 0 } // キャッシュしない
  });
  
  if (!res.ok) {
    throw new Error('Realtime data fetch failed');
  }
  
  return res.json();
});

よくある応用パターン

応用パターン1:ロード時間の段階的改善

大規模なリストページでは、初期表示に必要な最小限の項目だけ先に読み込む「プログレッシブ・エンハンスメント」を活用します。

// app/products/page.tsx
import { Suspense } from 'react';
import { ProductList } from '@/components/ProductList';
import { ProductListSkeleton } from '@/components/ProductListSkeleton';

export default function ProductsPage() {
  return (
    <main>
      <h1>商品一覧</h1>
      
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList initialCount={12} />
      </Suspense>
      
      <Suspense fallback={<div>読み込み中...</div>}>
        <ProductList startIndex={12} initialCount={12} />
      </Suspense>
    </main>
  );
}

応用パターン2:Edge FunctionsでのAPIルーティング最適化

リクエスト処理を地理的に近い場所で実行することで、レイテンシを削減します。

// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const query = searchParams.get('q');
  
  if (!query) {
    return NextResponse.json(
      { error: 'Query parameter is required' },
      { status: 400 }
    );
  }
  
  try {
    const results = await fetch(
      `https://api.example.com/search?q=${encodeURIComponent(query)}`,
      {
        headers: {
          'Authorization': `Bearer ${process.env.API_KEY}`
        }
      }
    ).then(res => res.json());
    
    return NextResponse.json(results, {
      headers: {
        'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300'
      }
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Search failed' },
      { status: 500 }
    );
  }
}

応用パターン3:クライアント側のパフォーマンス監視

ユーザー環境での実パフォーマンスを測定し、本当のボトルネックを特定します。

// lib/analytics.ts
export function reportWebVitals(metric: any) {
  // Google Analytics、Sentry、Datadog等に送信
  if (window.gtag) {
    window.gtag('event', metric.name, {
      value: Math.round(metric.value),
      event_category: 'web_vitals',
      event_label: metric.id,
      non_interaction: true,
    });
  }
}

// app/layout.tsx
import { useEffect } from 'react';
import { reportWebVitals } from 'web-vitals';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    reportWebVitals(console.log); // 開発環境では console.log
  }, []);
  
  return (
    <html>
      <body>
        {children}
      </body>
    </html>
  );
}

注意点

注意1:ISRの無限再生成を避ける

revalidateの値が小さすぎると、常に再生成が発生してサーバーリソースを消費します。

// ❌ 悪い例:毎秒再生成される
next: { revalidate: 1 }

// ✅ 良い例:ユースケースに応じて設定
// 更新頻度の低いページ
next: { revalidate: 86400 } // 24時間

// 定期更新が必要なページ
next: { revalidate: 3600 } // 1時間

// リアルタイム性が必要
next: { revalidate: 0 } // キャッシュなし

注意2:画像のwidthとheightは必須

Image コンポーネントでwithとheightを指定しないと、Cumulative Layout Shift(CLS)の悪化につながります。

// ❌ 悪い例:CLSが発生
<Image src=\"/image.jpg\" alt=\"\" />

// ✅ 良い例:width/height指定
<Image src=\"/image.jpg\" alt=\"\" width={300} height={200} />

注意3:動的インポートのssr: falseは慎重に

ssr: falseを使うと、SEO上のメリットが失われる可能性があります。

// ⚠️ SSRが必要な情報を動的インポートしない
const CriticalInfo = dynamic(() => import('@/components/CriticalInfo'), {
  ssr: false // SEOに影響
});

// ✅ インタラクティブな要素のみssr: false
const ThirdPartyWidget = dynamic(() => import('@/components/Widget'), {
  ssr: false // 外部ウィジェット等
});

注意4:キャッシュの一貫性

複数のキャッシュレイヤー(ブラウザ、CDN、サーバー)で一貫性を保つことが重要です。

export async function GET(request: NextRequest) {
  return NextResponse.json(data, {
    headers: {
      // CDN(1時間)、ブラウザ(10分)、古いコンテンツの配信(300秒)
      'Cache-Control': 'public, s-maxage=3600, max-age=600, stale-while-revalidate=300'
    }
  });
}

注意5:環境ごとの設定分け

開発環境と本番環境でキャッシング戦略を分けるべきです。

const revalidateTime = process.env.NODE_ENV === 'production' ? 3600 : 0;

export async function getArticle(slug: string) {
  const res = await fetch(`https://api.example.com/articles/${slug}`, {
    next: { revalidate: revalidateTime }
  });
  
  return res.json();
}

まとめ

Next.jsのパフォーマンス最適化は、「すべてを最適化する」のではなく、「どこが本当のボトルネックか」を測定して、優先順位をつけることが重要です。業務での実装では以下を心がけてください:

  1. 測定が第一:Lighthouseスコアやリアルユーザーモニタリングで現状を把握
  2. 段階的に導入:すべてを一度に変更するのではなく、効果の大きい施策から実装
  3. チームで共有:パフォーマンス最適化の知見をコードレビューやドキュメントで共有
  4. 継続的改善:定期的にパフォーマンスを監視し、改善サイクルを回す
  5. 本番環境優先:開発環境より本番環境でのユーザー体験を優先

本記事で紹介した実装パターンは、実務で即座に採用できるものばかりです。プロジェクトの特性に合わせてカスタマイズしながら、段階的に導入していってください。パフォーマンス最適化は、ユーザーエクスペリエンスの向上だけでなく、検索順位向上やサーバーコスト削減にも直結する重要な取り組みです。

タイトルとURLをコピーしました