React, Vue.js
投稿日:

Next.js の画像最適化を AI 駆動で徹底する ─ LCP 4.8秒を1.6秒にした実装プロセス

株式会社ファストコーディングのフルスタックエンジニア MrFireです。

先日、久しぶりにバイクで房総半島をツーリングしてきました。途中で見つけた海沿いのカフェが気になって、スマホでサイトを開いたのですが、、、、メイン画像がなかなか表示されない。3G回線でもないのに、画面の半分が白いまま数秒間待たされました。結局その場で「まあいいか」と閉じてしまったのですが、帰宅後にDevToolsで見てみると、案の定トップの画像が3MBのPNGでした。

ツーリング先でもDevToolsを開いてしまうのは職業病だと思いたい。

この体験から数日後、ちょうど案件でECサイトのパフォーマンス改善を依頼されました。Core Web VitalsのLCPが4.8秒で真っ赤。原因の大半が画像まわりだったので、今回はAI駆動開発のアプローチでNext.jsの画像最適化を徹底した話を書きます。

案件の状況:LCPが4.8秒で真っ赤だった

クライアントは30商品ほどを扱うアパレルECサイトを運営していました。Next.js 14のApp Routerで構築されたサイトですが、PageSpeed Insightsのスコアはモバイルで38点。LCPは4.8秒、CLSは0.15と、Core Web Vitalsの指標が軒並み基準を下回っていました。

「商品画像を高画質で見せたい」というクライアントの意向から、撮影した画像をそのままアップロードしていたのが根本原因です。1枚あたり2〜5MBのPNGファイルが、トップページだけで8枚並んでいました。

AIに画像実装のレビューを依頼する

まず、既存のコードをClaudeに渡して問題点を洗い出してもらいました。プロンプトは以下のような内容です。

「このNext.jsプロジェクトの画像実装をレビューしてください。パフォーマンス観点で問題がある箇所と、next/imageを使った改善案を提示してください。」

AIが指摘したポイントは、大きく3つありました。

  • HTMLの<img>タグを直接使っており、Next.jsの画像最適化パイプラインが機能していない
  • widthとheightの指定がなく、CLSの原因になっている
  • すべての画像が初期ロード時に読み込まれており、ビューポート外の画像にもlazy loadingが適用されていない

ここからBad CaseとGood Caseを見ていきます。

Bad Case:生の <img> タグで画像を配置していた

AIが指摘した既存コードの問題箇所を再現すると、以下のような実装になっていました。

// Bad Case: 生の <img> タグで画像を配置
export function ProductCard({ product }: { product: Product }) {
  return (
    <div className="product-card">
      <img src={`/images/products/${product.slug}.png`} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price.toLocaleString()}円</p>
    </div>
  );
}

一見すると普通のコードですが、パフォーマンス観点では3つの問題があります。

1つ目は、widthとheightが指定されていない点です。ブラウザは画像のダウンロードが完了するまでレイアウト領域を確保できません。画像が読み込まれた瞬間にページ全体がガタッとずれる。CLSが0.15まで悪化していた直接の原因です。

2つ目は、Next.jsの<Image>コンポーネントを使っていない点です。Next.jsには画像のリサイズ、フォーマット変換(WebP/AVIF)、レスポンシブ対応を自動で行う仕組みがあります。生の<img>タグではこれらが一切動きません。

3つ目は、すべての画像が同時にロードされる点です。画面に見えていない商品カードの画像も初期ロードに含まれるため、LCPの画像が後回しになっていました。

Good Case:next/image で最適化した実装

AIの提案をベースに修正したコードがこちらです。

// Good Case: next/image で最適化
import Image from 'next/image';

export function ProductCard({ product }: { product: Product }) {
  return (
    <div className="product-card">
      <Image
        src={`/images/products/${product.slug}.png`}
        alt={product.name}
        width={640}
        height={800}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder="blur"
        blurDataURL={product.blurDataUrl}
      />
      <h3>{product.name}</h3>
      <p>{product.price.toLocaleString()}円</p>
    </div>
  );
}

変更点を整理します。

widthとheightを明示することで、ブラウザがレイアウト領域を事前に確保します。画像の読み込み前後でレイアウトがずれなくなり、CLSが改善します。

sizes属性でビューポート幅に応じた画像サイズを指定しています。モバイルでは画面幅いっぱい、タブレットでは50%、デスクトップでは33%の幅で表示されるため、必要以上に大きな画像をダウンロードしなくなります。

placeholder="blur"とblurDataURLを組み合わせることで、画像の読み込み中にぼかしたプレビューを表示します。ユーザー体験として「何かが表示されている」状態を作れるため、体感的な待ち時間が短くなります。

ファーストビューの画像には priority を指定する

ここで、AIが最初の提案では見落としていた部分があります。商品一覧ページのファーストビューに表示される画像にpriorityプロパティを指定していなかったのです。

Next.jsの<Image>コンポーネントは、デフォルトでlazy loading(遅延読み込み)が有効です。ビューポート外の画像を後から読み込むことで初期ロードを軽くする仕組みですが、ファーストビューの画像まで遅延させてしまうとLCPが遅くなります。

私がAIの出力に手を加えた部分がこちらです。

// ファーストビュー用:priority を指定してプリロードする
import Image from 'next/image';

type ProductGridProps = {
  products: Product[];
};

export function ProductGrid({ products }: ProductGridProps) {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {products.map((product, index) => (
        <div key={product.id} className="product-card">
          <Image
            src={`/images/products/${product.slug}.png`}
            alt={product.name}
            width={640}
            height={800}
            sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
            priority={index < 3}
            placeholder="blur"
            blurDataURL={product.blurDataUrl}
          />
          <h3>{product.name}</h3>
          <p>{product.price.toLocaleString()}円</p>
        </div>
      ))}
    </div>
  );
}

