# 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.

**Type:** Playbook  
**Tools:** Cursor, Claude Code, Codex, Windsurf  
**Stack:** Next.js, Cloudflare, TypeScript  
**Difficulty:** medium  
**Updated:** 2026-06-08

---

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

```txt title="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

```txt
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

```bash
# 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 PUT** — `Content-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

```txt title="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

```md title="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.
```