Cloudflare R2 — S3-compatible object storage with zero egress fees.
R2 is S3-compatible object storage with zero egress fees — you pay only for storage and operations, not for data transfer out. Critical for AI workloads that serve large outputs.
Free tier: 10GB storage, 1M Class A ops/month (writes), 10M Class B ops/month (reads). Paid: $0.015/GB-month storage, $4.50/M Class A, $0.36/M Class B.
[[r2_buckets]]
binding = "R2"
bucket_name = "my-bucket"
# For separate read-only public bucket
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "my-assets"
wrangler r2 bucket create my-bucket
wrangler r2 bucket list
// Upload (put)
await env.R2.put('documents/report-2026.pdf', pdfBuffer, {
httpMetadata: { contentType: 'application/pdf' },
customMetadata: { uploadedBy: userId, projectId },
});
// Download (get)
const obj = await env.R2.get('documents/report-2026.pdf');
if (!obj) return new Response('Not found', { status: 404 });
return new Response(obj.body, {
headers: { 'Content-Type': obj.httpMetadata?.contentType ?? 'application/octet-stream' },
});
// Stream directly (memory-efficient for large files)
const obj = await env.R2.get(key);
if (!obj) return new Response('Not found', { status: 404 });
return new Response(obj.body, { headers: { 'Content-Type': obj.httpMetadata?.contentType ?? '' } });
// Delete
await env.R2.delete('documents/old-report.pdf');
// List objects
const listed = await env.R2.list({ prefix: 'documents/', limit: 100 });
for (const obj of listed.objects) {
console.log(obj.key, obj.size, obj.uploaded);
}
// Head (metadata only, no body)
const head = await env.R2.head('documents/report-2026.pdf');
if (head) console.log(head.size, head.customMetadata);
Generate a time-limited URL for direct browser → R2 uploads without going through your Worker:
import { AwsClient } from 'aws4fetch';
const r2 = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto',
});
const url = new URL(`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET}/${key}`);
url.searchParams.set('X-Amz-Expires', '3600'); // 1 hour
const signed = await r2.sign(new Request(url, { method: 'PUT' }), { aws: { signQuery: true } });
return Response.json({ uploadUrl: signed.url });
Enable public access in the Cloudflare dashboard or connect a custom domain:
https://pub-<id>.r2.dev/<key> # default public URL
https://assets.yourdomain.com/<key> # custom domain (via Pages or R2 domain settings)
// For files >100MB — breaks into parts
const upload = await env.R2.createMultipartUpload(key, { httpMetadata: { contentType } });
const part1 = await upload.uploadPart(1, chunk1);
const part2 = await upload.uploadPart(2, chunk2);
await upload.complete([part1, part2]);
/ in key names as a convention (e.g., users/123/avatar.jpg).obj.body is a ReadableStream: Don't read it twice. If you need both the body and metadata, cache the body to a variable.r2.dev work out of the box; custom domains require a Cloudflare zone.endpoint to https://.r2.cloudflarestorage.com and use auto as the region.