Product and category images are stored in Cloudflare R2. The dashboard uploads directly to R2 via presigned URLs (browser → R2, never through the Worker). The storefront serves images through the R2 custom domain with Cloudflare Image Resizing.
- Dashboard calls
POST /api/images/presignwith a MIME type. - Worker generates a signed R2 PUT URL (10-minute expiry) and returns
{ presignedUrl, key, publicUrl }. - Browser PUTs the file directly to R2 using the presigned URL.
- Dashboard calls
POST /api/products/:id/imageswith{ key, src: publicUrl }to create the database record. - Storefront reads
srcfrom the database and serves it throughhttps://<MEDIA_DOMAIN>/...— Cloudflare Image Resizing rewrites the URL to/cdn-cgi/image/<params>/<key>automatically.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/images/upload |
products:manage |
Multipart upload through the Worker (≤ 10 MB) |
POST |
/api/images/presign |
products:manage |
Generate presigned URL for direct browser → R2 upload |
GET |
/images/:key |
none | Serve image from R2 (fallback — prefer the R2 custom domain) |
The serve route (GET /images/:key) uses plain Hono (not OpenAPIHono) because
the :key{.+} regex param is required for keys containing slashes
(products/abc.jpg). A legacy OpenAPI documentation stub exists in
openapi.ts.
Set these via wrangler secret put — never in wrangler.toml:
| Secret | Where to get it |
|---|---|
CF_ACCOUNT_ID |
Cloudflare dashboard → top-right account menu |
R2_ACCESS_KEY_ID |
R2 → Manage R2 API Tokens → Create API Token (Object Read & Write) |
R2_SECRET_ACCESS_KEY |
Same token creation — shown once |
MEDIA_DOMAIN and R2_BUCKET_NAME are non-secret and live in wrangler.toml [vars].
| Secret | Value |
|---|---|
MEDIA_DOMAIN |
Your R2 custom domain hostname, e.g. media.yourdomain.com |
Set via wrangler secret put MEDIA_DOMAIN --name <theme01-worker-name> then
redeploy the storefront.
Two one-time steps before image uploads work from a browser:
Cloudflare dashboard → R2 → your bucket → Settings → Custom Domains →
Connect Domain. Enter your media domain (e.g. media.yourdomain.com).
Cloudflare adds the DNS record automatically.
Cloudflare dashboard → R2 → your bucket → Settings → CORS Policy → Add CORS policy. Paste this JSON (replace the origin with your dashboard domain):
[
{
"AllowedOrigins": [
"https://app.yourdomain.com",
"http://localhost:3000"
],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["Content-Type", "Content-Length"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]Without this, browser presigned PUT requests are blocked by CORS and image uploads fail silently.
images/
├── routes.ts — upload + presign routers; plain-Hono serve router
├── handlers.ts — uploadImage, serveImage, listProductImages, saveProductImage,
│ reorderProductImages, deleteProductImage
├── presign.ts — presigned URL generation via @aws-sdk/client-s3 S3Client
├── openapi.ts — legacy OpenAPI stub for the serve route
└── *.test.ts — unit + integration tests