Documentation Next.js

はじめに

Next.jsを本番環境で効果的に運用するためには、パフォーマンスの最適化とモニタリングが欠かせません。本記事では、本番環境でのアプリケーションのパフォーマンスを向上させるための具体的な手法と、その監視方法について詳しく解説します。

この記事で学べること

  • 画像最適化とコード分割による高速化
  • パフォーマンスモニタリングツールの導入方法
  • SSR/SSG/ISRの適切な使い分け
  • Core Web Vitalsの改善テクニック

画像最適化とコード分割

next/imageによる画像最適化

Next.jsには、ビルトインの最適化機能が豊富に搭載されています。特に、next/imageコンポーネントを活用すると、画像の遅延読み込み(Lazy Loading)やデバイスに応じたリサイズが自動で行われ、ページ読み込み速度が大幅に向上します。

遅延読み込み(Lazy Loading)とは: ページの初期表示時にすべての画像を読み込むのではなく、ユーザーがスクロールして画像が表示領域に入ったタイミングで読み込む技術です。これにより、初期読み込み時間を短縮できます。

// components/OptimizedImage.tsx
import Image from 'next/image';

interface OptimizedImageProps {
  src: string;
  alt: string;
  priority?: boolean; // ファーストビューの画像にはtrueを設定
}

export function OptimizedImage({ src, alt, priority = false }: OptimizedImageProps) {
  return (
    <Image
      src={src}
      alt={alt}
      width={800}
      height={600}
      // 遅延読み込みはデフォルトで有効
      // priorityをtrueにすると遅延読み込みが無効になりLCPが改善
      priority={priority}
      // 画像のサイズを最適化
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      // WebPなどの最適なフォーマットに自動変換
      quality={85}
      // プレースホルダーを表示してCLSを防止
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
    />
  );
}

動的インポートによるコード分割

大規模なコンポーネントやライブラリは、動的インポートを使って必要なタイミングでのみ読み込むことで、初期バンドルサイズを削減できます。

// pages/dashboard.tsx
import dynamic from 'next/dynamic';
import { Suspense } from 'react';

// 重いチャートライブラリを動的にインポート
// ssr: falseでサーバーサイドレンダリングを無効化(クライアント専用コンポーネントの場合)
const DynamicChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div className="animate-pulse h-64 bg-gray-200 rounded" />,
  ssr: false, // クライアントサイドのみでレンダリング
});

// モーダルコンポーネントも動的インポート
const DynamicModal = dynamic(() => import('@/components/Modal'), {
  loading: () => null,
});

export default function Dashboard() {
  return (
    <div>
      <h1>ダッシュボード</h1>
      {/* Suspenseと組み合わせてローディング状態を管理 */}
      <Suspense fallback={<div>読み込み中...</div>}>
        <DynamicChart data={chartData} />
      </Suspense>
    </div>
  );
}

パフォーマンスのモニタリングツール

本番環境でアプリケーションの状態を監視するためには、適切なモニタリングツールの導入が重要です。

Next.jsビルトインのWeb Vitals計測

Next.jsには、Core Web Vitalsを計測するための組み込み機能があります。

// app/layout.tsx (App Router)
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    // メトリクスの種類に応じて処理を分岐
    switch (metric.name) {
      case 'LCP': // Largest Contentful Paint(最大コンテンツの描画)
        console.log('LCP:', metric.value, 'ms');
        break;
      case 'FID': // First Input Delay(初回入力遅延)
        console.log('FID:', metric.value, 'ms');
        break;
      case 'CLS': // Cumulative Layout Shift(累積レイアウトシフト)
        console.log('CLS:', metric.value);
        break;
      case 'TTFB': // Time to First Byte(最初のバイトまでの時間)
        console.log('TTFB:', metric.value, 'ms');
        break;
      case 'INP': // Interaction to Next Paint(次の描画までのインタラクション)
        console.log('INP:', metric.value, 'ms');
        break;
    }

    // 分析サービスに送信
    sendToAnalytics(metric);
  });

  return null;
}

// Google Analytics 4への送信例
function sendToAnalytics(metric: { name: string; value: number; id: string }) {
  // gtag関数が存在する場合のみ送信
  if (typeof window !== 'undefined' && 'gtag' in window) {
    (window as any).gtag('event', metric.name, {
      event_category: 'Web Vitals',
      event_label: metric.id,
      value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
      non_interaction: true,
    });
  }
}

