Supabase Storage Upload: What You'll Build

Supabase Storage lets you store files (avatars, documents, images, videos) next to your Postgres database, with the same auth and permissions. Uploading takes a few lines of JavaScript, but permissions trip up almost everyone the first time.

This guide covers the full path: create a bucket, set the right policies, upload from a Next.js or React app, get a URL to display the file, and fix the common errors.


๐ŸŽฏ Quick Answer (30-Second Read)

  • Create a bucket in the dashboard (Storage โ†’ New bucket): public for open assets, private for user files.
  • Add RLS policies on storage.objects, or uploads fail with "new row violates row-level security policy".
  • Upload: supabase.storage.from('bucket').upload(path, file).
  • Display: getPublicUrl() for public buckets, createSignedUrl() (time-limited) for private ones.
  • Organise by user: store files under userId/filename so policies can restrict each user to their own folder.

How Supabase Storage Works

Storage has two parts: the files live in S3-compatible object storage, and each file's metadata (bucket, path, owner) is a row in your Postgres database, in storage.objects. That's why Postgres RLS policies control who can upload and read.

sequenceDiagram participant App as Your app (browser) participant API as Supabase Storage API participant DB as Postgres (storage.objects + RLS) participant S3 as Object storage App->>API: upload avatars/USER_ID/pic.png + user JWT API->>DB: Insert metadata row as that user alt RLS policy allows DB-->>API: OK API->>S3: Store file bytes API-->>App: Returns the file path else No matching policy DB-->>API: violates row-level security API-->>App: 403 error end

Step 1: Create a Bucket

In the Supabase dashboard: Storage โ†’ New bucket.

Public bucket
Anyone with the URL can view files. Good for blog images, logos and public avatars. Uploads still need a policy.
Private bucket
Files need permission to read. Good for invoices, documents and anything personal. Share via signed URLs.

You can also set limits such as max file size and allowed MIME types (for example image/png, image/jpeg) on the bucket.


Step 2: Add Storage Policies

Storage keeps file records in the storage.objects table, which has Row Level Security enabled. Without a policy, uploads are rejected.

Let each logged-in user upload, read and replace files only in their own folder (avatars/<user-id>/...):

create policy "Users upload to own folder"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

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

create policy "Users update own files"
on storage.objects for update to authenticated
using (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

storage.foldername(name) splits the path into folders, so [1] is the first folder: the user's ID.


Step 3: Upload a File From Your App

"use client";
import { createClient } from "@supabase/supabase-js";

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

export function AvatarUpload() {
  async function onChange(e: React.ChangeEvent) {
    const file = e.target.files?.[0];
    if (!file) return;

    const { data: { user } } = await supabase.auth.getUser();
    if (!user) return alert("Please log in first");

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

    if (error) return alert(error.message);
    console.log("Uploaded to:", data.path);
  }

  return ;
}
Pro tip: add a timestamp or random ID to the file name. Uploading to an existing path fails unless you set upsert: true, which also needs an UPDATE policy.

Step 4: Get a URL to Display the File

Public bucket: a permanent URL.

const { data } = supabase.storage.from("avatars").getPublicUrl(path);
console.log(data.publicUrl);

Private bucket: a signed URL that expires.

const { data, error } = await supabase.storage
  .from("documents")
  .createSignedUrl(path, 60 * 60); // valid for 1 hour

Save the path (not the URL) in your database, then generate URLs when you need them. That way you can switch between public and signed URLs later.


Step 5: Replace and Delete Files

// Replace (needs an UPDATE policy)
await supabase.storage.from("avatars").upload(path, newFile, { upsert: true });

// Delete (needs a DELETE policy)
await supabase.storage.from("avatars").remove([path]);

Common Errors and Fixes

Error Cause Fix
new row violates row-level security policy No INSERT policy on storage.objects (or user not logged in) Add the policy in Step 2. See the full fix guide
The resource already exists Path already used Use a unique file name or upsert: true (+ UPDATE policy)
File too large / invalid MIME type Bucket limits Raise the limit in bucket settings or validate before upload
Image URL returns 400/404 Wrong path, or private bucket used with getPublicUrl Check the stored path; use signed URLs for private buckets

Uploading Large Files

For large files (like videos), Supabase supports resumable uploads using the TUS protocol, so an interrupted upload can continue instead of restarting. Use it for anything big or for users on unreliable connections.


Frequently Asked Questions

Do public buckets still need policies to upload?

Yes. "Public" only means anyone can read files with the URL. Uploading, updating and deleting are still controlled by policies on storage.objects, so add an INSERT policy for the users who should be able to upload.

Should I upload from the browser or the server?

Browser uploads with the user's session are simplest and scale well, because files go straight to Supabase and policies protect each user's folder. Upload from the server when you need to process files first or use privileged access, and keep the service-role key on the server only.

How do I show a private image in my app?

Store the file path in your database, then call createSignedUrl(path, seconds) when rendering. The signed URL works for the time you set, after which it expires, so private files can't be shared permanently by copying a link.


Conclusion

Uploading to Supabase Storage takes three things: a bucket, policies on storage.objects, and one upload() call. Organise files by user ID, store paths in your database, use public URLs for open assets and signed URLs for private files, and most errors disappear.


Sources

Last verified: 14 October 2026.


Related reads: Fix Supabase RLS Policy Error ยท How to Create a SaaS With Next.js and Supabase ยท How to Use Environment Variables in Next.js

I write about building products with Next.js, Supabase and AI. Follow on LinkedIn or X, or invite me to speak.