priority={index < 3}で、最初の3枚だけプリロード対象にしています。ファーストビューに見える可能性がある画像だけを優先的に読み込み、残りはlazy loadingに任せます。この判断はAIに「ファーストビューに何枚の商品が表示されるか」を伝えないと正確にできません。レイアウトの知識が必要な部分は、人間が判断する領域です。

next.config.js で画像フォーマットとサイズを最適化する

コンポーネントの修正と合わせて、Next.jsの設定ファイルでも画像最適化のパラメータを調整しました。

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 30,
  },
};

export default nextConfig;

formatsでavifをwebpより前に指定しています。AVIFはWebPよりもさらに圧縮率が高いフォーマットです。ブラウザがAVIFに対応していればAVIFを、対応していなければWebPを、どちらも非対応ならオリジナルのフォーマットで配信します。Next.jsがこの判定を自動で行うため、開発者はフォーマット変換のコードを書く必要がありません。

deviceSizesはデフォルトから不要なサイズを削っています。実際のデバイス幅に合わせてバリエーションを絞ることで、キャッシュ効率が上がります。

minimumCacheTTLを30日(2,592,000秒)に設定しました。商品画像の差し替え頻度が月1回程度だったため、キャッシュを長めに保持する判断にしています。

blurDataURL の生成を自動化する

placeholder="blur"を使うには、各画像のぼかしプレビュー(Base64エンコードされた小さな画像)を用意する必要があります。AIにこの生成スクリプトの作成も依頼しました。

// scripts/generate-blur.ts
import { readdir } from 'fs/promises';
import { join } from 'path';
import sharp from 'sharp';

async function generateBlurDataUrls() {
  const imageDir = join(process.cwd(), 'public/images/products');
  const files = await readdir(imageDir);
  const pngFiles = files.filter((f) => f.endsWith('.png'));

  const results: Record<string, string> = {};

  for (const file of pngFiles) {
    const filePath = join(imageDir, file);
    const buffer = await sharp(filePath).resize(10, 10).blur().toBuffer();
    const base64 = `data:image/png;base64,${buffer.toString('base64')}`;
    const slug = file.replace('.png', '');
    results[slug] = base64;
  }

  console.log(JSON.stringify(results, null, 2));
}

generateBlurDataUrls();

sharpライブラリで画像を10×10ピクセルにリサイズし、ぼかし処理をかけてBase64に変換しています。このスクリプトの出力をJSONファイルとして保存し、コンポーネントから参照する仕組みです。

AIが生成したこのスクリプトは、ほぼそのまま動きました。画像処理のような定型的な変換処理は、AIが正確なコードを出しやすい領域です。

改善結果:LCP 4.8秒 → 1.6秒

すべての修正を適用した結果、以下の改善が得られました。

指標BeforeAfter改善幅
LCP4.8秒1.6秒67%短縮
CLS0.150.0287%改善
PageSpeed(モバイル)38点89点+51点
画像転送量(トップページ)24MB1.8MB92%削減

画像転送量が24MBから1.8MBに減ったのが最も大きな変化です。AVIFフォーマットへの自動変換と、sizesによるレスポンシブ配信が効いています。

LCPの改善にはpriority指定が直接的に寄与しました。ファーストビューの画像をプリロードすることで、ブラウザの優先度制御が正しく機能するようになっています。

AI が苦手だった部分:アートディレクションと品質判断

今回のプロジェクトで、AIが適切な回答を出せなかった領域が2つあります。

1つ目はアートディレクションです。モバイルとデスクトップで画像のトリミング位置を変えたいケースがありました。たとえば商品の全体像をデスクトップで見せつつ、モバイルではロゴ部分をクローズアップするような表現です。<picture>要素と<source>を組み合わせた実装が必要ですが、「どこをトリミングするか」はデザイナーの判断であり、AIが決められるものではありません。

2つ目は画像の圧縮品質です。Next.jsのimages.qualityパラメータ(デフォルト75)をどの程度にするかは、商品のジャンルによって異なります。今回のアパレルECでは、生地の質感が伝わることが重要だったため、品質を85に設定しました。ファイルサイズと見た目のトレードオフは、実際の画像を見ながら人間が判断する領域です。

AI駆動開発では「何をAIに任せ、何を人間が判断するか」の線引きが重要です。画像フォーマットの選定やコンポーネントの書き換えはAIが得意ですが、デザインの意図や品質の許容範囲は人間が決める必要があります。

まとめ

今回は、Next.jsの画像最適化をAI駆動開発で実装したプロセスを紹介しました。

画像はWebページの転送量の大部分を占めます。とくにECサイトのように画像が主役のサイトでは、最適化の効果がCore Web Vitalsの数値に直結します。

押さえておきたいポイントは以下の3つです。

  • next/imageコンポーネントとsizes属性で、デバイスに応じた適切なサイズの画像を自動配信する
  • ファーストビューの画像にはpriorityを指定し、LCPの原因となる遅延読み込みを防ぐ
  • next.config.tsでformats: ['image/avif', 'image/webp']を設定し、モダンフォーマットへの自動変換を有効にする

実際のプロジェクトでは、これらの修正によってLCPが4.8秒から1.6秒に改善し、PageSpeedスコアは38点から89点に向上しました。

株式会社ファストコーディングでは、React/Next.jsのフロントエンド実装やAI駆動開発のご相談を承っています。画像最適化やCore Web Vitalsの改善でお困りの際は、お問い合わせフォームからお気軽にご連絡ください。