OpenTelemetryによる分散トレーシング

より詳細なパフォーマンス分析には、OpenTelemetryを活用します。

// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

export function register() {
  // 本番環境でのみ有効化
  if (process.env.NODE_ENV === 'production') {
    const sdk = new NodeSDK({
      // トレース情報の送信先を設定
      traceExporter: new OTLPTraceExporter({
        url: process.env.OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
      }),
      // 自動計装を有効化(HTTP、fetch、データベースなど)
      instrumentations: [
        getNodeAutoInstrumentations({
          // 不要な計装は無効化してオーバーヘッドを削減
          '@opentelemetry/instrumentation-fs': { enabled: false },
        }),
      ],
    });

    sdk.start();
    console.log('OpenTelemetry initialized');
  }
}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // OpenTelemetryの計装を有効化
  experimental: {
    instrumentationHook: true,
  },
};

module.exports = nextConfig;

サーバーサイドレンダリング(SSR)と静的サイト生成(SSG)

SSR - リクエスト時にページを生成

SSR(Server-Side Rendering)は、リクエストごとにサーバー側でページを生成する方式です。ユーザー固有のデータや、リアルタイムで更新が必要なコンテンツに適しています。

// app/products/[id]/page.tsx (App Router)
import { notFound } from 'next/navigation';

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

// 動的レンダリングを強制(リクエストごとに実行)
export const dynamic = 'force-dynamic';

async function getProduct(id: string): Promise<Product | null> {
  const res = await fetch(`${process.env.API_URL}/products/${id}`, {
    // キャッシュを無効化して常に最新データを取得
    cache: 'no-store',
  });

  if (!res.ok) return null;
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await getProduct(params.id);

  if (!product) {
    notFound();
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>価格: {product.price.toLocaleString()}円</p>
      {/* 在庫数はリアルタイムで更新される */}
      <p>在庫: {product.stock}個</p>
    </div>
  );
}

SSG - ビルド時にページを生成

SSG(Static Site Generation)は、ビルド時に静的なHTMLファイルを生成する方式です。更新頻度が低いコンテンツに最適で、CDNからの配信により非常に高速なレスポンスを実現できます。

// app/blog/[slug]/page.tsx (App Router)
import { Metadata } from 'next';

interface Post {
  slug: string;
  title: string;
  content: string;
  publishedAt: string;
}

// ビルド時に生成するページのパスを定義
export async function generateStaticParams() {
  const posts = await fetch(`${process.env.API_URL}/posts`).then((res) =>
    res.json()
  );

  return posts.map((post: Post) => ({
    slug: post.slug,
  }));
}

// 静的生成を明示的に指定
export const dynamic = 'force-static';

async function getPost(slug: string): Promise<Post> {
  const res = await fetch(`${process.env.API_URL}/posts/${slug}`, {
    // ビルド時にキャッシュされる
    cache: 'force-cache',
  });
  return res.json();
}

// メタデータも静的に生成
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);
  return {
    title: post.title,
    description: post.content.substring(0, 160),
  };
}

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.publishedAt).toLocaleDateString('ja-JP')}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Core Web Vitalsの最適化

Core Web Vitalsは、Googleが提供するユーザー体験を測定するための指標です。SEOにも影響するため、これらの改善は非常に重要です。

指標説明目標値
LCP(Largest Contentful Paint)最大コンテンツの描画時間2.5秒以下
INP(Interaction to Next Paint)インタラクションの応答性200ms以下
CLS(Cumulative Layout Shift)レイアウトのずれ0.1以下

LCPの改善

// components/HeroSection.tsx
import Image from 'next/image';

export function HeroSection() {
  return (
    <section className="relative h-screen">
      {/* ファーストビューの画像にはpriorityを設定 */}
      <Image
        src="/hero-image.jpg"
        alt="ヒーロー画像"
        fill
        priority // LCP改善のため優先読み込み
        sizes="100vw"
        className="object-cover"
      />
      <div className="absolute inset-0 flex items-center justify-center">
        <h1 className="text-4xl font-bold text-white">
          Welcome to Our Site
        </h1>
      </div>
    </section>
  );
}

