Next.js App RouterとCloudflare R2で画像を配信する実装手順

※本記事にはプロモーション(広告・アフィリエイトリンク)を含みます。
自作のECサイトで商品画像を public/ に置かず、Route Handler 経由でローカルディスクから配信する構成を取っている。この構成を Cloudflare R2 に切り替えると、アプリサーバーとストレージを分離でき、Cloudflare CDN によるエッジキャッシュも活かせる。この記事では Next.js App Router + TypeScript の環境で、R2 バケットの作成・アクセストークン設定・画像アップロード・Route Handler による配信まで、コマンドと実際のコードで手順を書く。
Cloudflare R2 の特徴と料金の概要
R2 は Cloudflare が提供する S3 互換のオブジェクトストレージ。最大の特徴は エグレス(データ転送出)料金がゼロであること。公式ドキュメントによると、執筆時点の料金は以下のとおり(変動する可能性があるため、実際に使う前に必ず公式ページで確認する)。
| 項目 | 無料枠 | 超過時の単価 |
|---|---|---|
| ストレージ | 10 GB/月 | $0.015/GB-月 |
| Class A(書き込み系) | 100万回/月 | $4.50/100万回 |
| Class B(読み出し系) | 1,000万回/月 | $0.36/100万回 |
| エグレス | 制限なし | $0 |
ECサイト程度の画像枚数なら無料枠に収まることが多いが、実際の利用量は運用してみないと分からない。
R2 バケットを作成して API トークンを発行する
Wrangler CLI でバケットを作成する
npm install -g wrangler
wrangler login
wrangler r2 bucket create ec-images
ダッシュボードから作る場合は「R2 Object Storage」→「Create bucket」で作成できる。バケット名は後から変更できないため、最初に決めておく。
API トークンを発行する
Cloudflare ダッシュボード右上のアカウントメニュー → 「My Profile」→「API Tokens」→「Create Token」を開く。テンプレートは「Edit Cloudflare Workers」ではなく 「Create Custom Token」 を選択し、次の権限だけを付与する。
| スコープ | 権限 |
|---|---|
| Account > R2 Storage | Edit |
トークンは発行時に一度しか表示されないので、すぐコピーする。あわせてダッシュボード右上に表示される Account ID もメモしておく。R2 のエンドポイント URL に使う。
環境変数を設定する
.env.local に追記する。
CLOUDFLARE_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxx
R2_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
R2_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
R2_BUCKET_NAME=ec-images
.env.local が .gitignore に含まれているか、コミット前に必ず確認する。
S3 互換クライアントを設定する
R2 は S3 互換 API を持つため、@aws-sdk/client-s3 をそのまま使える。
npm install @aws-sdk/client-s3
クライアントの初期化は lib/r2.ts にまとめておく。
// lib/r2.ts
import { S3Client } from '@aws-sdk/client-s3';
export const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
region: 'auto' は R2 固有の指定で、他の値(us-east-1 など)を渡すとエンドポイント解決に失敗してエラーになる。
画像を R2 にアップロードする Route Handler
サーバー側で受け取ってそのまま R2 に書き込む Route Handler の例。マジックバイト検証を挟むことで、拡張子の偽装を防げる。
// app/api/admin/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { r2 } from '@/lib/r2';
import { randomUUID } from 'crypto';
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'no file' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
// マジックバイトで JPEG / PNG だけ許可する
const isJpeg = buffer[0] === 0xff && buffer[1] === 0xd8;
const isPng = buffer.toString('hex', 0, 4) === '89504e47';
if (!isJpeg && !isPng) {
return NextResponse.json({ error: 'invalid file type' }, { status: 415 });
}
const ext = isJpeg ? 'jpg' : 'png';
const key = `products/${randomUUID()}.${ext}`;
await r2.send(new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: key,
Body: buffer,
ContentType: file.type,
}));
return NextResponse.json({ key });
}
このマジックバイト検証は、自作 ECサイトの WAF 層(proxy.ts)で実装しているものと同じ考え方で、拡張子だけに頼るより確実にファイル種別を確認できる。
R2 から画像を取得して返す Route Handler
// app/api/images/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { r2 } from '@/lib/r2';
export async function GET(
_req: NextRequest,
{ params }: { params: { path: string[] } }
) {
const key = params.path.join('/');
try {
const obj = await r2.send(new GetObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: key,
}));
const body = await obj.Body?.transformToByteArray();
if (!body) {
return new NextResponse(null, { status: 404 });
}
return new NextResponse(body, {
headers: {
'Content-Type': obj.ContentType ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (e: unknown) {
const err = e as { name?: string };
if (err?.name === 'NoSuchKey') {
return new NextResponse(null, { status: 404 });
}
return new NextResponse(null, { status: 500 });
}
}
Cache-Control: immutable にしているのは、キーに UUID を使っているため同一 URL で内容が変わらないから。同じキーで上書きする運用の場合は no-cache に変えること。
画像の参照は <img src="/api/images/products/xxxx.jpg" /> のように書く。next/image を使う場合は remotePatterns の設定は不要(自ドメイン発のリクエストになるため)だが、unoptimized prop または loader の設定が必要になる環境もある。
つまずきやすいポイントと対処
region: 'auto' の指定漏れ
S3 クライアントはデフォルトで us-east-1 を使おうとするため、省略するとエンドポイント解決に失敗する。R2 では必ず region: 'auto' を明示する。
Body の読み出しに transformToByteArray() を使う
GetObjectOutput.Body は AWS SDK v3 の SdkStream 型で、そのまま NextResponse に渡せない。transformToByteArray() で Uint8Array に変換してから渡す。
Cloudflare Tunnel 経由での注意点
自宅サーバーで Next.js を Cloudflare Tunnel 経由で公開している場合、Route Handler から R2 へのリクエストは「サーバー → インターネット → R2」という経路になる。エグレス料金はゼロだが、PutObjectCommand の呼び出しは Class A オペレーションとしてカウントされる。大量アップロードが発生する場合は無料枠の消費を確認しておく。
Cloudflare のインフラ全体の概要については Cloudflare Pagesに静的サイトを無料でデプロイする手順 も参考になる。
まとめ
| ステップ | 作業 |
|---|---|
| 1 | wrangler r2 bucket create でバケット作成 |
| 2 | Custom Token 発行、.env.local に設定 |
| 3 | @aws-sdk/client-s3 インストール、lib/r2.ts 作成 |
| 4 | アップロード用 Route Handler 実装 |
| 5 | 配信用 Route Handler 実装 |
次のステップとして、R2 バケットに カスタムドメインを紐付けると、Route Handler を経由せず Cloudflare CDN が直接画像を返すためアプリサーバーの負荷を下げられる。ダッシュボードの R2 バケット設定 →「Settings」→「Custom Domains」から設定できる(Cloudflare で管理しているドメインが必要)。まずは上記の Route Handler 構成で動作確認し、規模が大きくなったらカスタムドメインへの移行を検討するのがよい進め方だと考える。
よくある質問
Cloudflare R2 の無料枠はどのくらいですか?
公式ドキュメントによると執筆時点でストレージ10GB/月、Class A(書き込み)100万回/月、Class B(読み出し)1,000万回/月が無料です。エグレス料金はゼロです。料金は変動するため、使用前に公式ページで必ず確認してください。
R2 バケットを公開設定にすれば Route Handler は不要ですか?
パブリックバケットにするとURLを知れば誰でも画像にアクセスできます。Route Handler経由にすると認証チェックや参照元制限などの独自アクセス制御を挟めます。ECサイトの商品画像のように公開してよいものはパブリックバケット+カスタムドメインの方がシンプルです。
`@aws-sdk/client-s3` 以外に R2 を操作する方法はありますか?
Cloudflare Workers環境ではR2バインディングをネイティブで使えます。Node.js(Next.js)環境からリモートでアクセスする場合はS3互換APIを使う方法が標準的です。fetch で直接REST APIを呼ぶことも可能ですが、署名計算が必要なためSDKの利用が現実的です。
Next.js の `next/image` コンポーネントで R2 の画像を表示するには?
Route Handler経由のURL(/api/images/...)をsrcに渡す場合はremotePatternsの設定は不要です。R2のカスタムドメインを直接使う場合はnext.config.tsのremotePatternsにそのドメインを追加する必要があります。