R2 foundations
Model buckets, objects, and keys
Treat R2 as object storage with application-owned naming and authorization rather than a mounted relational filesystem.
R2 stores objects in buckets. An object is a body of bytes plus a key, some HTTP metadata such as the content type, optional custom metadata, and a version marker called an entity tag. That’s the entire model. No folders, no rows, no indexes.
If you’ve used Amazon S3, it’s the same idea. The pricing is the big difference. R2 charges nothing for egress, so serving files is cheap.
Create a bucket and bind it to a Worker:
npx wrangler r2 bucket create my-app-files
{
"r2_buckets": [
{ "binding": "FILES", "bucket_name": "my-app-files" }
]
}
Now env.FILES is the bucket in your code.
Keys are names, not paths
A key like tenant/123/uploads/report.pdf looks like a filesystem path, but the namespace is flat. The slashes are just characters in the name. R2 does not become a filesystem or a relational database because your keys contain slashes.
What a shared prefix gives you is cheap grouping. You can list everything under it:
const list = await env.FILES.list({ prefix: 'tenant/123/uploads/' })
for (const object of list.objects) {
console.log(object.key, object.size, object.etag)
}
Each entry carries the key, the size, the upload time, and the entity tag. There is no “rename folder” operation, because there are no folders. Changing a prefix means copying objects to new keys.
Design the namespace first
My advice is to put the owner first, then the purpose, then a server-generated name: tenant/123/uploads/<uuid>. The server picks the UUID, so two uploads can never collide and a user can never overwrite someone else’s file.
R2 only finds objects by key or prefix. Questions like “which files did this user upload last week” need a real query engine. Keep searchable metadata in D1, one row per object, keyed by the R2 key.
The classic early mistake is building the key from a request parameter. If the client sends ?path=tenant/456/uploads/x.pdf and you use it as the key, one tenant reads another tenant’s files. Take the tenant ID from the authenticated session, never from the request.
Try this: before shipping anything else, prove one tenant cannot choose another tenant’s prefix. Send a request with someone else’s tenant ID in the path and confirm your code ignores it.
Lesson completed