はじめに
Next.js App Routerでは、画像とフォントの最適化がCore Web Vitals改善の重要な要素です。この記事では、以下の最適化テクニックを解説します。
- next/image: 自動最適化、遅延読み込み、レスポンシブ対応
- next/font: フォントの自動最適化、CLS防止
- Core Web Vitals: LCP、CLS、FIDの改善
- OGP画像: 動的なソーシャル画像生成
Core Web Vitalsと画像・フォントの関係
画像とフォントは、以下のCore Web Vitalsに直接影響します。
| 指標 | 説明 | 画像・フォントの影響 |
|---|---|---|
| LCP | Largest Contentful Paint | ヒーロー画像の読み込み速度 |
| CLS | Cumulative Layout Shift | 画像・フォントによるレイアウトずれ |
| FID/INP | First Input Delay / Interaction to Next Paint | 重いJavaScriptの読み込み |
next/image基本設定
ローカル画像
ローカル画像を使用する場合、Next.jsが自動的にサイズを検出し、blur placeholder用のデータも生成します。
// components/LocalImage.tsx
import Image from 'next/image';
import heroImage from '@/public/images/hero.jpg';
export function LocalImage() {
return (
<Image
src={heroImage}
alt="Hero image"
placeholder="blur" // 自動生成されたblurを使用
priority // ファーストビューの画像には必須
sizes="100vw"
className="w-full h-auto"
/>
);
}
リモート画像
リモート画像は、width/heightを明示的に指定するか、fillモードを使用します。
// components/RemoteImage.tsx
import Image from 'next/image';
interface Props {
src: string;
alt: string;
width?: number;
height?: number;
}
export function RemoteImage({ src, alt, width = 800, height = 600 }: Props) {
return (
<Image
src={src}
alt={alt}
width={width}
height={height}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
className="rounded-lg"
/>
);
}
next.config.jsの設定
リモート画像を使用するには、許可するドメインを設定します。
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
// リモート画像のドメイン設定
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: '**.cloudinary.com',
},
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/images/**',
},
],
// 画像フォーマットの優先順位(AVIFが最優先)
formats: ['image/avif', 'image/webp'],
// ブレークポイント設定
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// キャッシュ期間(秒)
minimumCacheTTL: 60 * 60 * 24 * 30, // 30日
},
};
module.exports = nextConfig;
fillモードとレスポンシブ画像
親要素にフィット
fillモードは、親要素のサイズに合わせて画像を表示します。親要素にはposition: relativeが必要です。
// components/FillImage.tsx
import Image from 'next/image';
interface Props {
src: string;
alt: string;
aspectRatio?: '16/9' | '4/3' | '1/1';
}
export function FillImage({ src, alt, aspectRatio = '16/9' }: Props) {
return (
<div
className="relative w-full"
style={{ aspectRatio }}
>
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover rounded-lg"
/>
</div>
);
}
sizes属性の詳細
sizes属性は、ブラウザがどのサイズの画像をダウンロードするか決定するために使用します。
// sizes属性の例
<Image
src={image}
alt="Example"
fill
sizes={`
(max-width: 640px) 100vw,
(max-width: 1024px) 50vw,
33vw
`}
/>
// 意味:
// - 画面幅640px以下: 画像はビューポート幅の100%
// - 画面幅641-1024px: 画像はビューポート幅の50%
// - 画面幅1025px以上: 画像はビューポート幅の33%
グリッドレイアウトでの使用例
// components/ImageGrid.tsx
import Image from 'next/image';
interface ImageItem {
id: string;
src: string;
alt: string;
}
interface Props {
images: ImageItem[];
columns?: 2 | 3 | 4;
}
export function ImageGrid({ images, columns = 3 }: Props) {
const gridClass = {
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
};
const sizes = {
2: '(max-width: 640px) 100vw, 50vw',
3: '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw',
4: '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw',
};
return (
<div className={`grid ${gridClass[columns]} gap-4`}>
{images.map((image, index) => (
<div key={image.id} className="relative aspect-square">
<Image
src={image.src}
alt={image.alt}
fill
sizes={sizes[columns]}
className="object-cover rounded-lg"
loading={index < columns * 2 ? 'eager' : 'lazy'}
/>
</div>
))}
</div>
);
}
Blur Placeholder
ローカル画像(自動blur)
ローカル画像では、Next.jsが自動的にblurDataURLを生成します。
import Image from 'next/image';
import photo from '@/public/images/photo.jpg';
export function BlurredLocalImage() {
return (
<Image
src={photo}
alt="Photo"
placeholder="blur"
className="rounded-lg"
/>
);
}
リモート画像用のblurDataURL生成
リモート画像の場合は、plaiceholderライブラリを使用してblurDataURLを生成します。
npm install plaiceholder sharp
// lib/image-utils.ts
import { getPlaiceholder } from 'plaiceholder';
export async function getBlurDataURL(src: string): Promise<string> {
try {
const response = await fetch(src);
const buffer = Buffer.from(await response.arrayBuffer());
const { base64 } = await getPlaiceholder(buffer, { size: 10 });
return base64;
} catch (error) {
console.error('Failed to generate blur placeholder:', error);
// フォールバック: 透明な1x1ピクセル
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
}
}
// 複数画像の一括処理
export async function getBlurDataURLs(
images: { src: string; id: string }[]
): Promise<Map<string, string>> {
const results = await Promise.allSettled(
images.map(async (img) => ({
id: img.id,
blur: await getBlurDataURL(img.src),
}))
);
const map = new Map<string, string>();
results.forEach((result) => {
if (result.status === 'fulfilled') {
map.set(result.value.id, result.value.blur);
}
});
return map;
}
// app/posts/[slug]/page.tsx
import Image from 'next/image';
import { getBlurDataURL } from '@/lib/image-utils';
interface Props {
params: Promise<{ slug: string }>;
}
async function getPost(slug: string) {
// 記事データの取得処理
return {
title: 'Sample Post',
image: 'https://example.com/image.jpg',
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
const blurDataURL = await getBlurDataURL(post.image);
return (
<article>
<h1>{post.title}</h1>
<div className="relative aspect-video">
<Image
src={post.image}
alt={post.title}
fill
placeholder="blur"
blurDataURL={blurDataURL}
priority
sizes="(max-width: 768px) 100vw, 800px"
/>
</div>
</article>
);
}
カラープレースホルダー
画像の代表色を抽出してプレースホルダーとして使用することもできます。
// lib/image-utils.ts
import { getPlaiceholder } from 'plaiceholder';
export async function getImageColors(src: string) {
const response = await fetch(src);
const buffer = Buffer.from(await response.arrayBuffer());
const { color, base64 } = await getPlaiceholder(buffer);
return {
dominantColor: color.hex,
blurDataURL: base64,
};
}
next/fontによるフォント最適化
Google Fonts
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', '500', '700'],
display: 'swap',
variable: '--font-noto-sans-jp',
// 日本語フォントは大きいのでpreloadを無効に
preload: false,
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
<body className="font-sans">
{children}
</body>
</html>
);
}
display属性の選択
| 値 | 説明 | 使用場面 |
|---|---|---|
swap | フォールバックで即座に表示、後で置換 | 本文テキスト |
optional | ネットワーク条件によりフォールバックを維持 | パフォーマンス重視 |
block | 短い間ブロックしてから表示 | ロゴなど |
fallback | 100ms間ブロック、3秒でフォールバック | バランス重視 |
auto | ブラウザのデフォルト | 特に指定なし |
Tailwind CSSとの統合
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-noto-sans-jp)', 'var(--font-inter)', 'sans-serif'],
mono: ['var(--font-mono)', 'monospace'],
display: ['var(--font-inter)', 'sans-serif'],
},
},
},
};
ローカルフォント
独自フォントやライセンスの関係でセルフホスティングする場合は、localFontを使用します。
// app/layout.tsx
import localFont from 'next/font/local';
const customFont = localFont({
src: [
{
path: '../public/fonts/CustomFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/CustomFont-Medium.woff2',
weight: '500',
style: 'normal',
},
{
path: '../public/fonts/CustomFont-Bold.woff2',
weight: '700',
style: 'normal',
},
{
path: '../public/fonts/CustomFont-Italic.woff2',
weight: '400',
style: 'italic',
},
],
display: 'swap',
variable: '--font-custom',
fallback: ['system-ui', 'sans-serif'],
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja" className={customFont.variable}>
<body className="font-custom">
{children}
</body>
</html>
);
}
フォントサブセット化
不要な文字を削除してファイルサイズを削減できます。
// 特定の文字のみを含むサブセット
const notoSansJP = Noto_Sans_JP({
weight: ['400', '700'],
subsets: ['latin'],
display: 'swap',
// 特定の文字のみを含める
preload: true,
adjustFontFallback: true,
});
LCP(Largest Contentful Paint)最適化
ファーストビュー画像の優先読み込み
ファーストビュー(above the fold)に表示される大きな画像には、必ずpriority属性を設定します。
// components/HeroSection.tsx
import Image from 'next/image';
interface Props {
title: string;
subtitle?: string;
backgroundImage: string;
}
export function HeroSection({ title, subtitle, backgroundImage }: Props) {
return (
<section className="relative h-[80vh] min-h-[600px]">
<Image
src={backgroundImage}
alt=""
fill
priority // LCPの対象となる画像には必須
sizes="100vw"
className="object-cover"
quality={85}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-white px-4">
<h1 className="text-4xl md:text-6xl font-bold mb-4">{title}</h1>
{subtitle && (
<p className="text-xl md:text-2xl opacity-90">{subtitle}</p>
)}
</div>
</div>
</section>
);
}
loading属性の使い分け
// components/ImageGallery.tsx
import Image from 'next/image';
interface GalleryImage {
id: string;
src: string;
alt: string;
}
interface Props {
images: GalleryImage[];
aboveFoldCount?: number;
}
export function ImageGallery({ images, aboveFoldCount = 6 }: Props) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((image, index) => (
<div key={image.id} className="relative aspect-square">
<Image
src={image.src}
alt={image.alt}
fill
sizes="(max-width: 768px) 50vw, 33vw"
className="object-cover rounded-lg"
// ファーストビューの画像はeager、それ以外はlazy
loading={index < aboveFoldCount ? 'eager' : 'lazy'}
// 最初の画像のみpriority
priority={index === 0}
/>
</div>
))}
</div>
);
}
プリロードリンクの活用
// app/layout.tsx
import { headers } from 'next/headers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<head>
{/* 重要な画像のプリロード */}
<link
rel="preload"
href="/images/hero.webp"
as="image"
type="image/webp"
/>
{/* 重要なフォントのプリロード */}
<link
rel="preload"
href="/fonts/CustomFont-Bold.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<body>{children}</body>
</html>
);
}
CLS(Cumulative Layout Shift)防止
画像のアスペクト比を維持
// CLSを防ぐためのパターン
// パターン1: width/heightを明示
<Image
src="/image.jpg"
alt="Example"
width={800}
height={600}
/>
// パターン2: fillモードと親要素のアスペクト比
<div className="relative aspect-video">
<Image src="/image.jpg" alt="Example" fill />
</div>
// パターン3: CSSでアスペクト比を設定
<div className="relative" style={{ aspectRatio: '16/9' }}>
<Image src="/image.jpg" alt="Example" fill />
</div>
スケルトンローダー
// components/ImageWithSkeleton.tsx
'use client';
import Image from 'next/image';
import { useState } from 'react';
interface Props {
src: string;
alt: string;
aspectRatio?: string;
}
export function ImageWithSkeleton({ src, alt, aspectRatio = '16/9' }: Props) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className="relative" style={{ aspectRatio }}>
{/* スケルトンローダー */}
{isLoading && (
<div className="absolute inset-0 bg-gray-200 animate-pulse rounded-lg" />
)}
<Image
src={src}
alt={alt}
fill
className={`object-cover rounded-lg transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
}`}
onLoad={() => setIsLoading(false)}
/>
</div>
);
}
画像フォーマットと品質の最適化
フォーマットの選択
| フォーマット | 圧縮率 | ブラウザサポート | 用途 |
|---|---|---|---|
| AVIF | 最高 | Chrome, Firefox, Safari 16+ | 写真、グラデーション |
| WebP | 高 | ほぼ全て | 汎用 |
| PNG | 低 | 全て | 透過、イラスト |
| JPEG | 中 | 全て | フォールバック |
// next.config.js
const nextConfig = {
images: {
// AVIFを最優先、WebPをフォールバック
formats: ['image/avif', 'image/webp'],
},
};
quality設定のガイドライン
// 用途に応じたquality設定
// ヒーロー画像(高品質)
<Image src={heroImage} alt="Hero" quality={85} priority />
// 記事内の画像(バランス)
<Image src={contentImage} alt="Content" quality={75} />
// サムネイル(低品質でOK)
<Image src={thumbnailImage} alt="Thumbnail" quality={60} />
// アイコン・ロゴ(高品質を維持)
<Image src={logoImage} alt="Logo" quality={90} />
OGP画像の動的生成
基本的なOGP画像生成
// app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Default Title';
const description = searchParams.get('description') || '';
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
background: 'linear-gradient(135deg, #1e3a8a 0%, #7c3aed 100%)',
padding: '60px',
}}
>
<div
style={{
fontSize: 64,
fontWeight: 'bold',
color: 'white',
marginBottom: 20,
lineHeight: 1.2,
}}
>
{title}
</div>
{description && (
<div
style={{
fontSize: 28,
color: 'rgba(255, 255, 255, 0.8)',
lineHeight: 1.4,
}}
>
{description}
</div>
)}
<div
style={{
position: 'absolute',
top: 40,
right: 40,
fontSize: 24,
color: 'rgba(255, 255, 255, 0.6)',
}}
>
MyBlog
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}
カスタムフォントを使用したOGP画像
// app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
// フォントの読み込み
const interBold = fetch(
new URL('../../public/fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
export async function GET(request: NextRequest) {
const fontData = await interBold;
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Default Title';
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#000',
fontFamily: 'Inter',
}}
>
<div style={{ fontSize: 60, color: '#fff', fontWeight: 700 }}>
{title}
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: fontData,
weight: 700,
style: 'normal',
},
],
}
);
}
メタデータでOGP画像を設定
// app/posts/[slug]/page.tsx
import { Metadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
const ogImageUrl = new URL('/api/og', process.env.NEXT_PUBLIC_SITE_URL);
ogImageUrl.searchParams.set('title', post.title);
ogImageUrl.searchParams.set('description', post.description);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [
{
url: ogImageUrl.toString(),
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: [ogImageUrl.toString()],
},
};
}
パフォーマンス測定
Lighthouseスコアの確認
// scripts/check-performance.ts
import lighthouse from 'lighthouse';
import chromeLauncher from 'chrome-launcher';
async function runLighthouse(url: string) {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const result = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['performance'],
});
await chrome.kill();
const { categories, audits } = result?.lhr || {};
console.log('Performance Score:', categories?.performance?.score * 100);
console.log('LCP:', audits?.['largest-contentful-paint']?.displayValue);
console.log('CLS:', audits?.['cumulative-layout-shift']?.displayValue);
console.log('FID:', audits?.['max-potential-fid']?.displayValue);
return result;
}
runLighthouse('http://localhost:3000');
まとめ
| 最適化項目 | 設定・機能 | 効果 |
|---|---|---|
| 画像フォーマット | AVIF/WebP自動変換 | ファイルサイズ削減 |
| 遅延読み込み | loading=“lazy” | 初期読み込み高速化 |
| LCP改善 | priority属性 | LCPスコア向上 |
| CLS防止 | width/height, fill | レイアウトシフト防止 |
| フォント | next/font + display: swap | CLS防止、FOUT軽減 |
| Blur Placeholder | plaiceholder | 知覚的読み込み速度向上 |
ベストプラクティス
- ファーストビューの画像にはpriority: LCPに影響する画像は必ず優先読み込み
- 適切なsizes属性: 不必要に大きな画像をダウンロードしない
- next/fontを使用: 外部リクエストを排除しCLSを防止
- blur placeholderを活用: 知覚的な読み込み速度を向上
- 画像フォーマットの最適化: AVIF > WebP > JPEG/PNGの優先順位
参考文献
- Next.js Image Optimization - 画像最適化の公式ドキュメント
- Next.js Font Optimization - フォント最適化の公式ドキュメント
- web.dev - Largest Contentful Paint (LCP) - LCPの詳細解説
- web.dev - Cumulative Layout Shift (CLS) - CLSの詳細解説
- web.dev - Interaction to Next Paint (INP) - INPの詳細解説
- plaiceholder - blur placeholder生成ライブラリ
- Vercel OG Image Generation - OGP画像生成のドキュメント