---
title: "File Uploads"
description: "Store images, PDFs and other files in Cloudflare R2 from teenybase records: the file field, the R2 binding, and uploads from the browser and curl."
url: https://teenybase.com/docs/guides/file-uploads
last_updated: 2026-10-26
---

> Documentation index: https://teenybase.com/llms.txt lists every teenybase docs page with a link to its Markdown version. All docs in one file: https://teenybase.com/llms-full.txt

# 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?** Paste this prompt:
>
> Add file uploads to this teenybase app using the real teenybase R2 flow. Configure the table field, worker R2 binding, worker entry point, browser upload request, and download URL.

## 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
npx teenybase deploy --local
```

```bash
npx teenybase dev
```

## 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`.
