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-SinceやIf-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" がベストプラクティスです。
参考