Documentation Next.js

はじめに

Core Web Vitalsは、Googleが定義するユーザー体験の重要な指標です。この記事では、Next.js App Routerで各指標を最適化し、Lighthouseスコアを向上させる具体的な実装方法を解説します。

Core Web Vitalsの指標

2024年以降の指標

指標説明良好改善が必要
LCP最大コンテンツの表示時間≤ 2.5秒> 4秒
INPインタラクション応答時間≤ 200ms> 500ms
CLSレイアウトシフト累積値≤ 0.1> 0.25

LCP(Largest Contentful Paint)の最適化

画像の最適化

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

export function HeroImage() {
  return (
    <div className="relative h-[60vh] w-full">
      <Image
        src="/hero.jpg"
        alt="Hero Image"
        fill
        priority // LCP要素には必須
        sizes="100vw"
        quality={85}
        placeholder="blur"
        blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."
        className="object-cover"
      />
    </div>
  );
}

画像プレースホルダーの自動生成

// lib/image-placeholder.ts
import { getPlaiceholder } from 'plaiceholder';
import fs from 'fs/promises';
import path from 'path';

export async function getImagePlaceholder(imagePath: string) {
  const fullPath = path.join(process.cwd(), 'public', imagePath);
  const file = await fs.readFile(fullPath);

  const { base64, metadata } = await getPlaiceholder(file, { size: 10 });

  return {
    blurDataURL: base64,
    width: metadata.width,
    height: metadata.height,
  };
}

// 使用例
// app/page.tsx
import { getImagePlaceholder } from '@/lib/image-placeholder';

export default async function Page() {
  const placeholder = await getImagePlaceholder('/hero.jpg');

  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={placeholder.width}
      height={placeholder.height}
      placeholder="blur"
      blurDataURL={placeholder.blurDataURL}
      priority
    />
  );
}

フォントの最適化

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

const notoSansJP = Noto_Sans_JP({
  subsets: ['latin'],
  weight: ['400', '500', '700'],
  display: 'swap', // FOUTを許容してFOITを防ぐ
  preload: true,
  variable: '--font-noto-sans-jp',
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja" className={notoSansJP.variable}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

クリティカルCSSの最適化

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <head>
        {/* クリティカルCSSのインライン化 */}
        <style
          dangerouslySetInnerHTML={{
            __html: `
              /* Above-the-fold の重要なスタイル */
              body { margin: 0; font-family: system-ui, sans-serif; }
              .hero { min-height: 60vh; }
              .nav { height: 64px; }
            `,
          }}
        />
        {/* 非クリティカルCSSの遅延読み込み */}
        <link
          rel="preload"
          href="/styles/main.css"
          as="style"
          onLoad="this.onload=null;this.rel='stylesheet'"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

リソースの優先読み込み

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <head>
        {/* LCP画像のプリロード */}
        <link
          rel="preload"
          href="/hero.webp"
          as="image"
          type="image/webp"
          fetchPriority="high"
        />
        {/* 重要なフォントのプリロード */}
        <link
          rel="preload"
          href="/fonts/NotoSansJP-Bold.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
        {/* APIエンドポイントのプリコネクト */}
        <link rel="preconnect" href="https://api.example.com" />
        <link rel="dns-prefetch" href="https://api.example.com" />
      </head>
      <body>{children}</body>
    </html>
  );
}

INP(Interaction to Next Paint)の最適化

イベントハンドラの最適化

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

import { useState, useTransition, useDeferredValue } from 'react';

export function SearchForm() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const deferredQuery = useDeferredValue(query);

  const handleSearch = (value: string) => {
    setQuery(value);

    // 重い処理をトランジションでラップ
    startTransition(() => {
      // 検索結果の更新(低優先度)
      performSearch(value);
    });
  };

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="検索..."
        className="border p-2 rounded"
      />
      {isPending && <span className="text-gray-500">検索中...</span>}
      <SearchResults query={deferredQuery} />
    </div>
  );
}

長いタスクの分割

// lib/task-scheduler.ts
export function yieldToMain(): Promise<void> {
  return new Promise((resolve) => {
    if ('scheduler' in globalThis && 'yield' in (globalThis.scheduler as any)) {
      (globalThis.scheduler as any).yield().then(resolve);
    } else {
      setTimeout(resolve, 0);
    }
  });
}

export async function processLargeArray<T, R>(
  items: T[],
  processor: (item: T) => R,
  batchSize: number = 100
): Promise<R[]> {
  const results: R[] = [];

  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    results.push(...batch.map(processor));

    // メインスレッドに制御を戻す
    if (i + batchSize < items.length) {
      await yieldToMain();
    }
  }

  return results;
}

Web Workerの活用

// workers/heavy-computation.worker.ts
self.onmessage = (e: MessageEvent) => {
  const { data, type } = e.data;

  switch (type) {
    case 'PROCESS_DATA':
      const result = heavyComputation(data);
      self.postMessage({ type: 'RESULT', result });
      break;
  }
};

