はじめに
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の使用、リソースのプリロード |
| INP | useTransition、Web Worker、タスク分割、Dynamic Import |
| CLS | アスペクト比指定、スケルトンローダー、フォント最適化 |
継続的なモニタリングと改善を行い、ユーザー体験を向上させましょう。