Storage
9 Nov 2026
9 min read

User Avatars with Supabase Storage: Upload, Storage Policies, and CDN Delivery

How SpaceZero implements user avatar uploads with Supabase Storage: public bucket with RLS, 5MB validation, and CDN delivery.

User Avatars with Supabase Storage

SpaceZero includes complete avatar upload functionality.

Storage Setup

1. Bucket

The migration creates a public bucket (no dashboard steps needed):

sql

insert into storage.buckets (id, name, public)

values ('avatars', 'avatars', true)

on conflict (id) do nothing;

  • Name: avatars
  • Public: Yes (for CDN delivery)
  • Upload limit: enforced client-side at 5MB

2. RLS Policies

sql

-- Allow authenticated users to upload to their folder

CREATE POLICY "Users can upload own avatar"

ON storage.objects FOR INSERT

WITH CHECK (

bucket_id = 'avatars' AND

auth.uid()::text = (storage.foldername(name))[1]

);

 

-- Allow public read

CREATE POLICY "Public read access"

ON storage.objects FOR SELECT

USING (bucket_id = 'avatars');

 

-- Allow users to update own

CREATE POLICY "Users can update own avatar"

ON storage.objects FOR UPDATE

USING (

bucket_id = 'avatars' AND

auth.uid()::text = (storage.foldername(name))[1]

);

 

-- Allow users to delete own

CREATE POLICY "Users can delete own avatar"

ON storage.objects FOR DELETE

USING (

bucket_id = 'avatars' AND

auth.uid()::text = (storage.foldername(name))[1]

);

Upload Handler

Uploads go straight to Storage — the original file is sent as-is (no client-side resize), after basic validation:

typescript

// In Profile.tsx

const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {

const file = e.target.files?.[0];

if (!file) return;

 

if (!file.type.startsWith('image/')) {

setError('Please select an image file.');

return;

}

 

if (file.size > 5 * 1024 * 1024) {

setError('Image must be smaller than 5MB.');

return;

}

 

try {

setAvatarUploading(true);

 

const fileId = `${user.id}/${Date.now()}.${file.name.split('.').pop()}`;

const { data, error: uploadError } = await supabase.storage

.from('avatars')

.upload(fileId, file, { upsert: true });

 

if (uploadError) throw uploadError;

 

const { data: { publicUrl } } = supabase.storage

.from('avatars')

.getPublicUrl(data.path);

 

setAvatarUrl(publicUrl);

setMessage('Avatar uploaded! Click "Save Changes" to make it permanent.');

} catch (err) {

setError(err instanceof Error ? err.message : 'Failed to upload avatar.');

} finally {

setAvatarUploading(false);

}

};

In preview mode (no session), the file is read locally with FileReader instead of uploaded.

CDN Delivery

publicUrl serves via Supabase CDN:

code

https://xxx.supabase.co/storage/v1/object/public/avatars/user-id/timestamp.ext

  • Global CDN
  • Cache headers
  • No additional config

Display

tsx

{displayAvatar ? (

<img

src={displayAvatar}

alt="Avatar preview"

className="w-7xl h-7xl object-cover border border-border"

style={{ borderRadius: avatarShapeRadius }}

onError={(e) => {

(e.target as HTMLImageElement).style.display = 'none';

}}

/>

) : (

<div

className="w-7xl h-7xl bg-muted flex items-center justify-center border border-border text-regular"

style={{ borderRadius: avatarShapeRadius }}

>

<span className="text-5xl font-semibold">{avatarLetter}</span>

</div>

)}

avatarShapeRadius maps the user's shape preference (circle/soft/sharp) to 50% / 0.5rem / 0. When no avatar is set, SpaceZero falls back to the OAuth provider's avatar_url, then to a letter avatar.

Benefits

  • Cost: No server processing or storage of derived sizes
  • UX: Instant preview after upload
  • Reliability: CDN delivery with cache headers
  • Security: RLS ensures users only access own files