How SpaceZero implements user avatar uploads with Supabase Storage: public bucket with RLS, 5MB validation, and CDN delivery.
SpaceZero includes complete avatar upload functionality.
The migration creates a public bucket (no dashboard steps needed):
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
avatars-- 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]
);
Uploads go straight to Storage — the original file is sent as-is (no client-side resize), after basic validation:
// 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.
publicUrl serves via Supabase CDN:
https://xxx.supabase.co/storage/v1/object/public/avatars/user-id/timestamp.ext
{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.