P PasteCode
Playbook

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
.md .json Difficulty: Medium Updated Jun 8, 2026

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

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 S3Client in src/lib/r2.ts uses endpoint: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com and region: "auto".
  • Presigned URL expiry is short (60–300 seconds); not a full day.
  • The PUT from the browser includes Content-Type header matching what was presigned — mismatches cause 403.
  • NEXT_PUBLIC_R2_PUBLIC_URL points to the bucket’s public domain, not the R2 API endpoint.
  • FileUpload.tsx starts 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

Terminal window
# Start dev server
bun dev
# Test presign endpoint directly
curl -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-end
curl -X PUT "<uploadUrl>" \
-H "Content-Type: image/png" \
--data-binary @test.png -v
# Fetch the public URL to verify the file is readable
curl -I "<publicUrl>"

6. Common Failures

  • 403 on PUTContent-Type header 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-check R2_BUCKET_NAME in 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 PUT from your origin. Set it in the Cloudflare dashboard under R2 → Settings → CORS.
  • Agent uses @aws-sdk/s3-presigned-post (for POST) instead of getSignedUrl for PUT — different flow, different client code required.

7. Fix Prompt

Fix Prompt
The browser PUT to R2 returns 403 SignatureDoesNotMatch.
The Content-Type passed to getSignedUrl must exactly match the Content-Type
header sent by the browser. Update the presign route to pass the contentType
from the request body into getSignedUrl, and update FileUpload.tsx to set
the Content-Type header on the PUT request to the same value.
Also confirm the S3Client endpoint is:
https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
not the generic AWS S3 endpoint.

8. PR Description

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 PUT
from your app origin.