R2 Storage
Cloudflare R2 for production image uploads, with local filesystem fallback for dev.
Cloudflare R2 is an S3-compatible object store with zero egress fees. Free tier: 10 GB + 1M ops/month.
What gets uploaded
- Product / submission thumbnails
- Sponsor images
All uploads go through /api/upload (app/api/upload/route.ts), which authenticates the user and pipes the file to lib/r2.ts.
Setup
- Cloudflare Dashboard → R2 → Create bucket
- Enable public access — via the r2.dev subdomain or a custom domain (recommended for prod)
- Create an API token with Object Read & Write permission on the bucket
- Fill in:
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET_NAME=...
R2_PUBLIC_URL=https://cdn.yoursite.com
NEXT_PUBLIC_CDN_HOSTNAME=cdn.yoursite.comIf any of the R2_* vars is missing, lib/r2.ts falls back to writing into public/uploads/ on disk (via uploadLocal()). Fine for dev; breaks on ephemeral filesystems in production.
NEXT_PUBLIC_CDN_HOSTNAME is read directly by next.config.mjs to add the CDN host to next/image remotePatterns. It's not validated by the Zod env schema — if you forget it, images load via raw URLs in dev but next/image throws "hostname not allowed" in production.
Image processing
Before upload, app/api/upload/route.ts uses sharp to:
- Resize to 1280×720
- Convert to WebP at 80% quality
This normalizes all thumbnails regardless of what users upload.
File constraints
- Content types accepted:
image/jpeg,image/png,image/webp - Max file size: configurable via the
max_thumbnail_size_mbsetting (default 5 MB)
Rejections return a 4xx with an error message ("Only JPEG/PNG/WebP allowed" or "File too large").
Rate limit
upload:{userId} — 20 uploads per hour per user (lib/rate-limit.ts).
Custom domain setup
Using the r2.dev subdomain works but is rate-limited by Cloudflare and gives ugly URLs. Recommended: attach a custom domain.
- Bucket settings → Custom Domains → Connect Domain
- Enter
cdn.your-domain.com - Cloudflare auto-creates the CNAME (if your domain is already on Cloudflare DNS)
- Update
R2_PUBLIC_URL+NEXT_PUBLIC_CDN_HOSTNAMEto the custom domain
NEXT_PUBLIC_CDN_HOSTNAME is referenced in next.config.mjs to whitelist the host for next/image. Missing this → images fail with "hostname not allowed".
Switching to S3 / B2 / MinIO
The code uses the AWS S3 SDK, which works against any S3-compatible endpoint. Change the endpoint in lib/r2.ts and set the matching access keys.
Common issues
"Access Denied" on public URL — Public access not enabled on the bucket.
CORS error in browser — R2 CORS is not auto-configured. Add a CORS policy in bucket settings:
[{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET"] }]Uploads work locally but fail in prod — Missing one of the 5 R2 env vars. The code silently falls back to filesystem, which fails on ephemeral hosts.
Trailing slash in R2_PUBLIC_URL — Breaks URL construction. Remove trailing slash.