function heavyComputation(data: number[]): number {
  // 重い計算処理
  return data.reduce((sum, val) => sum + Math.sqrt(val), 0);
}

// hooks/useWorker.ts
'use client';

import { useEffect, useRef, useState, useCallback } from 'react';

export function useWorker<T, R>(workerUrl: string) {
  const workerRef = useRef<Worker | null>(null);
  const [result, setResult] = useState<R | null>(null);
  const [isProcessing, setIsProcessing] = useState(false);

  useEffect(() => {
    workerRef.current = new Worker(new URL(workerUrl, import.meta.url));

    workerRef.current.onmessage = (e: MessageEvent) => {
      if (e.data.type === 'RESULT') {
        setResult(e.data.result);
        setIsProcessing(false);
      }
    };

    return () => {
      workerRef.current?.terminate();
    };
  }, [workerUrl]);

  const processData = useCallback((data: T) => {
    setIsProcessing(true);
    workerRef.current?.postMessage({ type: 'PROCESS_DATA', data });
  }, []);

  return { result, isProcessing, processData };
}

Dynamic Importの活用

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

import dynamic from 'next/dynamic';
import { Suspense } from 'react';

// 重いコンポーネントを遅延読み込み
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // クライアントのみでレンダリング
});

const DataTable = dynamic(() => import('./DataTable'), {
  loading: () => <TableSkeleton />,
});

export function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <DataTable />
      </Suspense>
    </div>
  );
}

CLS(Cumulative Layout Shift)の最適化

画像のアスペクト比指定

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

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

export function ResponsiveImage({
  src,
  alt,
  aspectRatio = '16/9',
}: ResponsiveImageProps) {
  const aspectRatioClass = {
    '16/9': 'aspect-video',
    '4/3': 'aspect-[4/3]',
    '1/1': 'aspect-square',
    '3/2': 'aspect-[3/2]',
  }[aspectRatio];

  return (
    <div className={`relative w-full ${aspectRatioClass}`}>
      <Image
        src={src}
        alt={alt}
        fill
        sizes="(max-width: 768px) 100vw, 50vw"
        className="object-cover"
      />
    </div>
  );
}

スケルトンローダー

// components/Skeleton.tsx
interface SkeletonProps {
  className?: string;
  variant?: 'text' | 'circular' | 'rectangular';
  width?: string | number;
  height?: string | number;
}

export function Skeleton({
  className = '',
  variant = 'rectangular',
  width,
  height,
}: SkeletonProps) {
  const baseClasses = 'animate-pulse bg-gray-200';

  const variantClasses = {
    text: 'rounded',
    circular: 'rounded-full',
    rectangular: 'rounded-md',
  }[variant];

  return (
    <div
      className={`${baseClasses} ${variantClasses} ${className}`}
      style={{ width, height }}
    />
  );
}

// components/CardSkeleton.tsx
export function CardSkeleton() {
  return (
    <div className="border rounded-lg p-4 space-y-4">
      {/* 画像プレースホルダー - サイズを固定 */}
      <Skeleton variant="rectangular" className="w-full aspect-video" />

      {/* タイトル */}
      <Skeleton variant="text" className="h-6 w-3/4" />

      {/* 説明文 */}
      <div className="space-y-2">
        <Skeleton variant="text" className="h-4 w-full" />
        <Skeleton variant="text" className="h-4 w-5/6" />
      </div>

      {/* ボタン */}
      <Skeleton variant="rectangular" className="h-10 w-24" />
    </div>
  );
}

動的コンテンツの領域確保

// components/AdBanner.tsx
interface AdBannerProps {
  size: 'leaderboard' | 'rectangle' | 'skyscraper';
}

const AD_SIZES = {
  leaderboard: { width: 728, height: 90 },
  rectangle: { width: 300, height: 250 },
  skyscraper: { width: 160, height: 600 },
};

export function AdBanner({ size }: AdBannerProps) {
  const { width, height } = AD_SIZES[size];

  return (
    <div
      className="bg-gray-100 flex items-center justify-center"
      style={{
        width: `${width}px`,
        height: `${height}px`,
        minHeight: `${height}px`, // 最小高さを保証
      }}
    >
      {/* 広告スクリプトがロードされるまでのプレースホルダー */}
      <span className="text-gray-400 text-sm">広告</span>
    </div>
  );
}

フォントによるCLS防止

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

// Google Fontsの最適化
const notoSansJP = Noto_Sans_JP({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
  adjustFontFallback: true, // フォールバックフォントの調整
  fallback: ['Hiragino Sans', 'sans-serif'],
});

// ローカルフォントの最適化
const customFont = localFont({
  src: [
    { path: './fonts/Custom-Regular.woff2', weight: '400' },
    { path: './fonts/Custom-Bold.woff2', weight: '700' },
  ],
  display: 'swap',
  preload: true,
});

