Next.js の画像最適化を業務で活用するパターン集|実装コード付き

React / Next.js

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 などのツールを定期的に実行し、パフォーマンス指標を監視することをお勧めします。

タイトルとURLをコピーしました