File Uploads
Use this page when you want teenybase records to store images, PDFs, avatars, or other files in Cloudflare R2.
The short version
Uploads work when all of these are true:
- your table has a field with
type: 'file' - your worker has an
r2_bucketsbinding - your worker passes
c.env.R2_BUCKETinto$Database
r2Base is recommended, but not required. If you omit it, teenybase uses the table name as the folder prefix.
1. Add a file field to your table
{
name: 'photos',
autoSetUid: true,
fields: [
...baseFields,
{ name: 'caption', type: 'text', sqlType: 'text' },
{ name: 'image', type: 'file', sqlType: 'text' },
{ name: 'owner', type: 'text', sqlType: 'text' },
],
r2Base: 'photos',
extensions: [
{
name: 'rules',
listRule: 'auth.uid == owner',
viewRule: 'auth.uid == owner',
createRule: 'auth.uid == owner',
updateRule: 'auth.uid == owner',
deleteRule: 'auth.uid == owner',
},
],
}Why r2Base: 'photos'?
- it makes the bucket path explicit
- it keeps file paths stable if you ever rename the table later
If your rule says auth.uid == owner, every create request must send the signed-in user's record.id as owner.
2. Add the R2 bucket binding to wrangler.jsonc
{
"r2_buckets": [
{
"binding": "R2_BUCKET",
"bucket_name": "my-app-files"
}
]
}3. Regenerate Cloudflare types
npx wrangler types --env-interface CloudflareBindings4. Pass the bucket into $Database
import {$Database, $Env, OpenApiExtension, PocketUIExtension, D1Adapter, teenyHono} from 'teenybase/worker'
import config from 'virtual:teenybase'
type Env = $Env & {Bindings: CloudflareBindings}
const app = teenyHono<Env>(async (c) => {
const db = new $Database(c, config, new D1Adapter(c.env.PRIMARY_DB), c.env.R2_BUCKET)
db.extensions.push(new OpenApiExtension(db, true))
db.extensions.push(new PocketUIExtension(db))
return db
})
export default appIf you forget the fourth argument, uploads will fail even if the bucket exists.
5. Apply the change locally
teeny deploy --localnpx teeny deploy --localteeny dev --localnpx teeny dev --local6. Upload from the browser
Teenybase uses a specific multipart format (not the typical values={...} pattern you might expect). File uploads need two form fields:
@filePayload— the actual file binary@jsonPayload— a JSON string describing the record. Reference the uploaded file by name with@filePayload.0(the.0means "the first file in that field")
const formData = new FormData()
formData.append('@filePayload', fileInput.files[0])
formData.append('@jsonPayload', JSON.stringify({
values: {
caption: 'Sunset',
owner: currentUserId,
image: '@filePayload.0',
},
returning: '*',
}))
const res = await fetch('http://localhost:8787/api/v1/table/photos/insert', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
})
const created = await res.json()
console.log(created[0].image)Do not set Content-Type manually for FormData. The browser will add the correct boundary.
7. Upload with curl
curl -X POST http://localhost:8787/api/v1/table/photos/insert \
-H 'Authorization: Bearer YOUR_TOKEN' \
-F '@filePayload=@photo.jpg' \
-F '@jsonPayload={"values":{"caption":"Sunset","owner":"YOUR_USER_ID","image":"@filePayload.0"},"returning":"*"}'Use the signed-in user's record.id as YOUR_USER_ID if your rules depend on owner.
8. Render or download the uploaded file
The file route is:
GET /api/v1/files/{table}/{recordId}/{path}path is the file path stored in the record's field value (e.g. photos/abc123.jpg), not the field name. You get this value from the record after inserting it.
Example:
const imagePath = created[0].image
const recordId = created[0].id
const imageUrl = `http://localhost:8787/api/v1/files/photos/${recordId}/${imagePath}`You can use that URL directly:
<img src="IMAGE_URL_HERE" alt="Uploaded photo">9. Shared file references
By default, teenybase assumes one record owns one file.
That means:
autoDeleteR2Filesdefaults totrue- deleting the record also deletes the underlying R2 object
That is the right default for most apps.
If you want multiple records to point at the same stored file, turn on shared references explicitly:
{
allowMultipleFileRef: true,
autoDeleteR2Files: false,
}allowMultipleFileRef: true requires autoDeleteR2Files: false.