File Uploads

Use this page when you want teenybase records to store images, PDFs, avatars, or other files in Cloudflare R2.

Using a coding agent?

The short version

Uploads work when all of these are true:

  • your table has a field with type: 'file'
  • your worker has an r2_buckets binding
  • your worker passes c.env.R2_BUCKET into $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

ts
{
  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

jsonc
{
  "r2_buckets": [
    {
      "binding": "R2_BUCKET",
      "bucket_name": "my-app-files"
    }
  ]
}

3. Regenerate Cloudflare types

bash
npx wrangler types --env-interface CloudflareBindings

4. Pass the bucket into $Database

ts
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 app

If you forget the fourth argument, uploads will fail even if the bucket exists.

5. Apply the change locally

bash
teeny deploy --local
bash
npx teeny deploy --local
bash
teeny dev --local
bash
npx teeny dev --local

6. 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 .0 means "the first file in that field")
js
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

bash
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:

text
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:

js
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:

html
<img src="IMAGE_URL_HERE" alt="Uploaded photo">

9. Shared file references

By default, teenybase assumes one record owns one file.

That means:

  • autoDeleteR2Files defaults to true
  • 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:

ts
{
  allowMultipleFileRef: true,
  autoDeleteR2Files: false,
}

allowMultipleFileRef: true requires autoDeleteR2Files: false.