Next.js の Image 最適化を OFF にして Cloudflare R2 で画像配信する理由

はじめに

Next.js の <Image> コンポーネントは便利ですが、Cloudflare R2 で画像を配信する場合、あえて最適化を OFF にする選択肢があります。

この記事では、その理由と設定方法、LCP(Largest Contentful Paint)対策について解説します。
※ Vercel にホスティングしていることが前提です。

 

TL;DR

  • Cloudflare R2 + 自前 WebP 変換をしているなら unoptimized: true でOK
  • Vercel の画像最適化課金を回避できる
  • ただし fetchPriority="high" は手動で追加する必要がある

Next.js Image の2つのモード

1. 最適化 ON(デフォルト)

// next.config.mjs
const nextConfig = {
images: {
// unoptimized: false(デフォルト)
},
}

処理フロー

ユーザー → Next.js/Vercel サーバー → 外部画像URL

リサイズ・WebP変換

最適化された画像を返す

メリット

  • デバイスに応じた画像サイズを自動生成
  • WebP/AVIF 形式に自動変換
  • priority プロップで fetchpriority="high" が自動付与

デメリット

  • Vercel の画像最適化を使用(無料枠あり、超過で課金)
  • 外部ドメインは remotePatterns で許可が必要
  • Vercel 依存になる

2. 最適化 OFF

// next.config.mjs
const nextConfig = {
images: {
unoptimized: true,
},
}

処理フロー

ユーザー → Cloudflare R2(直接)

元の画像をそのまま返す

メリット

  • シンプル、Vercel 課金なし
  • Cloudflare CDN のキャッシュがそのまま効く
  • Vercel 以外のホスティングでも同じ動作

デメリット

  • 画像最適化なし(アップロードしたサイズのまま)
  • fetchPriority="high" を手動で書く必要あり

どちらを選ぶべきか?

条件 おすすめ
Vercel でホスティング + 画像最適化を任せたい 最適化 ON
Cloudflare R2 + 自前で WebP 変換している 最適化 OFF
Vercel 課金を避けたい 最適化 OFF
将来 Vercel 以外に移行する可能性がある 最適化 OFF

最適化 OFF 時の LCP 対策

unoptimized: true の場合、priority プロップだけでは fetchpriority="high" が自動付与されません。

❌ これだけでは不十分

<Image
src={imageUrl}
alt="Hero image"
fill
priority // これだけだと fetchpriority が付かない
/>

✅ 明示的に fetchPriority を追加

<Image
src={imageUrl}
alt="Hero image"
fill
priority
fetchPriority="high" // これを追加
className="object-cover"
sizes="100vw"
/>

なぜ fetchPriority=”high” が重要か?

LCP(Largest Contentful Paint)は Core Web Vitals の重要指標です。

fetchPriority=”high” の効果

  • HTMLから即座に発見可能:ブラウザがHTMLをパースした時点で画像URLがわかる
  • 優先的にダウンロード:他のリソースより先に画像を取得
  • 遅延読み込みしないloading="lazy" が適用されない

CSS background-image との違い

// ❌ LCP に悪い - CSS パース後に初めて画像 URL が判明
<div style={{ backgroundImage: `url(${imageUrl})` }} />

// ✅ LCP に良い - HTML から即座に発見可能
<Image src={imageUrl} priority fetchPriority="high" />

Cloudflare R2 での画像配信設定

アップロード時に WebP 変換

// 画像アップロード処理の例
import sharp from 'sharp'

async function uploadImage(file: File, key: string) {
const buffer = await file.arrayBuffer()

// WebP に変換
const webpBuffer = await sharp(Buffer.from(buffer))
.webp({ quality: 80 })
.toBuffer()

// R2 にアップロード
await r2.put(key, webpBuffer, {
httpMetadata: {
contentType: 'image/webp',
cacheControl: 'public, max-age=31536000', // 1年キャッシュ
},
})
}

キャッシュ設定

Lighthouse で「Use efficient cache lifetimes」の警告が出る場合、R2 アップロード時の Cache-Control ヘッダーを確認してください。

// 推奨: 静的画像は1年キャッシュ + immutable
cacheControl: 'public, max-age=31536000, immutable'

immutable ディレクティブとは?

immutable は「このリソースは絶対に変更されない」とブラウザに伝えるディレクティブです。

通常のキャッシュ(immutable なし)

Cache-Control: public, max-age=31536000
  • ブラウザはキャッシュを持っていても、ページ再訪問時に条件付きリクエストを送る
  • サーバーに If-Modified-SinceIf-None-Match で「変わった?」と確認
  • 変わってなければ 304 Not Modified(ボディなし)
  • リクエスト自体は発生する → 遅延の原因に

immutable あり

Cache-Control: public, max-age=31536000, immutable
  • ブラウザは「このファイルは絶対変わらない」と理解
  • キャッシュ有効期間中はサーバーへの確認すら行わない
  • ネットワークリクエスト = 0 → 最速

図解

【immutable なし】
ユーザー再訪問 → ブラウザ「変わった?」→ サーバー「304 変わってないよ」
└─ リクエスト発生(数十ms〜)

【immutable あり】
ユーザー再訪問 → ブラウザ「キャッシュ使う」→ 即表示
└─ リクエストなし(0ms)

いつ immutable を使うべきか?

ケース immutable 理由
ハッシュ付きファイル名(hero-abc123.webp) ✅ 使う ファイル名で一意、変更時は別URLになる
固定ファイル名(logo.png)で差し替える可能性あり ❌ 使わない 変更が反映されなくなる
R2 に一度アップしたら変更しない運用 ✅ 使う 実質 immutable

注意点

immutable を付けたファイルを同じURLで差し替えると、ユーザーのブラウザに古いキャッシュが残り続けます。

対策

  • ファイル名にハッシュやバージョンを含める(例:image-v2.webp
  • または、画像 ID を URL に含める(例:/images/{uuid}.webp

R2 で画像を管理する場合、通常はアップロード時に一意の ID を振るので、immutable を付けても問題ありません。

まとめ

項目 最適化 ON 最適化 OFF
設定 デフォルト unoptimized: true
画像変換 自動 自前で対応
fetchpriority 自動 手動で追加
Vercel 課金 あり得る なし
Vercel 依存 あり なし

Cloudflare R2 で自前 WebP 変換をしているなら、unoptimized: true + 手動 fetchPriority="high" がベストプラクティスです。

参考

 

技術者のページへも、是非ご訪問ください

https://zenn.dev/takabo/articles/3e6760b37c9ddc