{
  "id": "add-cloudflare-r2-upload",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "en",
  "url": "/playbooks/add-cloudflare-r2-upload",
  "title": "Prompt-to-PR: Add Cloudflare R2 File Upload",
  "description": "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.",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "Cloudflare",
    "TypeScript"
  ],
  "tags": [
    "cloudflare",
    "nextjs",
    "typescript",
    "upload"
  ],
  "difficulty": "medium",
  "updated": "2026-06-08",
  "markdown": "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.\n\n## 1. Requirement\n\nUsers 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.\n\n## 2. First Prompt\n\n```txt title=\"First Prompt\"\nAdd Cloudflare R2 file upload to this Next.js 15 App Router project.\n\nRequirements:\n- Use presigned PUT URLs (not proxy upload). The Next.js route only issues\n  the presigned URL; the browser uploads directly to R2.\n- Install `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`\n  (R2 is S3-compatible).\n- Create `src/app/api/upload/presign/route.ts` (POST). Accept JSON body\n  `{ filename: string; contentType: string; size: number }`. Validate:\n  allowed MIME types (image/jpeg, image/png, image/webp, application/pdf),\n  max size 10 MB. Return `{ uploadUrl, publicUrl, key }`.\n- Read R2 credentials from env vars:\n    R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY,\n    R2_BUCKET_NAME, NEXT_PUBLIC_R2_PUBLIC_URL.\n- Create `src/components/FileUpload.tsx` — a Client Component with a\n  drag-and-drop zone. On file select: POST to /api/upload/presign, then\n  PUT the file to the returned uploadUrl with the correct Content-Type header.\n  Show progress, handle errors, and call an `onUpload(publicUrl)` callback.\n- Do not store the file in any database table; that is the caller's\n  responsibility via the onUpload callback.\n```\n\n## 3. Expected File Changes\n\n```txt\npackage.json                                (@aws-sdk/client-s3, @aws-sdk/s3-request-presigner)\nsrc/app/api/upload/presign/route.ts         (new — presign endpoint)\nsrc/components/FileUpload.tsx               (new — drag-and-drop client component)\nsrc/lib/r2.ts                               (new — S3Client singleton)\n.env.local.example                          (R2_* vars)\n```\n\n## 4. Review Checklist\n\n- The presign endpoint validates MIME type and size before calling R2 — bad uploads rejected before any AWS call.\n- The `S3Client` in `src/lib/r2.ts` uses `endpoint: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com` and `region: \"auto\"`.\n- Presigned URL expiry is short (60–300 seconds); not a full day.\n- The `PUT` from the browser includes `Content-Type` header matching what was presigned — mismatches cause 403.\n- `NEXT_PUBLIC_R2_PUBLIC_URL` points to the bucket's public domain, not the R2 API endpoint.\n- `FileUpload.tsx` starts with `\"use client\"` — no server imports.\n- There is no size or type check only on the client side — the server must also validate.\n- The key uses a random prefix (e.g. `crypto.randomUUID()`) to avoid filename collisions.\n\n## 5. Test Commands\n\n```bash\n# Start dev server\nbun dev\n\n# Test presign endpoint directly\ncurl -X POST http://localhost:3000/api/upload/presign \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"filename\":\"test.png\",\"contentType\":\"image/png\",\"size\":12345}' | jq .\n\n# Confirm returned uploadUrl is an R2 presigned URL (contains X-Amz-Signature)\n# Then PUT a real file to confirm end-to-end\ncurl -X PUT \"<uploadUrl>\" \\\n  -H \"Content-Type: image/png\" \\\n  --data-binary @test.png -v\n\n# Fetch the public URL to verify the file is readable\ncurl -I \"<publicUrl>\"\n```\n\n## 6. Common Failures\n\n- **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.\n- **`NoSuchBucket`** — wrong bucket name or account ID. Double-check `R2_BUCKET_NAME` in the Cloudflare dashboard.\n- **`InvalidAccessKeyId`** — R2 API token needs \"Object Read & Write\" permission, not just \"Read\".\n- **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.\n- **Agent uses `@aws-sdk/s3-presigned-post`** (for POST) instead of `getSignedUrl` for PUT — different flow, different client code required.\n\n## 7. Fix Prompt\n\n```txt title=\"Fix Prompt\"\nThe browser PUT to R2 returns 403 SignatureDoesNotMatch.\n\nThe Content-Type passed to getSignedUrl must exactly match the Content-Type\nheader sent by the browser. Update the presign route to pass the contentType\nfrom the request body into getSignedUrl, and update FileUpload.tsx to set\nthe Content-Type header on the PUT request to the same value.\n\nAlso confirm the S3Client endpoint is:\n  https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com\nnot the generic AWS S3 endpoint.\n```\n\n## 8. PR Description\n\n```md title=\"PR description\"\n## Feature: Cloudflare R2 file upload via presigned URLs\n\n- New POST `/api/upload/presign` validates MIME type and size, then returns\n  a short-lived R2 presigned PUT URL\n- Files upload directly from the browser to R2 — zero binary data through\n  the Next.js server\n- New `<FileUpload>` component: drag-and-drop, progress indicator, error state\n- Random UUID key prefix prevents filename collisions\n\n**Required env vars** (see `.env.local.example`):\n`R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`,\n`R2_BUCKET_NAME`, `NEXT_PUBLIC_R2_PUBLIC_URL`\n\n**R2 bucket setup**: enable public access and add a CORS rule allowing PUT\nfrom your app origin.\n```"
}