# Uploading files

> Upload files to Supabase Storage on SupaHost from the browser, a server or an Edge Function. Covers upload options, overwriting, signed upload URLs and the 50 MB limit.

Source: https://supahost.app/docs/storage/uploads · Updated 2026-11-11

Uploads go to your project's Storage API with `supabase.storage.from(bucket).upload()`. The request carries the user's access token, so RLS on `storage.objects` decides whether the upload is allowed. Each file can be up to 50 MB on SupaHost.

## A basic upload from the browser

This example lets a user attach a PDF to an invoice in an invoicing app. The file goes into a private `invoices` bucket under the user's own folder.

```ts
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)

async function attachInvoicePdf(invoiceId: number, file: File) {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Sign in first')

  const path = `${user.id}/${invoiceId}-${crypto.randomUUID()}.pdf`

  const { data, error } = await supabase.storage
    .from('invoices')
    .upload(path, file, {
      contentType: 'application/pdf',
      cacheControl: '3600', // seconds browsers may cache the file
      upsert: false,        // fail instead of overwriting an existing file
    })

  if (error) throw error
  return data.path // store this in your invoices table
}
```

For this to work you need a bucket called `invoices` and an insert policy that lets users write to their own folder. See [Storage access control](/docs/storage/access-control).

## Upload options

| Option | What it does |
|---|---|
| `contentType` | The MIME type stored with the file and sent back as `Content-Type` when it's served. Also checked against the bucket's allowed types. Set it explicitly when uploading a `Blob` or bytes. |
| `cacheControl` | The `max-age` in seconds sent as `cache-control` when the file is downloaded. Files are served from your instance with no CDN in front, so this is your main caching lever. See [Serving and downloading files](/docs/storage/serving-files). |
| `upsert` | `true` overwrites an existing file at the same path. `false` (the default) returns an error if the path is taken. |

What you pass as the body can be a `File`, `Blob`, `ArrayBuffer`, `FormData`, a typed array, or in Node.js a `Buffer` or stream.

## Overwriting files

Overwriting needs more permissions than a fresh upload. With `upsert: true`, the user needs `insert`, `select` and `update` policies on the object. To replace a file explicitly, use `update()`:

```ts
await supabase.storage
  .from('avatars')
  .update(`${user.id}/avatar.webp`, newFile, { contentType: 'image/webp', upsert: true })
```

Overwriting a file at the same path makes caching harder, because browsers may keep showing the old version until `cacheControl` runs out. For anything public, prefer a new path per version (for example by adding a UUID or timestamp) and save the new path in your table.

## Uploading from a server

Server code can upload with the user's token (so RLS applies) or with the `service_role` key (which bypasses RLS). Use `service_role` only on the server, and only for files your backend creates itself, like generated reports.

```ts
// Node.js: save a generated monthly statement
import { readFile } from 'node:fs/promises'
import { createClient } from '@supabase/supabase-js'

const admin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } }
)

const pdf = await readFile('/tmp/statement-2026-10.pdf')

const { error } = await admin.storage
  .from('statements')
  .upload('team-7/2026-10.pdf', pdf, { contentType: 'application/pdf', upsert: true })
if (error) throw error
```

The same code works in an Edge Function with `Deno.env.get('SUPABASE_URL')` and `Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')`, which SupaHost injects into every function. See [Environment variables and secrets](/docs/functions/secrets).

## Signed upload URLs

Sometimes you want your server to decide where a file goes, but let the browser send the bytes directly. A signed upload URL does that: the server creates a one-time token for one path, and the client uploads to it without needing its own insert policy.

```ts
// On the server (service_role)
const { data, error } = await admin.storage
  .from('invoices')
  .createSignedUploadUrl(`team-7/${invoiceId}.pdf`)
// send data.path and data.token to the browser
```

```ts
// In the browser (anon key)
const { error } = await supabase.storage
  .from('invoices')
  .uploadToSignedUrl(path, token, file, { contentType: 'application/pdf' })
```

The token only works for the path it was created for, and it expires. The 50 MB limit still applies.

## Moving, copying and removing

```ts
const bucket = supabase.storage.from('invoices')

await bucket.move('team-7/draft.pdf', 'team-7/final.pdf')
await bucket.copy('team-7/final.pdf', 'archive/team-7/final.pdf')
await bucket.remove(['team-7/old-1.pdf', 'team-7/old-2.pdf'])
```

`move` needs an `update` policy, `copy` needs `select` on the source and `insert` on the destination, and `remove` needs `delete` (plus `select`). Always remove files through the Storage API. Deleting rows from `storage.objects` with SQL doesn't remove the files from disk, and they would keep using your file storage quota.

## Large files and resumable uploads

On SupaHost a single file can be at most 50 MB. Larger uploads are rejected with a "maximum allowed size" error, whatever the bucket's own limit says. If you need to store bigger files, split them into parts in your app, or compress them before upload.

Standard uploads as shown above are the supported way to upload. Upstream Supabase Storage also has a resumable upload protocol (TUS), which Supabase Cloud recommends for files over 6 MB. SupaHost's documentation doesn't cover it, so build on standard uploads. For files up to 50 MB on a normal connection they work well; if an upload fails, retry it with the same path.

## Common errors

| Error | Usual cause |
|---|---|
| `new row violates row-level security policy` (403) | No insert policy matches the bucket and path. See [Storage 403 on upload](/kb/storage-rls-upload-403) |
| `The resource already exists` (409) | A file is already at that path and `upsert` is `false` |
| `The object exceeded the maximum allowed size` (413) | The file is over 50 MB or over the bucket's limit. See [Upload file size limits](/kb/upload-size-limit) |
| `mime type ... is not supported` | The `contentType` isn't in the bucket's allowed types |

For abandoned or half-finished uploads, see [Cleaning up failed or partial uploads](/kb/storage-partial-uploads).

## Related

- [Buckets](/docs/storage/buckets)
- [Images without transformations](/docs/storage/images)
- [supabase-js `upload` reference](https://supabase.com/docs/reference/javascript/storage-from-upload)
