File Storage
Enabling, binding, and using Cloudflare R2
Last updated November 3, 2026
User uploads live in Cloudflare R2, reached through the Workers BUCKET binding with zero egress fees. The code is in src/storage/.
The storage layer is implemented, but the repository ships no upload route:features.enableFileUploadisfalseby default and there is no/api/filesroute undersrc/routes/api/. This page covers how to wire it up when you need it. Not connecting R2 does not affect the template.
Steps
- Enable R2 in the Dashboard. This cannot be done through the API — R2 must be activated once in the Dashboard (a payment method is required; the free tier costs nothing). Until then the API rejects requests:
``text [code: 10042] Please enable R2 through the Cloudflare Dashboard. ``
- Create a bucket.
``bash pnpm exec wrangler r2 bucket create <your-bucket-name> ``
- Configure the binding. Uncomment the
r2_bucketsblock inwrangler.jsoncand fill it in:
``jsonc title="wrangler.jsonc" "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "<your-bucket-name>" }] ``
The binding is the name code reads (env.BUCKET) and must match src/types/cloudflare.d.ts. Without the binding env.BUCKET is undefined and getStorageProvider() throws — that is the intended degradation.
- Turn on the file upload flag.
``ts // src/config/website.ts features: { enableFileUpload: true, } ``
Rebuild and redeploy afterwards.
- Add your own upload route. The repository has no upload API; create a route under
src/routes/api/and calluploadFileinside it (see the usage below).
Usage
All functions are imported from @/storage and resolve the current request's provider through AsyncLocalStorage:
import { uploadFile, deleteFile, downloadFile, getFileInfo, listUserFiles } from '@/storage'
// Upload: folder is limited to uploads / avatars / documents
const result = await uploadFile(arrayBuffer, 'photo.png', 'image/png', {
folder: 'uploads',
userId: 'user_abc',
visibility: 'private',
})
// → { key, url, metadata }
// Delete: the second argument true means the caller confirmed ownership
await deleteFile(key, true)
// Download: private files require an explicit true
const stream = await downloadFile(key, true)
// Look up
const info = await getFileInfo(key) // { key, size, contentType, uploadedAt } | null
const { objects, nextCursor } = await listUserFiles('user_abc')Security rules
- The bucket stays private. Do not attach a public domain and do not use an
r2.devsubdomain; every read and write goes through the Worker on the server. - Authorization is not in the storage layer.
deleteFileanddownloadFileonly look at theauthorizedboolean and never query ownership, so the caller must check the session and the ownership first — otherwise any signed-in user can delete or overwrite someone else's files. - The key is assembled on the server, shaped like
uploads/<userId>/<timestamp>-<filename>:foldermust be a whitelisted value, and/,\,..and a leading.in the filename or user id are stripped while other illegal characters become_.
Common commands
| Command | What it does |
|---|---|
pnpm exec wrangler r2 bucket create <name> | Create a bucket |
pnpm exec wrangler r2 bucket list | List buckets |
pnpm exec wrangler r2 object put <bucket>/<key> --file <path> | Upload an object |
Once this is wired up you can move on to Cache.