Next.js Image Optimization を実務で活用するパターン集
\n\n
はじめに
\n
Next.jsの Image Optimization は、単なる便利機能ではなく、実務プロジェクトにおいてパフォーマンス改善と開発効率の両立を実現する重要な機能です。本記事では、教科書的な説明ではなく、実際のプロジェクトで直面する課題と、その解決パターンを具体的なコードで紹介します。
\n\n
Next.js Image Optimization の基本理解
\n
Next.jsの Image コンポーネントは、以下の最適化を自動で行います:
\n
- \n
- 画像フォーマットの自動変換(WebP対応)
- レスポンシブ画像のサイズ最適化
- 遅延読み込み(Lazy Loading)
- Cumulative Layout Shift(CLS)の防止
\n
\n
\n
\n
\n\n
これらの機能により、ページの読み込み速度が向上し、SEO順位の改善にも直結します。しかし、実務では「どうやって使い分けるか」という判断が重要になってきます。
\n\n
業務でのユースケース
\n\n
ユースケース1:ECサイトの商品画像管理
\n
ECサイトでは、数千〜数万の商品画像を扱うため、最適化の有無で読み込み速度に大きな差が出ます。ユーザーが商品一覧ページを訪れた際に、すべての商品画像を最初から読み込むと、初期表示が非常に遅くなります。
\n\n
ユースケース2:ブログサイトのアイキャッチ画像
\n
ブログ記事のアイキャッチ画像は、品質を落とさずにファイルサイズを削減する必要があります。複数解像度への対応も課題になります。
\n\n
ユースケース3:マーケティングダッシュボード
\n
管理画面では、ユーザーアバターやサムネイル画像が多数表示されます。これらを効率良く表示しながら、キャッシュを活用して応答性を高めることが求められます。
\n\n
実装コード:実務パターン
\n\n
パターン1:ECサイトの商品一覧ページ
\n\n
商品一覧ページでは、複数の商品画像を高速に読み込みつつ、各デバイス向けに最適なサイズを提供する必要があります。以下は実際のプロダクションコードです。
\n\n
// components/ProductCard.tsx\nimport Image from 'next/image';\nimport { useState } from 'react';\n\ninterface ProductCardProps {\n id: string;\n name: string;\n imageUrl: string;\n price: number;\n discount?: number;\n}\n\nexport const ProductCard = ({\n id,\n name,\n imageUrl,\n price,\n discount,\n}: ProductCardProps) => {\n const [isImageLoading, setIsImageLoading] = useState(true);\n\n // CloudFlareやAwsなどの画像最適化サービスを使う場合\n const getOptimizedImageUrl = (url: string): string => {\n // 外部CDNを利用する場合はここで処理\n if (url.includes('cloudinary')) {\n return `${url}?q=auto&f=auto`;\n }\n return url;\n };\n\n return (\n \n \n setIsImageLoading(false)}\n className={`transition-opacity duration-300 ${\n isImageLoading ? 'opacity-0' : 'opacity-100'\n }`}\n sizes=\"(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw\"\n />\n \n {name}
\n \n {discount ? (\n <>\n ${price}\n ${(price * (1 - discount / 100)).toFixed(2)}\n >\n ) : (\n ${price}\n )}\n \n \n );\n};\n
\n\n
// pages/products.tsx - 商品一覧ページ\nimport { GetStaticProps } from 'next';\nimport { ProductCard } from '@/components/ProductCard';\nimport { useState, useEffect } from 'react';\n\ninterface Product {\n id: string;\n name: string;\n imageUrl: string;\n price: number;\n discount?: number;\n}\n\ninterface ProductsPageProps {\n products: Product[];\n}\n\nconst ProductsPage = ({ products }: ProductsPageProps) => {\n const [displayProducts, setDisplayProducts] = useState([]);\n const [page, setPage] = useState(1);\n const ITEMS_PER_PAGE = 20;\n\n // クライアント側での無限スクロール対応\n useEffect(() => {\n setDisplayProducts(products.slice(0, ITEMS_PER_PAGE * page));\n }, [page, products]);\n\n const handleLoadMore = () => {\n setPage((prev) => prev + 1);\n };\n\n return (\n \n 商品一覧
\n \n {displayProducts.map((product) => (\n \n ))}\n \n {displayProducts.length < products.length && (\n \n )}\n \n );\n};\n\nexport const getStaticProps: GetStaticProps = async () => {\n // データベースから商品情報を取得\n const products = await fetchProductsFromDB();\n\n return {\n props: {\n products,\n },\n revalidate: 3600, // 1時間ごとに再生成\n };\n};\n\nexport default ProductsPage;\n
\n\n
パターン2:ブログのアイキャッチ画像と複数解像度対応
\n\n
ブログ記事では、異なるデバイスで見栄えの良いアイキャッチ画像を表示する必要があります。さらに、SEO的にも最適化が重要です。
\n\n
// components/BlogArticleCard.tsx\nimport Image from 'next/image';\nimport Link from 'next/link';\nimport { ReactNode } from 'react';\n\ninterface BlogArticleCardProps {\n slug: string;\n title: string;\n excerpt: string;\n featureImage: {\n src: string;\n alt: string;\n };\n publishedAt: string;\n author: string;\n readTime: number;\n}\n\nexport const BlogArticleCard = ({\n slug,\n title,\n excerpt,\n featureImage,\n publishedAt,\n author,\n readTime,\n}: BlogArticleCardProps) => {\n // 公開日時のフォーマット\n const formattedDate = new Date(publishedAt).toLocaleDateString('ja-JP', {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n });\n\n return (\n \n \n \n \n \n \n \n \n \n 読了時間 {readTime}分\n \n \n {title}\n
\n {excerpt}
\n \n 執筆者: {author}\n \n 続きを読む →\n \n \n \n \n );\n};\n
\n\n
// pages/blog/[slug].tsx - ブログ詳細ページ\nimport { GetStaticProps, GetStaticPaths } from 'next';\nimport Image from 'next/image';\nimport Head from 'next/head';\n\ninterface BlogPost {\n slug: string;\n title: string;\n content: string;\n featureImage: {\n src: string;\n alt: string;\n };\n publishedAt: string;\n author: string;\n}\n\ninterface BlogPostPageProps {\n post: BlogPost;\n}\n\nconst BlogPostPage = ({ post }: BlogPostPageProps) => {\n const canonicalUrl = `https://yourdomain.com/blog/${post.slug}`;\n\n return (\n <>\n \n {post.title} \n \n \n \n \n \n {post.title}
\n \n \n \n \n \n {post.author}\n \n \n \n >\n );\n};\n\nexport const getStaticPaths: GetStaticPaths = async () => {\n const posts = await fetchBlogPostsFromDB();\n const paths = posts.map((post) => ({\n params: { slug: post.slug },\n }));\n\n return {\n paths,\n fallback: 'blocking',\n };\n};\n\nexport const getStaticProps: GetStaticProps = async ({\n params,\n}) => {\n const slug = params?.slug as string;\n const post = await fetchBlogPostBySlug(slug);\n\n if (!post) {\n return { notFound: true };\n }\n\n return {\n props: { post },\n revalidate: 86400, // 24時間ごとに再生成\n };\n};\n\nexport default BlogPostPage;\n
\n\n
パターン3:ユーザーアバター画像の最適化
\n\n
ダッシュボードやユーザープロフィールでは、複数のアバター画像が表示されます。これらは外部ソースからの取得になることが多いため、ドメイン設定と動的なサイズ対応が重要です。
\n\n
// next.config.js\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n images: {\n // 複数の画像ソースを許可\n domains: [\n 'gravatar.com',\n 'cdn.example.com',\n 'avatars.githubusercontent.com',\n 'images.unsplash.com',\n ],\n // 本番環境での画像最適化設定\n formats: ['image/avif', 'image/webp'],\n // 画像の最適化を有効化\n minimumCacheTTL: 60,\n // より多くのサイズを対応させる場合\n deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],\n imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],\n },\n};\n\nmodule.exports = nextConfig;\n
\n\n
// components/UserAvatar.tsx\nimport Image from 'next/image';\nimport { useMemo } from 'react';\n\ninterface UserAvatarProps {\n userId: string;\n avatarUrl?: string;\n userName: string;\n size?: 'small' | 'medium' | 'large';\n}\n\nconst AVATAR_SIZES = {\n small: 32,\n medium: 64,\n large: 128,\n};\n\nexport const UserAvatar = ({\n userId,\n avatarUrl,\n userName,\n size = 'medium',\n}: UserAvatarProps) => {\n const pixelSize = AVATAR_SIZES[size];\n\n // フォールバック画像の生成(Gravatar API)\n const fallbackUrl = useMemo(\n () => `https://gravatar.com/avatar/${userId}?d=identicon&s=${pixelSize * 2}`,\n [userId, pixelSize]\n );\n\n const imageUrl = avatarUrl || fallbackUrl;\n\n return (\n \n {\n // 画像読み込み失敗時の処理\n (e.target as HTMLImageElement).src = fallbackUrl;\n }}\n />\n \n );\n};\n
\n\n
よくある応用パターン
\n\n
応用1:ダークモード対応の画像表示
\n\n
多くのモダンなUIでは、ライトモード・ダークモードの両方に対応する必要があります。Next.js Image では直接対応していないため、工夫が必要です。
\n\n
// components/DarkModeAwareImage.tsx\nimport Image, { ImageProps } from 'next/image';\nimport { useEffect, useState } from 'react';\n\ninterface DarkModeAwareImageProps extends ImageProps {\n lightSrc: string;\n darkSrc: string;\n}\n\nexport const DarkModeAwareImage = ({\n lightSrc,\n darkSrc,\n ...props\n}: DarkModeAwareImageProps) => {\n const [isDark, setIsDark] = useState(false);\n\n useEffect(() => {\n // ダークモードの状態を検出\n const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n setIsDark(prefersDark);\n\n // ダークモード変更をリッスン\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n const handleChange = (e: MediaQueryListEvent) => setIsDark(e.matches);\n mediaQuery.addEventListener('change', handleChange);\n\n return () => mediaQuery.removeEventListener('change', handleChange);\n }, []);\n\n return (\n \n );\n};\n
\n\n
応用2:画像ギャラリーでの効率的な読み込み
\n\n
商品詳細ページで複数の画像を表示する場合、すべてを読み込むのではなく、選択されている画像だけを優先して読み込む方法が効果的です。
\n\n
// components/ImageGallery.tsx\nimport Image from 'next/image';\nimport { useState, useCallback } from 'react';\n\ninterface ImageGalleryProps {\n images: Array<{\n id: string;\n src: string;\n alt: string;\n }>;\n}\n\nexport const ImageGallery = ({ images }: ImageGalleryProps) => {\n const [selectedIndex, setSelectedIndex] = useState(0);\n const selectedImage = images[selectedIndex];\n\n const handleThumbnailClick = useCallback((index: number) => {\n setSelectedIndex(index);\n }, []);\n\n return (\n \n \n \n \n \n {images.map((image, index) => (\n \n ))}\n \n \n );\n};\n
\n\n
応用3:外部APIから動的に取得した画像の最適化
\n\n
マーケットプレイスやSaaS型の管理画面では、ユーザーがアップロードした画像を扱うことが多いです。この場合、セキュリティと最適化の両立が重要です。
\n\n
// lib/imageOptimizer.ts\n/**\n * 外部ソースの画像をセキュアに最適化\n * Next.js の Image Optimization を活用\n */\n\ninterface ImageOptimizationConfig {\n maxWidth?: number;\n maxHeight?: number;\n quality?: number;\n}\n\nexport const optimizeExternalImage = (\n imageUrl: string,\n config: ImageOptimizationConfig = {}\n): string => {\n const {\n maxWidth = 1200,\n maxHeight = 800,\n quality = 75,\n } = config;\n\n // Vercelの画像最適化APIを直接利用(オプション)\n // ただし、next.config.jsで外部ドメインを許可する必要がある\n if (process.env.NEXT_PUBLIC_USE_VERCEL_OPTIMIZATION === 'true') {\n const url = new URL('/_next/image', process.env.NEXT_PUBLIC_APP_URL);\n url.searchParams.append('url', imageUrl);\n url.searchParams.append('w', maxWidth.toString());\n url.searchParams.append('q', quality.toString());\n return url.toString();\n }\n\n return imageUrl;\n};\n\n// コンポーネントでの使用\ninterface SafeExternalImageProps {\n src: string;\n alt: string;\n maxWidth?: number;\n maxHeight?: number;\n}\n\nexport const SafeExternalImage = ({\n src,\n alt,\n maxWidth = 800,\n maxHeight = 600,\n}: SafeExternalImageProps) => {\n // URLの妥当性チェック\n let imageUrl: string;\n try {\n const url = new URL(src);\n imageUrl = url.toString();\n } catch {\n // 相対パスの場合\n imageUrl = src;\n }\n\n const optimizedUrl = optimizeExternalImage(imageUrl, {\n maxWidth,\n maxHeight,\n quality: 75,\n });\n\n return (\n {\n console.error(`Failed to load image: ${src}`, e);\n }}\n />\n );\n};\n
\n\n
注意点と落とし穴
\n\n
注意1:width と height 属性の必須性
\n\n
静的にサイズが決定できない場合、fill プロパティを使用します。ただし、この場合は親要素を position: relative で定義する必要があります。不正な設定は Cumulative Layout Shift(CLS)の原因となり、SEO順位を低下させます。
\n\n
// NG: 親要素の position が指定されていない\n<div>\n <Image src=\"...\" alt=\"...\" fill />\n</div>\n\n// OK: 親要素に position: relative を指定\n<div style={{ position: 'relative', width: '100%', height: '300px' }}>\n <Image src=\"...\" alt=\"...\" fill />\n</div>\n
\n\n
注意2:priority と preload の過度な使用
\n\n
priority={true} を多数の画像に設定すると、メインスレッドを圧迫し、逆にパフォーマンスが低下します。LCP(Largest Contentful Paint)に該当する画像のみに限定しましょう。
\n\n
// NG: すべての画像に priority を設定\n{products.map((product) => (\n <Image key={product.id} src={product.image} priority />\n))}\n\n// OK: ファーストビューに見える画像のみ\n{products.map((product, index) => (\n <Image\n key={product.id}\n src={product.image}\n priority={index < 3} // 最初の3つだけ\n />\n))}\n
\n\n
注意3:外部ドメインの許可設定
\n\n
多くの業務アプリケーションでは、CDN や外部サーバーの画像を利用します。これらのドメインを next.config.js で明示的に許可しないと、実行時エラーが発生します。
\n\n
// next.config.js - 実務的な設定例\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n images: {\n domains: process.env.ALLOWED_IMAGE_DOMAINS?.split(',') || [\n 'localhost:3000', // 開発環境\n 'cdn.example.com', // 本社CDN\n 'api.example.com', // APIサーバー\n 'user-uploads.example.com', // ユーザーアップロード\n ],\n // 環境に応じた設定\n remotePatterns: process.env.NODE_ENV === 'production' ? [\n {\n protocol: 'https',\n hostname: '**.example.com',\n },\n ] : [],\n },\n};\n\nmodule.exports = nextConfig;\n
\n\n
注意4:placeholder=\”blur\” の正しい活用
\n\n
blurDataURL を指定しないと、blur エフェクトが機能しません。また、静的に生成できない場合は、適切なデフォルト値を用意することが重要です。
\n\n
// ヘルパー関数:ブラー用の SVG データを生成\nexport const generateBlurDataUrl = (color: string = '#f0f0f0'): string => {\n const svg = `\n <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'>\n <rect fill='${encodeURIComponent(color)}' width='1' height='1'/>\n </svg>\n `;\n return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;\n};\n\n// 使用例\n<Image\n src={imageUrl}\n alt=\"...\" \n placeholder=\"blur\"\n blurDataURL={generateBlurDataUrl('#e0e0e0')}\n/>\n
\n\n
注意5:モバイル環境でのキャッシング戦略
\n\n
Next.js の Image Optimization は、サーバー側でキャッシュされます。本番環境では、キャッシュ戦略を明示的に定義することで、不要な再生成を避けられます。
\n\n

