Prompt-to-PR: Add Cloudflare R2 File Upload
End-to-end SOP for wiring presigned R2 uploads into a Next.js or Cloudflare Workers app — bucket binding, presigned URLs, and client upload flow.
CursorClaude CodeCodexWindsurf Next.jsCloudflareTypeScript
Add file upload to R2 without proxying binary data through your server. The agent generates a presigned URL server-side; the browser PUT goes directly to R2.
1. Requirement
Users can upload files (images, PDFs, up to 10 MB) via a drag-and-drop UI. The server issues a presigned PUT URL; the browser streams the file directly to Cloudflare R2. A public read URL is returned after upload. No files pass through the Next.js server.
2. First Prompt
Add Cloudflare R2 file upload to this Next.js 15 App Router project.
Requirements:- Use presigned PUT URLs (not proxy upload). The Next.js route only issues the presigned URL; the browser uploads directly to R2.- Install `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` (R2 is S3-compatible).- Create `src/app/api/upload/presign/route.ts` (POST). Accept JSON body `{ filename: string; contentType: string; size: number }`. Validate: allowed MIME types (image/jpeg, image/png, image/webp, application/pdf), max size 10 MB. Return `{ uploadUrl, publicUrl, key }`.- Read R2 credentials from env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, NEXT_PUBLIC_R2_PUBLIC_URL.- Create `src/components/FileUpload.tsx` — a Client Component with a drag-and-drop zone. On file select: POST to /api/upload/presign, then PUT the file to the returned uploadUrl with the correct Content-Type header. Show progress, handle errors, and call an `onUpload(publicUrl)` callback.- Do not store the file in any database table; that is the caller's responsibility via the onUpload callback.3. Expected File Changes
package.json (@aws-sdk/client-s3, @aws-sdk/s3-request-presigner)src/app/api/upload/presign/route.ts (new — presign endpoint)src/components/FileUpload.tsx (new — drag-and-drop client component)src/lib/r2.ts (new — S3Client singleton).env.local.example (R2_* vars)4. Review Checklist
- The presign endpoint validates MIME type and size before calling R2 — bad uploads rejected before any AWS call.
- The
S3Clientinsrc/lib/r2.tsusesendpoint: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.comandregion: "auto". - Presigned URL expiry is short (60–300 seconds); not a full day.
- The
PUTfrom the browser includesContent-Typeheader matching what was presigned — mismatches cause 403. NEXT_PUBLIC_R2_PUBLIC_URLpoints to the bucket’s public domain, not the R2 API endpoint.FileUpload.tsxstarts with"use client"— no server imports.- There is no size or type check only on the client side — the server must also validate.
- The key uses a random prefix (e.g.
crypto.randomUUID()) to avoid filename collisions.
5. Test Commands
# Start dev serverbun dev
# Test presign endpoint directlycurl -X POST http://localhost:3000/api/upload/presign \ -H "Content-Type: application/json" \ -d '{"filename":"test.png","contentType":"image/png","size":12345}' | jq .
# Confirm returned uploadUrl is an R2 presigned URL (contains X-Amz-Signature)# Then PUT a real file to confirm end-to-endcurl -X PUT "<uploadUrl>" \ -H "Content-Type: image/png" \ --data-binary @test.png -v
# Fetch the public URL to verify the file is readablecurl -I "<publicUrl>"6. Common Failures
- 403 on PUT —
Content-Typeheader in the browser PUT does not match the one used when presigning. Ensure both use the exact same string. NoSuchBucket— wrong bucket name or account ID. Double-checkR2_BUCKET_NAMEin the Cloudflare dashboard.InvalidAccessKeyId— R2 API token needs “Object Read & Write” permission, not just “Read”.- CORS error on direct PUT — R2 bucket CORS policy must allow
PUTfrom your origin. Set it in the Cloudflare dashboard under R2 → Settings → CORS. - Agent uses
@aws-sdk/s3-presigned-post(for POST) instead ofgetSignedUrlfor PUT — different flow, different client code required.
7. Fix Prompt
The browser PUT to R2 returns 403 SignatureDoesNotMatch.
The Content-Type passed to getSignedUrl must exactly match the Content-Typeheader sent by the browser. Update the presign route to pass the contentTypefrom the request body into getSignedUrl, and update FileUpload.tsx to setthe Content-Type header on the PUT request to the same value.
Also confirm the S3Client endpoint is: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.comnot the generic AWS S3 endpoint.8. PR Description
## Feature: Cloudflare R2 file upload via presigned URLs
- New POST `/api/upload/presign` validates MIME type and size, then returns a short-lived R2 presigned PUT URL- Files upload directly from the browser to R2 — zero binary data through the Next.js server- New `<FileUpload>` component: drag-and-drop, progress indicator, error state- Random UUID key prefix prevents filename collisions
**Required env vars** (see `.env.local.example`):`R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`,`R2_BUCKET_NAME`, `NEXT_PUBLIC_R2_PUBLIC_URL`
**R2 bucket setup**: enable public access and add a CORS rule allowing PUTfrom your app origin.