All answers

Supabase

How do I upload an image to Supabase Storage from an Expo app?

Pick the photo with expo-image-picker, read the file into an ArrayBuffer, and pass it to supabase.storage.from(bucket).upload() with the image's contentType. Then add a storage policy that allows inserts, because uploads are blocked until one exists.

Updated October 31, 2026

Uploading a photo from a phone touches three separate pieces (the picker, the file’s bytes, and Supabase’s access rules), so when it fails it’s hard to tell which one broke. Each piece is small once you know what it expects, though, and the fixes below are listed by the error you’ll see.

How it works

When the user picks a photo, what you get back from the image picker is a local URI that points to the file on the phone, not the file itself. So your app reads the bytes from that URI into an ArrayBuffer and uploads those, with a content type so the file is stored as an image. That’s the same approach Supabase’s own Expo example uses.

On the Supabase side, Storage is protected by row level security on the storage.objects table. Uploads are blocked until a policy allows them, and that’s true even for a public bucket, because “public” only means anyone can download.

How to do it

  1. Create a bucket. In your Supabase project, open Storage, click New Bucket, name it avatars, and click Create Bucket. Make it public if anyone should be able to view the images by URL, or leave it private and show them with signed URLs.

  2. Allow uploads with a policy. Run this in the SQL editor. It lets each signed-in user upload only into a folder named after their own user ID:

    create policy "Users upload to their own folder"
    on storage.objects for insert to authenticated
    with check (
      bucket_id = 'avatars' and
      (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
    );
  3. Pick the image. You don’t need to ask for permission to open the photo library:

    import * as ImagePicker from "expo-image-picker";
    
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ["images"],
      quality: 0.8,
    });
    if (result.canceled) return;
    const image = result.assets[0];
  4. Upload the bytes into the user’s folder, so the path matches the policy:

    const {
      data: { user },
    } = await supabase.auth.getUser();
    if (!user) throw new Error("Sign in before uploading");
    
    const bytes = await fetch(image.uri).then((res) => res.arrayBuffer());
    const ext = image.uri.split(".").pop()?.toLowerCase() ?? "jpg";
    const path = `${user.id}/${Date.now()}.${ext}`;
    
    const { data, error } = await supabase.storage
      .from("avatars")
      .upload(path, bytes, { contentType: image.mimeType ?? "image/jpeg" });
    if (error) throw error;
  5. Save the path and show the image. Store data.path on the user’s row. For a public bucket, supabase.storage.from("avatars").getPublicUrl(data.path).data.publicUrl gives you a URL you can pass to an Image.

When an Edge Function is worth it

We’d start without one. Uploading straight from the app is simpler, and the policy above keeps each user in their own folder. A function earns its place when the upload needs something the app can’t safely hold, such as a secret key (which bypasses row level security, so it never belongs in a shipped app), or a check that has to run on a server before the file is accepted. In that case the function can do its check and return a signed upload URL from createSignedUploadUrl, and the app uploads the file itself with uploadToSignedUrl, so the image never has to pass through the function.

How you know it works

On a real phone, you can pick a photo and the upload finishes without an error. The file shows up in the bucket in the Supabase dashboard, inside a folder named after your user ID, with an image type and a sensible size, and the public URL opens the image in a browser.

If it still fails

A 403 with “new row violates row-level security policy” means no policy allowed the insert. Check that the user is signed in, that the bucket name in the policy matches, and that the first folder in the path is the user’s ID.

A 400 Asset Already Exists means a file is already at that path. Use a new file name, or pass upsert: true, which also needs policies that allow select and update. If the file uploads but won’t open as an image, check that you passed the ArrayBuffer and a contentType, not the URI or the asset object. Large files can also hit your size limits, which you’ll find in your Storage settings and on each bucket (Free projects top out at 50 MB per file).

Ask the agent

If your app is in Draftbit, paste this into the agent, and it can read your code and make the change for you.

Let users pick a photo from their library and upload it to a Supabase Storage bucket called avatars, in a folder named after their user ID. Add the storage policy that allows it, save the file path on their profile, and show the uploaded image.

Open Draftbit

More Supabase answers