# How to Upload Files to Supabase Storage

- Tool: Supabase
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

To upload files to Supabase Storage, create a storage bucket in the Dashboard or via the client, then use supabase.storage.from('bucket').upload() to send files. Set buckets as public or private, configure RLS policies on storage.objects to control who can upload, and use getPublicUrl() or createSignedUrl() to retrieve file URLs. Always validate file type and size before uploading.

## Uploading Files to Supabase Storage with Access Control

Supabase Storage is an S3-compatible object storage system integrated with Supabase Auth and Row Level Security. This tutorial covers creating buckets, uploading files from the client, securing uploads with RLS policies, and generating URLs to access your files. You will build a complete file upload flow that works with authenticated users and respects access control rules.

## Before you start

- A Supabase project (free tier or above)
- The @supabase/supabase-js library installed in your project
- Basic knowledge of JavaScript/TypeScript and file handling
- Supabase Auth configured with at least email/password login

## Step-by-step guide

### 1. Create a storage bucket in the Dashboard

Open your Supabase Dashboard and click Storage in the left sidebar. Click New Bucket and enter a name like 'uploads'. Choose whether the bucket should be public (anyone with the URL can read files) or private (requires authentication). For most applications, start with a private bucket and use signed URLs for controlled access. You can also set allowed MIME types and a file size limit at creation time.

**Expected result:** A new storage bucket appears in the Dashboard Storage section.

### 2. Set up RLS policies for upload access

Once you create a bucket, you need RLS policies on the storage.objects table to control who can upload files. Without policies, all uploads will be denied. The most common pattern is to let authenticated users upload to a folder named after their user ID. This keeps files organized and prevents users from overwriting each other's files.

```
-- Allow authenticated users to upload files to their own folder
create policy "Users can upload to own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'uploads'
  and (storage.foldername(name))[1] = auth.uid()::text
);

-- Allow authenticated users to read their own files
create policy "Users can read own files"
on storage.objects for select
to authenticated
using (
  bucket_id = 'uploads'
  and (storage.foldername(name))[1] = auth.uid()::text
);

-- Allow authenticated users to delete their own files
create policy "Users can delete own files"
on storage.objects for delete
to authenticated
using (
  bucket_id = 'uploads'
  and (storage.foldername(name))[1] = auth.uid()::text
);
```

**Expected result:** Authenticated users can upload, read, and delete files only within their own user-ID folder.

### 3. Upload a file from the client

Use the Supabase client to upload files. The upload() method takes the file path within the bucket and the File object. The path should include the user's ID as the first folder segment to match the RLS policy. Set cacheControl for browser caching and upsert to control whether existing files should be overwritten.

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

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

async function uploadFile(file: File) {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const filePath = `${user.id}/${Date.now()}-${file.name}`

  const { data, error } = await supabase.storage
    .from('uploads')
    .upload(filePath, file, {
      cacheControl: '3600',
      upsert: false,
    })

  if (error) throw error
  return data
}
```

**Expected result:** The file is uploaded to the uploads bucket under the user's folder and the upload returns the file path.

### 4. Retrieve file URLs for display

After uploading, you need a URL to display or share the file. For public buckets, use getPublicUrl() which returns a permanent URL. For private buckets, use createSignedUrl() which generates a temporary URL that expires after a specified number of seconds. Signed URLs are more secure because they automatically expire.

```
// For public buckets — permanent URL
const { data } = supabase.storage
  .from('public-bucket')
  .getPublicUrl('user-id/photo.jpg')
console.log(data.publicUrl)

// For private buckets — temporary signed URL (1 hour)
const { data: signedData, error } = await supabase.storage
  .from('uploads')
  .createSignedUrl('user-id/document.pdf', 3600)
console.log(signedData?.signedUrl)
```

**Expected result:** You get a usable URL that can be embedded in HTML img tags or shared as download links.

### 5. Handle upload errors and validate input

Always validate files before uploading: check the file size against your bucket limit, verify the MIME type is allowed, and handle errors from the upload response. Common errors include 413 (file too large), 403 (RLS policy denied), and 409 (file already exists when upsert is false). Provide clear error messages to help users fix the issue.

```
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'application/pdf']
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB

async function safeUpload(file: File) {
  if (!ALLOWED_TYPES.includes(file.type)) {
    return { error: `File type ${file.type} is not allowed.` }
  }
  if (file.size > MAX_SIZE) {
    return { error: `File is too large. Maximum size is 10 MB.` }
  }

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return { error: 'Please sign in to upload files.' }

  const { data, error } = await supabase.storage
    .from('uploads')
    .upload(`${user.id}/${Date.now()}-${file.name}`, file, {
      cacheControl: '3600',
      upsert: false,
    })

  if (error) return { error: error.message }
  return { data }
}
```

**Expected result:** Invalid files are rejected before upload, and server errors are caught and reported clearly.

## Complete code example

File: `file-upload.ts`

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

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'application/pdf']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const BUCKET = 'uploads'

interface UploadResult {
  success: boolean
  path?: string
  url?: string
  error?: string
}

export async function uploadFile(file: File): Promise<UploadResult> {
  // Validate file type
  if (!ALLOWED_TYPES.includes(file.type)) {
    return { success: false, error: `File type ${file.type} is not allowed.` }
  }

  // Validate file size
  if (file.size > MAX_FILE_SIZE) {
    const sizeMB = (file.size / (1024 * 1024)).toFixed(1)
    return { success: false, error: `File is ${sizeMB} MB. Max is 10 MB.` }
  }

  // Verify authentication
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) {
    return { success: false, error: 'You must be signed in to upload files.' }
  }

  // Upload to user-scoped folder
  const filePath = `${user.id}/${Date.now()}-${file.name}`
  const { data, error } = await supabase.storage
    .from(BUCKET)
    .upload(filePath, file, {
      cacheControl: '3600',
      upsert: false,
    })

  if (error) {
    return { success: false, error: error.message }
  }

  // Generate a signed URL (1 hour expiry)
  const { data: urlData } = await supabase.storage
    .from(BUCKET)
    .createSignedUrl(data.path, 3600)

  return {
    success: true,
    path: data.path,
    url: urlData?.signedUrl,
  }
}
```

## Common mistakes

- **Uploading files without RLS policies, resulting in silent 403 denials** — undefined Fix: Always create INSERT and SELECT policies on storage.objects for your bucket. Without policies, RLS blocks all operations and returns empty results or 403 errors.
- **Using the same file path for every upload, overwriting previous files** — undefined Fix: Prepend a timestamp or UUID to each filename to ensure uniqueness: `${Date.now()}-${file.name}`. Set upsert: false to get an error if a path collision occurs.
- **Exposing the service role key in client-side code for storage operations** — undefined Fix: Always use the anon key on the client side. The anon key respects RLS policies, which is the correct behavior. The service role key bypasses all security and should only be used server-side.
- **Not validating file type and size before upload, wasting bandwidth on rejected files** — undefined Fix: Check file.type against an allowed list and file.size against your bucket limit before calling storage.upload().

## Best practices

- Always create RLS policies on storage.objects before uploading files — without them, all operations are denied
- Use user-scoped folders (userId/filename) to keep files organized and simplify RLS policies
- Validate file type and size on the client before uploading to save bandwidth and provide instant feedback
- Use signed URLs for private files instead of making entire buckets public
- Set cacheControl on uploads to control browser caching behavior for static assets
- Prepend timestamps or UUIDs to filenames to prevent naming conflicts
- Never expose the SUPABASE_SERVICE_ROLE_KEY in client-side code — use the anon key which respects RLS

## Frequently asked questions

### What is the maximum file size I can upload to Supabase Storage?

The default limit is 50 MB on the free plan. Pro and Team plans support up to 5 GB per file. You can set a lower custom limit per bucket in the Dashboard.

### Do I need RLS policies for public buckets?

Public buckets allow anyone to read files without authentication. However, you still need INSERT policies to control who can upload, and DELETE policies to control who can remove files.

### Can I upload files without authenticating the user?

Yes, if you create an RLS policy that allows the anon role to insert into storage.objects. However, this is not recommended for production as it allows anyone to upload files to your bucket.

### How do I upload multiple files at once?

The Supabase client uploads one file at a time. To upload multiple files, use Promise.all() to run multiple upload() calls in parallel. Each file needs its own unique path.

### Why does my upload return a 403 error?

A 403 error means your RLS policy is blocking the upload. Check that you have an INSERT policy on storage.objects for your bucket and that the authenticated user's ID matches the folder path in the policy.

### Can I overwrite an existing file?

Set the upsert option to true in the upload() call to overwrite a file at the same path. When upsert is false (the default), uploading to an existing path returns a 409 conflict error.

### Can RapidDev help set up file storage with proper access controls?

Yes, RapidDev can configure your Supabase storage buckets, write RLS policies, build upload components with validation, and set up CDN caching for optimal file delivery.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-upload-files-to-supabase-storage
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-upload-files-to-supabase-storage