// CSS(size-adjustでフォールバックフォントのサイズを調整)
// globals.css
/*
@font-face {
  font-family: 'Fallback';
  src: local('Hiragino Sans');
  size-adjust: 105%;
  ascent-override: 95%;
  descent-override: 25%;
  line-gap-override: 0%;
}
*/

パフォーマンス計測

Web Vitals計測コンポーネント

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

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

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    const { name, value, rating, id } = metric;

    // コンソールに出力
    console.log(`${name}: ${value} (${rating})`);

    // Google Analyticsに送信
    if (typeof window !== 'undefined' && window.gtag) {
      window.gtag('event', name, {
        event_category: 'Web Vitals',
        event_label: id,
        value: Math.round(name === 'CLS' ? value * 1000 : value),
        non_interaction: true,
      });
    }

    // カスタムエンドポイントに送信
    sendToAnalytics({
      metric: name,
      value,
      rating,
      path: window.location.pathname,
    });
  });

  return null;
}

async function sendToAnalytics(data: {
  metric: string;
  value: number;
  rating: string;
  path: string;
}) {
  try {
    await fetch('/api/analytics/web-vitals', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
  } catch (error) {
    console.error('Failed to send web vitals:', error);
  }
}

パフォーマンスモニタリングAPI

// app/api/analytics/web-vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';

interface WebVitalMetric {
  metric: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  path: string;
}

export async function POST(request: NextRequest) {
  try {
    const data: WebVitalMetric = await request.json();

    // データストアに保存(例: Redis, PostgreSQL)
    await saveMetric(data);

    // 閾値を超えた場合はアラート
    if (data.rating === 'poor') {
      await sendAlert({
        metric: data.metric,
        value: data.value,
        path: data.path,
      });
    }

    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to process metric' },
      { status: 500 }
    );
  }
}

async function saveMetric(data: WebVitalMetric) {
  // 実際のデータストアへの保存処理
  console.log('Saving metric:', data);
}

async function sendAlert(data: { metric: string; value: number; path: string }) {
  // Slack, PagerDuty等への通知
  console.warn('Poor performance detected:', data);
}

Lighthouseスコア自動計測

// scripts/lighthouse-check.ts
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';

interface LighthouseResult {
  url: string;
  scores: {
    performance: number;
    accessibility: number;
    bestPractices: number;
    seo: number;
  };
  webVitals: {
    lcp: number;
    fid: number;
    cls: number;
    ttfb: number;
  };
}

async function runLighthouse(url: string): Promise<LighthouseResult> {
  const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });

  const options = {
    logLevel: 'info' as const,
    output: 'json' as const,
    port: chrome.port,
  };

  const result = await lighthouse(url, options);

  await chrome.kill();

  const { lhr } = result!;

  return {
    url,
    scores: {
      performance: lhr.categories.performance.score! * 100,
      accessibility: lhr.categories.accessibility.score! * 100,
      bestPractices: lhr.categories['best-practices'].score! * 100,
      seo: lhr.categories.seo.score! * 100,
    },
    webVitals: {
      lcp: lhr.audits['largest-contentful-paint'].numericValue!,
      fid: lhr.audits['max-potential-fid'].numericValue!,
      cls: lhr.audits['cumulative-layout-shift'].numericValue!,
      ttfb: lhr.audits['server-response-time'].numericValue!,
    },
  };
}

// CI/CDで実行
async function main() {
  const urls = [
    'https://example.com/',
    'https://example.com/about',
    'https://example.com/products',
  ];

  for (const url of urls) {
    const result = await runLighthouse(url);

    console.log(`\n📊 ${url}`);
    console.log(`  Performance: ${result.scores.performance}`);
    console.log(`  LCP: ${result.webVitals.lcp}ms`);
    console.log(`  CLS: ${result.webVitals.cls}`);

    // 閾値チェック
    if (result.scores.performance < 80) {
      console.error(`  ❌ Performance score below threshold`);
      process.exit(1);
    }
  }
}

main();

next.config.jsの最適化設定

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 30, // 30日
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
      },
    ],
  },
  experimental: {
    optimizePackageImports: [
      'lodash',
      '@mui/material',
      '@mui/icons-material',
      'date-fns',
    ],
  },
  compiler: {
    removeConsole: process.env.NODE_ENV === 'production',
  },
  // バンドルサイズの分析
  webpack: (config, { isServer }) => {
    if (process.env.ANALYZE === 'true') {
      const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
      config.plugins.push(
        new BundleAnalyzerPlugin({
          analyzerMode: 'static',
          reportFilename: isServer
            ? '../analyze/server.html'
            : './analyze/client.html',
        })
      );
    }
    return config;
  },
};

module.exports = nextConfig;

まとめ

Core Web Vitals最適化のポイントをまとめます。

指標主な最適化手法
LCP画像のpriority属性、next/fontの使用、リソースのプリロード
INPuseTransition、Web Worker、タスク分割、Dynamic Import
CLSアスペクト比指定、スケルトンローダー、フォント最適化

継続的なモニタリングと改善を行い、ユーザー体験を向上させましょう。

参考文献

円