Setup and context — Why Cloudflare R2?
File uploads and storage management are fundamental requirements in virtually every web application. Whether it's user avatars, product images, or document attachments, you need a reliable and cost-effective storage solution.
Cloudflare R2 stands out as a compelling choice because it offers zero egress fees while maintaining full S3 API compatibility. With AWS S3, you pay for every byte of data transferred out of your buckets. R2 eliminates that cost entirely, which can translate to significant savings for image-heavy or media-rich applications.
In this guide, we'll walk through building a complete file upload and image optimization pipeline using Antigravity's AI agents alongside Cloudflare R2 and Workers. You'll learn how to scaffold the entire system efficiently, from API routes to image processing.
What You'll Learn
- How to set up a Cloudflare R2 bucket and bind it to Workers
- Using Antigravity's agents to auto-generate upload API routes with TypeScript types
- Building an image resizing and WebP conversion pipeline on upload
- Implementing presigned URLs for secure, direct-to-R2 uploads
- Production-ready error handling, validation, and retry patterns
Prerequisites
- Basic familiarity with Cloudflare Workers and R2
- Antigravity IDE installed and configured
- A Cloudflare account with R2 enabled
Setting Up Cloudflare R2
Start by creating an R2 bucket from Antigravity's integrated terminal. If you ask Antigravity's agent to "create an R2 bucket and update wrangler.toml," it will execute the following steps automatically:
# Create the R2 bucket
npx wrangler r2 bucket create my-app-uploads
# Verify the bucket was created
npx wrangler r2 bucket listThen add the binding to your wrangler.toml:
# wrangler.toml
name = "my-upload-worker"
main = "src/index.ts"
compatibility_date = "2026-03-01"
[[r2_buckets]]
binding = "UPLOADS_BUCKET"
bucket_name = "my-app-uploads"This configuration makes the R2 bucket accessible in your Worker code as env.UPLOADS_BUCKET.
Auto-Generating the Upload API with Antigravity
Antigravity's agent capabilities make it straightforward to scaffold a complete upload API. Try giving the agent this prompt:
"Create a Cloudflare Workers API for uploading files to R2 in TypeScript. Include CORS support, a 10MB file size limit, and MIME type validation."
Here's the kind of production-quality code Antigravity generates:
// src/upload.ts
// R2 file upload handler
interface Env {
UPLOADS_BUCKET: R2Bucket;
ALLOWED_ORIGINS: string;
}
// Allowed MIME types
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"application/pdf",
];
// Maximum file size: 10MB
const MAX_FILE_SIZE = 10 * 1024 * 1024;
export async function handleUpload(
request: Request,
env: Env
): Promise<Response> {
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, {
headers: corsHeaders(env.ALLOWED_ORIGINS),
});
}
if (request.method !== "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return jsonResponse({ error: "No file provided" }, 400);
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return jsonResponse(
{ error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` },
413
);
}
// Validate MIME type
if (!ALLOWED_TYPES.includes(file.type)) {
return jsonResponse(
{ error: `File type ${file.type} is not allowed` },
415
);
}
// Generate a unique key
const key = generateKey(file.name);
// Upload to R2
await env.UPLOADS_BUCKET.put(key, file.stream(), {
httpMetadata: {
contentType: file.type,
},
customMetadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
});
return jsonResponse(
{
success: true,
key,
url: `/files/${key}`,
size: file.size,
type: file.type,
},
201,
corsHeaders(env.ALLOWED_ORIGINS)
);
} catch (err) {
console.error("Upload error:", err);
return jsonResponse({ error: "Upload failed" }, 500);
}
}
// Generate a unique file key
function generateKey(originalName: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const ext = originalName.split(".").pop() || "bin";
return `uploads/${timestamp}-${random}.${ext}`;
}
// CORS headers
function corsHeaders(origin: string): HeadersInit {
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
// JSON response helper
function jsonResponse(
data: Record<string, unknown>,
status: number,
headers?: HeadersInit
): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
}The agent-generated code includes CORS handling, file size limits, and MIME type validation right out of the box. Use this as your starting point and customize it for your specific project needs.
Building the Image Optimization Pipeline
Beyond basic uploads, let's add automatic image resizing and WebP conversion. When a user uploads an image, we'll generate multiple size variants optimized for different display contexts.
// src/image-optimizer.ts
// Image optimization pipeline
interface ImageVariant {
suffix: string;
width: number;
quality: number;
}
// Define the variants to generate
const VARIANTS: ImageVariant[] = [
{ suffix: "thumb", width: 150, quality: 75 },
{ suffix: "medium", width: 600, quality: 80 },
{ suffix: "large", width: 1200, quality: 85 },
];
export async function optimizeAndStore(
file: File,
key: string,
bucket: R2Bucket
): Promise<{ original: string; variants: Record<string, string> }> {
// Store the original
const originalKey = `originals/${key}`;
await bucket.put(originalKey, file.stream(), {
httpMetadata: { contentType: file.type },
});
const variantKeys: Record<string, string> = {};
// Use Cloudflare Image Resizing to generate variants
for (const variant of VARIANTS) {
const variantKey = `optimized/${variant.suffix}/${key.replace(
/\.[^.]+$/,
".webp"
)}`;
// Image Resizing API request
const resizeUrl = new URL(`https://your-domain.com/originals/${key}`);
const resizedResponse = await fetch(resizeUrl.toString(), {
cf: {
image: {
width: variant.width,
quality: variant.quality,
format: "webp",
fit: "cover",
},
},
});
if (resizedResponse.ok) {
await bucket.put(variantKey, resizedResponse.body, {
httpMetadata: { contentType: "image/webp" },
});
variantKeys[variant.suffix] = variantKey;
}
}
return { original: originalKey, variants: variantKeys };
}With this pipeline, a single uploaded image automatically produces three WebP variants: a thumbnail (150px), medium (600px), and large (1200px). This dramatically reduces bandwidth usage and improves page load times.
Secure Uploads with Presigned URLs
For scenarios where you want the client to upload directly to R2 — bypassing your application server entirely — presigned URLs are the way to go. Your server generates a time-limited, signed URL, and the client uses it to PUT the file directly into R2.
// src/presigned.ts
// Presigned URL generation endpoint
import {
S3Client,
PutObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
interface Env {
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
R2_ACCOUNT_ID: string;
R2_BUCKET_NAME: string;
}
export async function generatePresignedUrl(
env: Env,
filename: string,
contentType: string
): Promise<{ uploadUrl: string; key: string }> {
const s3 = new S3Client({
region: "auto",
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});
const key = `uploads/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: env.R2_BUCKET_NAME,
Key: key,
ContentType: contentType,
});
// Presigned URL expires in 15 minutes
const uploadUrl = await getSignedUrl(s3, command, {
expiresIn: 900,
});
return { uploadUrl, key };
}On the client side, the upload flow becomes:
// Frontend: get presigned URL and upload directly
async function uploadFile(file: File): Promise<string> {
// Step 1: Request a presigned URL from the server
const res = await fetch("/api/presigned-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
});
const { uploadUrl, key } = await res.json();
// Step 2: Upload directly to R2 using the presigned URL
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
return key;
}This approach keeps file data off your application server, reducing memory pressure and request processing time. It's especially beneficial for large file uploads.
Production Best Practices
When you ask Antigravity's agent to "add production-grade error handling and retry logic," it can generate robust patterns like this:
// src/upload-with-retry.ts
// Upload function with retry logic
async function uploadWithRetry(
bucket: R2Bucket,
key: string,
body: ReadableStream,
metadata: R2PutOptions,
maxRetries = 3
): Promise<R2Object> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await bucket.put(key, body, metadata);
if (result === null) {
throw new Error("R2 put returned null");
}
return result;
} catch (err) {
lastError = err as Error;
console.warn(
`Upload attempt ${attempt}/${maxRetries} failed: ${lastError.message}`
);
if (attempt < maxRetries) {
// Exponential backoff: 1s, 2s, 4s
await new Promise((r) =>
setTimeout(r, Math.pow(2, attempt - 1) * 1000)
);
}
}
}
throw new Error(
`Upload failed after ${maxRetries} attempts: ${lastError?.message}`
);
}Key Considerations for Production
- Sanitize file names: User-submitted file names may contain dangerous characters. Generate keys using timestamps and random strings, and store the original file name in metadata instead
- Verify Content-Type: Don't rely solely on the client-provided
Content-Typeheader. Validate file magic bytes (the first few bytes of the file) to catch disguised uploads - Lifecycle rules: Configure automatic deletion rules for temporary files and outdated variants in the R2 dashboard to keep storage costs under control
- Access control: Separate public files (profile images, etc.) from private files (invoices, etc.) using different buckets or key prefixes
For a deeper dive into authentication, validation, and rate limiting patterns for Cloudflare Workers APIs, check out Building Secure API Backends with Antigravity Agents.
Adding Image Analysis with Workers AI
By combining R2 with Cloudflare's Workers AI, you can add automatic image tagging and content moderation to your upload pipeline.
// src/image-analysis.ts
// Image analysis with Workers AI
interface Env {
AI: Ai;
UPLOADS_BUCKET: R2Bucket;
}
export async function analyzeImage(
env: Env,
imageKey: string
): Promise<{ labels: string[]; safe: boolean }> {
// Retrieve image from R2
const object = await env.UPLOADS_BUCKET.get(imageKey);
if (!object) {
throw new Error("Image not found");
}
const imageBytes = await object.arrayBuffer();
// Run image classification with Workers AI
const classificationResult = await env.AI.run(
"@cf/microsoft/resnet-50",
{
image: [...new Uint8Array(imageBytes)],
}
);
// Get top 5 labels
const labels = classificationResult
.slice(0, 5)
.map((r: { label: string }) => r.label);
return {
labels,
safe: true, // In production, use an NSFW detection model
};
}For a comprehensive guide on building edge AI applications that combine Workers AI with R2 storage, see Building Edge AI Apps with Antigravity and Cloudflare Workers AI.
Summary
In this guide, we built a complete file upload and image optimization pipeline using Antigravity's AI agents and Cloudflare R2. By leveraging R2's zero-egress pricing model, you can serve images and files at scale without worrying about bandwidth costs eating into your margins.
The real productivity win comes from Antigravity's agents — describe what you need, and the agent generates type-safe upload APIs, validation logic, and retry mechanisms. Start with a small project, create your first R2 bucket, and try building an upload endpoint. You'll be surprised how quickly you can go from zero to a production-ready file management system.