Documentation Next.js

はじめに

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に直接影響します。

指標説明画像・フォントの影響
LCPLargest Contentful Paintヒーロー画像の読み込み速度
CLSCumulative Layout Shift画像・フォントによるレイアウトずれ
FID/INPFirst 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短い間ブロックしてから表示ロゴなど
fallback100ms間ブロック、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: swapCLS防止、FOUT軽減
Blur Placeholderplaiceholder知覚的読み込み速度向上

ベストプラクティス

  1. ファーストビューの画像にはpriority: LCPに影響する画像は必ず優先読み込み
  2. 適切なsizes属性: 不必要に大きな画像をダウンロードしない
  3. next/fontを使用: 外部リクエストを排除しCLSを防止
  4. blur placeholderを活用: 知覚的な読み込み速度を向上
  5. 画像フォーマットの最適化: AVIF > WebP > JPEG/PNGの優先順位

参考文献

円