画像付きCMSで難しいのは、画像をアップロードする瞬間ではありません。公開後もURLが変わらず、端末幅が変わってもレイアウトが崩れず、失敗時に記事本文まで失わない仕組みを作ることです。
この記事では、Next.js 16 + vinextでCloudflare Workersへ配信するブログを題材に、D1には本文と画像URL、R2には画像本体を保存する設計を整理します。実装だけでなく、どこで失敗を止めるか、公開後に何を確認するかまで扱います。

最初に決める5つの条件
画像機能を作り始める前に、次の条件を固定します。
• 画像本体と記事本文を同じ保存先へ詰め込まない
• 公開済み画像のURLを上書きで使い回さない
• MIME typeと容量をサーバー側でも検証する
• Markdownには画像URLとaltテキストだけを保存する
• 公開成功は「保存APIが200」ではなく「記事ページで画像が見える」まで確認する
D1のBLOBへ画像を入れることも技術的には可能ですが、バックアップ、転送量、キャッシュの責務が記事データと混ざります。D1は検索・並び替え・更新競合の管理に集中させ、画像はオブジェクトストレージへ分けた方が運用しやすくなります。
保存先は3択で考える
`public/` に置く
ビルド時点で画像が確定している解説記事には最も単純です。Gitで差分を追え、同じデプロイに画像も含まれます。一方、管理画面から随時アップロードする用途には向きません。画像を1枚追加するたびに再ビルドが必要だからです。
R2へ置く
CMSから追加する画像にはR2が合います。Workerのbindingから直接保存でき、記事側には取得URLだけを残せます。UUIDやコンテンツハッシュを含む新しいkeyを毎回発行すれば、Cache-Control: public, max-age=31536000, immutable のような長期キャッシュを安全に使えます。
外部画像URLをそのまま使う
最初は速いものの、外部サイトの削除、ホットリンク制限、利用規約、レスポンス速度に記事品質が依存します。引用元へのリンクは残しつつ、掲載許可のある自前画像は管理下のストレージへ保存する方が安定します。

アップロードAPIは「保存前」に失敗させる
アップロードの入口では、管理者認証を最初に確認します。その後でContent-Length、実データのサイズ、許可する画像形式を検証します。SVGを許可する場合はスクリプトを含められるため、一般的な写真・図解のアップロードではJPEG、PNG、WebP、GIFなど必要な形式だけに絞る方が安全です。
保存keyは次のように、日付と一意なIDを含めると追跡しやすくなります。
text
thumbnails/2026-07-16/<uuid>.webp
同じkeyへ上書きしない設計なら、ブラウザやCDNに古い画像が残る「更新したのに見た目が変わらない」問題を避けられます。差し替えは新しい画像を保存し、記事側のURLを更新します。使われなくなったobjectは、参照確認後に別の保守処理で削除します。
Markdownには表示の意図を残す
本文には次のような標準的な画像記法を保存します。
markdown

altテキストはファイル名の言い換えではなく、画像が伝える内容を書きます。「image-01」ではなく「R2から記事へ画像を配信する構成図」のようにすると、画像を見られない読者にも意味が残ります。
独自Markdown parserを使う場合は、対応範囲を明示します。たとえば画像記法を1行単位で判定する実装なら、画像の前後に空行を入れ、文章と同じ行に置かない運用にします。保存できる記法と描画できる記法がずれると、管理画面では正しく見えても公開ページでは文字列のまま残ります。
`next/image`では寸法と許可範囲を固定する
Imageへwidthとheightを渡す主目的は、画像をそのピクセル数に固定することではなく、読み込み前からアスペクト比を確保してレイアウトシフトを抑えることです。本文図解を16:9で統一するなら、width={1200}、height={675}のように比率を明示し、CSSでwidth: 100%とobject-fitを調整します。
外部hostの画像を最適化する場合は、Next.jsのimages.remotePatternsを広く許可しすぎないことが重要です。protocol、hostname、pathnameを必要な範囲へ絞ります。同一originのWorker取得routeを使う設計なら、公開URLを自サイト配下へ統一でき、外部host許可を増やさずに済みます。
公開は6段階で確認する
記事保存APIが成功しても、画像公開が成功したとは限りません。アップロードと記事保存の間には、R2 binding、取得route、Markdown parser、next/image、ブラウザの5つの境界があります。

確認順は次の通りです。
1. 画像アップロードが成功し、返されたURLが空でない
2. 画像URLを直接GETして200と正しい`Content-Type`を確認する
3. 記事本文へalt付きMarkdownを挿入する
4. 下書きプレビューで画像の比率と余白を確認する
5. 公開記事をPC幅とモバイル幅の両方で開く
6. DevToolsで404、CSP、画像最適化エラーがないことを確認する
途中で失敗したら記事を公開状態にしない、または本文だけでも読める表示へフォールバックします。画像の失敗が記事全体の500エラーへ連鎖しないことが重要です。
キャッシュは「URLを変える」前提で長くする
公開画像を高速にする最も単純な戦略は、objectを上書きせず、内容が変わるたびにURLを変えることです。その前提があれば、長いmax-ageとimmutableを設定できます。
逆に、cover.pngのような固定URLを上書きする場合は、キャッシュ削除や短いTTLが必要です。実装は簡単に見えますが、CDN、ブラウザ、SNSのOGキャッシュを同時に更新する必要があり、運用は複雑になります。
失敗しやすい実装
• 画像をbase64のまま記事JSONへ保存する
• clientが送ったContent-Typeだけを無条件で信用する
• 画像保存前に記事へURLだけ書き込む
• 既存keyへ上書きして長期immutable cacheを付ける
• altを空欄のまま量産する
• 画像URLの直接GETをせず、管理画面の成功toastだけで完了とする
• 外部画像hostをワイルドカードで広く許可する
画像配信はUI部品ではなく、小さなデータパイプラインです。保存先、URL、キャッシュ、本文記法、公開確認を一続きで設計すると、画像を増やしても壊れにくいCMSになります。
参考資料
• Next.js Image Component
• Next.js 16へのアップグレード
• Cloudflare R2 Workers API
• Cloudflare R2 Public buckets