CLSの改善

// components/ResponsiveImage.tsx
import Image from 'next/image';

interface ResponsiveImageProps {
  src: string;
  alt: string;
  aspectRatio: '16/9' | '4/3' | '1/1';
}

export function ResponsiveImage({ src, alt, aspectRatio }: ResponsiveImageProps) {
  // アスペクト比を指定してレイアウトシフトを防止
  return (
    <div
      className="relative w-full"
      style={{ aspectRatio }}
    >
      <Image
        src={src}
        alt={alt}
        fill
        className="object-cover"
        sizes="(max-width: 768px) 100vw, 50vw"
      />
    </div>
  );
}

INPの改善

// components/InteractiveList.tsx
'use client';

import { useCallback, useTransition } from 'react';

interface Item {
  id: string;
  name: string;
}

interface InteractiveListProps {
  items: Item[];
  onItemClick: (id: string) => Promise<void>;
}

export function InteractiveList({ items, onItemClick }: InteractiveListProps) {
  // useTransitionで重い処理を非ブロッキングに
  const [isPending, startTransition] = useTransition();

  const handleClick = useCallback((id: string) => {
    // UIの応答性を維持しながら処理を実行
    startTransition(async () => {
      await onItemClick(id);
    });
  }, [onItemClick]);

  return (
    <ul className={isPending ? 'opacity-50' : ''}>
      {items.map((item) => (
        <li key={item.id}>
          <button
            onClick={() => handleClick(item.id)}
            disabled={isPending}
            className="w-full text-left p-4 hover:bg-gray-100 transition-colors"
          >
            {item.name}
          </button>
        </li>
      ))}
    </ul>
  );
}

インクリメンタル静的再生成(ISR)

ISR(Incremental Static Regeneration)は、静的生成の速度を活かしつつ、定期的にコンテンツを更新できる機能です。

// app/news/page.tsx (App Router)
interface NewsItem {
  id: string;
  title: string;
  summary: string;
  publishedAt: string;
}

// 60秒ごとにページを再生成
export const revalidate = 60;

async function getNews(): Promise<NewsItem[]> {
  const res = await fetch(`${process.env.API_URL}/news`, {
    // next.revalidateで再検証間隔を指定
    next: { revalidate: 60 },
  });
  return res.json();
}

export default async function NewsPage() {
  const news = await getNews();

  return (
    <div>
      <h1>最新ニュース</h1>
      {/* 最大60秒前のデータが表示される可能性がある */}
      <p className="text-sm text-gray-500">
        ※ このページは最大60秒ごとに更新されます
      </p>
      <ul>
        {news.map((item) => (
          <li key={item.id} className="border-b py-4">
            <h2 className="font-bold">{item.title}</h2>
            <p>{item.summary}</p>
            <time className="text-sm text-gray-400">
              {new Date(item.publishedAt).toLocaleDateString('ja-JP')}
            </time>
          </li>
        ))}
      </ul>
    </div>
  );
}

オンデマンド再検証

特定のイベント(例: CMSでの更新)をトリガーにページを再生成することも可能です。

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  // シークレットトークンで認証
  const token = request.headers.get('x-revalidate-token');
  if (token !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
  }

  const { path, tag } = await request.json();

  try {
    if (path) {
      // 特定のパスを再検証
      revalidatePath(path);
    }
    if (tag) {
      // 特定のタグを持つキャッシュを再検証
      revalidateTag(tag);
    }

    return NextResponse.json({
      revalidated: true,
      date: new Date().toISOString(),
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Revalidation failed' },
      { status: 500 }
    );
  }
}

まとめ

本番環境でNext.jsを運用する際は、以下の最適化技術やツールを活用し、ユーザーに最高のパフォーマンスを提供しましょう。

パフォーマンス最適化のチェックリスト

  • next/imageを使用して画像を最適化する
  • 動的インポートで初期バンドルサイズを削減する
  • 適切なレンダリング戦略(SSR/SSG/ISR)を選択する
  • Core Web Vitals(LCP、INP、CLS)を継続的に監視する
  • OpenTelemetryやGoogle Analyticsでメトリクスを収集する

これらの技術を適切に組み合わせることで、高速で信頼性の高いWebアプリケーションを構築できます。

参考文献

円