Next.js の画像最適化を業務で活用するパターン集|実装コード付き
Web アプリケーション開発において、画像の最適化は必須の課題です。特に Next.js では、標準で提供される Image コンポーネントを活用することで、自動的に画像を最適化できます。本記事では、実務で頻繁に遭遇する画像最適化のパターンについて、具体的な実装例を交えて解説します。
Next.js の画像最適化とは
Next.js の Image コンポーネントは、以下のような自動最適化を行います:
- WebP や AVIF などの最新フォーマットへの自動変換
- デバイスサイズに応じたレスポンシブ画像の配信
- Lazy Loading による読み込み最適化
- Cumulative Layout Shift(CLS)の防止
- 画像の自動リサイズ
これらの機能により、開発者が明示的に対応することなく、高速で効率的な画像配信が実現します。
業務でのユースケース
1. E-commerce サイトでの商品画像表示
オンラインショップでは、複数の商品画像を複数のページに表示する必要があります。ユーザーのネットワーク速度やデバイスに応じた最適な解像度の画像を提供することで、ページ読み込み時間を大幅に短縮できます。
2. ブログプラットフォームのアイキャッチ画像
ブログ記事一覧ページでは多数の画像が表示されます。自動的な画像リサイズにより、不要な容量削減ができます。
3. 管理画面ダッシュボードのサムネイル表示
ユーザーアップロード画像をサムネイルとして表示する場合、信頼性の高い画像最適化が重要です。
実装コード|実務パターン
パターン 1:基本的な商品画像コンポーネント
E-commerce サイトでよく使用される、複数サイズに対応した商品画像コンポーネントの実装例です。
// components/ProductImage.tsx
import Image from 'next/image';
import { useState } from 'react';
interface ProductImageProps {
src: string;
alt: string;
productId: string;
}
export const ProductImage: React.FC<ProductImageProps> = ({
src,
alt,
productId,
}) => {
const [isLoading, setIsLoading] = useState(true);
return (
<div className=\"relative w-full bg-gray-100 rounded-lg overflow-hidden\">
<Image
src={src}
alt={alt}
width={400}
height={400}
priority={false}
onLoadingComplete={() => setIsLoading(false)}
className={`w-full h-auto object-cover transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
}`}
sizes=\"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px\"
/>
{isLoading && (
<div className=\"absolute inset-0 bg-gray-200 animate-pulse\" />
)}
</div>
);
};
このコンポーネントの特徴:
- 固定の width/height を指定することで、CLS を防止
- sizes 属性でレスポンシブ対応
- loading 状態を管理してスケルトン表示
- priority=false でブラウザの最適化に委ねる
パターン 2:ダッシュボード内の複数画像グリッド表示
管理画面で複数の画像を効率的に表示する場合の実装です。
// components/ImageGrid.tsx
import Image from 'next/image';
import { useCallback, useState } from 'react';
interface ImageItem {
id: string;
url: string;
title: string;
uploadedAt: Date;
}
interface ImageGridProps {
items: ImageItem[];
isLoading?: boolean;
}
export const ImageGrid: React.FC<ImageGridProps> = ({
items,
isLoading = false,
}) => {
const [loadedImages, setLoadedImages] = useState<Set<string>>(
new Set()
);
const handleImageLoad = useCallback((id: string) => {
setLoadedImages((prev) => new Set([...prev, id]));
}, []);
if (isLoading) {
return (
<div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 p-4\">
{Array.from({ length: 8 }).map((_, idx) => (
<div
key={idx}
className=\"w-full aspect-square bg-gray-200 rounded animate-pulse\"
/>
))}
</div>
);
}
return (
<div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 p-4\">
{items.map((item) => (
<div
key={item.id}
className=\"relative w-full aspect-square rounded-lg overflow-hidden bg-gray-100\">
<Image
src={item.url}
alt={item.title}
fill
className=\"object-cover\"
sizes=\"(max-width: 768px) 50vw, 25vw\"
onLoadingComplete={() => handleImageLoad(item.id)}
quality={75}
/>
{!loadedImages.has(item.id) && (
<div className=\"absolute inset-0 bg-gray-200 animate-pulse\" />
)}
<div className=\"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2\">
<p className=\"text-white text-sm truncate\">{item.title}</p>
</div>
</div>
))}
</div>
);
};
パターン 3:ブログの記事一覧ページ
複数の記事サムネイルを効率的に読み込む実装です。
// pages/blog/index.tsx
import Image from 'next/image';
import { GetStaticProps } from 'next';
import Link from 'next/link';
interface BlogPost {
id: string;
title: string;
excerpt: string;
featuredImage: string;
publishedAt: string;
slug: string;
}
interface BlogIndexProps {
posts: BlogPost[];
}
export default function BlogIndex({ posts }: BlogIndexProps) {
return (
<div className=\"max-w-6xl mx-auto px-4 py-8\">
<h1 className=\"text-4xl font-bold mb-12\">ブログ</h1>
<div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8\">
{posts.map((post) => (
<Link key={post.id} href={`/blog/${post.slug}`}>
<a className=\"group cursor-pointer\">
<div className=\"relative w-full h-48 mb-4 rounded-lg overflow-hidden bg-gray-200\">
<Image
src={post.featuredImage}
alt={post.title}
fill
className=\"object-cover group-hover:scale-105 transition-transform duration-300\"
sizes=\"(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw\"
quality={80}
/>
</div>
<h2 className=\"text-xl font-semibold mb-2 group-hover:text-blue-600 transition-colors\">
{post.title}
</h2>
<p className=\"text-gray-600 text-sm mb-3\">{post.excerpt}</p>
<time className=\"text-xs text-gray-500\">
{new Date(post.publishedAt).toLocaleDateString('ja-JP')}
</time>
</a>
</Link>
))}
</div>
</div>
);
}
export const getStaticProps: GetStaticProps<BlogIndexProps> = async () => {
// 実際の実装では API やデータベースから取得
const posts: BlogPost[] = [
{
id: '1',
title: '画像最適化のベストプラクティス',
excerpt: 'Web アプリケーションで画像を効率的に配信する方法',
featuredImage: '/images/blog-1.jpg',
publishedAt: '2024-01-15',
slug: 'image-optimization-best-practices',
},
// ... 他の記事
];
return {
props: { posts },
revalidate: 3600, // 1 時間ごとに再生成
};
};
パターン 4:外部画像ソースの最適化設定
外部サービス(Cloudinary、AWS S3 など)から画像を取得する場合の設定です。
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: [
'cloudinary.com',
'res.cloudinary.com',
'my-s3-bucket.s3.amazonaws.com',
'cdn.example.com',
],
// 画像の最適化ファイルサイズ制限
dangerouslyAllowSVG: true,
contentSecurityPolicy: \"default-src 'self'; script-src 'none'; sandbox;\",
// 複数の画像フォーマットをサポート
formats: ['image/webp', 'image/avif'],
// キャッシュ戦略
minimumCacheTTL: 31536000, // 1 年
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
// 本番環境でのキャッシュ設定
onDemandEntries: {
maxInactiveAge: 60 * 1000,
pagesBufferLength: 5,
},
};
module.exports = nextConfig;
このコンフィグの説明:
- domains:許可される外部ドメインを明示
- formats:生成される画像フォーマットを指定
- minimumCacheTTL:キャッシュの最小 TTL を設定
- dangerouslyAllowSVG:SVG ファイルの処理を許可
パターン 5:カスタムローダーの実装
特定の CDN や画像サービスを使用する場合のカスタムローダーです。
// lib/imageLoader.ts
export const customImageLoader = ({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}): string => {
// Cloudinary を使用する場合の例
if (src.startsWith('/')) {
const fileName = src.substring(1); // 先頭のスラッシュを削除
return `https://res.cloudinary.com/your-cloud-name/image/fetch/w_${width},q_${
quality || 75
},f_auto/https://your-domain.com/${fileName}`;
}
// 外部 URL の場合
const params = new URLSearchParams();
params.set('url', src);
params.set('w', width.toString());
params.set('q', (quality || 75).toString());
return `https://your-image-optimization-service.com/api/optimize?${params.toString()}`;
};
// components/OptimizedImage.tsx
import Image from 'next/image';
import { customImageLoader } from '@/lib/imageLoader';
interface OptimizedImageProps {
src: string;
alt: string;
width: number;
height: number;
quality?: number;
}
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
src,
alt,
width,
height,
quality = 75,
}) => {
return (
<Image
loader={customImageLoader}
src={src}
alt={alt}
width={width}
height={height}
quality={quality}
/>
);
};
よくある応用パターン
1. ユーザープロフィール画像との組み合わせ
// components/UserAvatar.tsx
import Image from 'next/image';
import { useMemo } from 'react';
interface UserAvatarProps {
imageUrl?: string;
userName: string;
size?: 'sm' | 'md' | 'lg';
}
const sizeMap = {
sm: { width: 32, height: 32, className: 'w-8 h-8' },
md: { width: 64, height: 64, className: 'w-16 h-16' },
lg: { width: 128, height: 128, className: 'w-32 h-32' },
};
export const UserAvatar: React.FC<UserAvatarProps> = ({
imageUrl,
userName,
size = 'md',
}) => {
const config = sizeMap[size];
const initials = useMemo(
() =>
userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2),
[userName]
);
if (!imageUrl) {
return (
<div
className={`${config.className} rounded-full bg-blue-500 flex items-center justify-center text-white font-bold`}>
{initials}
</div>
);
}
return (
<div className={`${config.className} rounded-full overflow-hidden relative bg-gray-200`}>
<Image
src={imageUrl}
alt={userName}
width={config.width}
height={config.height}
className=\"w-full h-full object-cover\"
quality={80}
/>
</div>
);
};
2. 背景画像として使用する場合
// components/HeroSection.tsx
import Image from 'next/image';
interface HeroSectionProps {
backgroundImage: string;
title: string;
subtitle: string;
}
export const HeroSection: React.FC<HeroSectionProps> = ({
backgroundImage,
title,
subtitle,
}) => {
return (
<div className=\"relative w-full h-96 overflow-hidden\">
<Image
src={backgroundImage}
alt=\"Hero background\"
fill
className=\"object-cover brightness-50\"
priority
quality={85}
sizes=\"100vw\"
/>
<div className=\"absolute inset-0 flex flex-col justify-center items-center text-white\">
<h1 className=\"text-5xl font-bold mb-4 drop-shadow-lg\">{title}</h1>
<p className=\"text-2xl drop-shadow-lg\">{subtitle}</p>
</div>
</div>
);
};
3. 画像のアップロード後の最適化
// lib/imageUpload.ts
import sharp from 'sharp';
import { v4 as uuidv4 } from 'uuid';
export async function processUploadedImage(
fileBuffer: Buffer,
fileName: string
): Promise<{
originalPath: string;
optimizedPath: string;
thumbnailPath: string;
}> {
const id = uuidv4();
const ext = fileName.split('.').pop();
try {
// オリジナル画像の保存
const originalPath = `/uploads/original/${id}.${ext}`;
await sharp(fileBuffer).toFile(`public${originalPath}`);
// 最適化版(WebP)の生成
const optimizedPath = `/uploads/optimized/${id}.webp`;
await sharp(fileBuffer)
.resize(1200, 1200, { withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(`public${optimizedPath}`);
// サムネイル版の生成
const thumbnailPath = `/uploads/thumbnail/${id}.webp`;
await sharp(fileBuffer)
.resize(300, 300, { fit: 'cover' })
.webp({ quality: 75 })
.toFile(`public${thumbnailPath}`);
return {
originalPath,
optimizedPath,
thumbnailPath,
};
} catch (error) {
console.error('Image processing failed:', error);
throw new Error('Failed to process uploaded image');
}
}
// pages/api/upload.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { processUploadedImage } from '@/lib/imageUpload';
export const config = {
api: {
bodyParser: {
sizeLimit: '10mb',
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { buffer, fileName } = req.body;
if (!buffer || !fileName) {
return res.status(400).json({ error: 'Missing required fields' });
}
const fileBuffer = Buffer.from(buffer);
const paths = await processUploadedImage(fileBuffer, fileName);
return res.status(200).json({
success: true,
paths,
});
} catch (error) {
return res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
注意点と実装時の落とし穴
1. 必ず width と height を指定する
Image コンポーネント使用時、width と height の指定は必須です。これらがないと CLS(累積レイアウトシフト)が発生し、Core Web Vitals に悪影響を及ぼします。
// ❌ 悪い例
<Image src=\"/image.jpg\" alt=\"example\" />
// ✅ 良い例
<Image src=\"/image.jpg\" alt=\"example\" width={400} height={300} />
// または fill を使用する場合は親要素が relative である必要がある
<div className=\"relative w-full h-96\">
<Image src=\"/image.jpg\" alt=\"example\" fill />
</div>
2. 外部ドメイン設定を忘れない
外部 URL の画像を使用する場合、next.config.js に domains を追加する必要があります。
3. sizes 属性を適切に設定する
sizes 属性がないと、ブラウザが画像サイズを判断できず、不適切なサイズの画像が配信される可能性があります。
// sizes を指定しない場合、デフォルトで 100vw が使用される
<Image
src=\"/image.jpg\"
alt=\"example\"
width={400}
height={300}
sizes=\"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px\"
/>
4. priority 属性の使い分け
LCP(Largest Contentful Paint)に関連する画像にのみ priority を付与します。
// ✅ ファーストビューの重要な画像
<Image src=\"/hero.jpg\" alt=\"hero\" priority />
// ❌ スクロールが必要な画像
<Image src=\"/below-fold.jpg\" alt=\"content\" />
5. 品質設定の最適化
quality パラメータは 1-100 の値を取ります。業務では以下を目安にします:
- サムネイル:75-80
- 通常表示:80-85
- 高品質が必要な場合:85-90
6. キャッシュ戦略
本番環境では、Image Optimization API のキャッシュが重要です。Vercel にデプロイしている場合は自動的に管理されていますが、セルフホストの場合は注意が必要です。
実装時のパフォーマンス測定
Google Lighthouse を使用して、以下を確認します:
npm run build
npm run start
# ブラウザで http://localhost:3000 を開き、
# Chrome DevTools → Lighthouse → Generate report
改善すべき指標:
- Largest Contentful Paint(LCP):2.5 秒以下
- Cumulative Layout Shift(CLS):0.1 以下
- First Input Delay(FID):100ms 以下
まとめ
Next.js の Image コンポーネントは、適切に設定することで、大幅なパフォーマンス改善が実現できます。本記事で紹介したパターンを業務に応用することで、以下のメリットが得られます:
- 自動フォーマット変換による帯域幅削減(平均 30-50%)
- CLS の防止による UX 改善
- レスポンシブ画像による読み込み時間短縮
- Core Web Vitals スコアの向上
重要なのは、単に Image コンポーネントを使用するだけではなく、width/height の指定、sizes 属性の最適化、quality の調整など、細部にこだわることです。これらの実装を通じて、ユーザーに快適なブラウジング体験を提供できます。
また、画像最適化は継続的なプロセスです。Lighthouse などのツールを定期的に実行し、パフォーマンス指標を監視することをお勧めします。

