Codea Bien Logo
Storage: Upload, Transform and Deliver Files
Supabase

Storage: Upload, Transform and Deliver Files

Kevin Dávila

Kevin Dávila

supabase storagefile uploadimage transformationcloud storagesupabase files

Quick question: how much of your life have you lost fighting buckets, CORS, signed URLs, and image workers? If just reading that brought back a bad memory, keep reading.

Supabase Storage takes care of all of it: direct uploads from the client, image transformations on the fly, and everything protected with RLS. Here's how to set it up.

What is Supabase Storage

Supabase Storage is a file storage service built on S3. It offers:

  • File upload/download

  • Image transformations (resize, crop, format)

  • Integrated CDN

  • Security policies with RLS

  • Bucket organization

Create a bucket

From the dashboard:

  1. Go to Storage

  2. Click New Bucket

  3. Choose name and whether it's public or private

From code:

// src/storage/setup.ts
import { supabase } from '../lib/supabase'

// Create a bucket
const { data, error } = await supabase.storage.createBucket('avatars', {
  public: false, // Private, requires auth to access
  fileSizeLimit: 5 * 1024 * 1024, // 5MB
  allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp']
})

Upload files

Simple upload

// src/storage/upload.ts
import { supabase } from '../lib/supabase'

export async function uploadAvatar(userId: string, file: File) {
  const filePath = `${userId}/avatar.${file.name.split('.').pop()}`
  
  const { data, error } = await supabase.storage
    .from('avatars')
    .upload(filePath, file, {
      cacheControl: '3600',
      upsert: true // Overwrite if exists
    })

  if (error) throw error
  return data
}

Upload with HTML input

// src/components/UploadAvatar.ts
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'image/*'

fileInput.addEventListener('change', async (e) => {
  const file = (e.target as HTMLInputElement).files?.[0]
  if (!file) return

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return

  const result = await uploadAvatar(user.id, file)
  console.log('Uploaded:', result)
})

Download files

Public URL (public buckets)

// src/storage/public-url.ts
const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl('user-123/avatar.jpg')

console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/avatars/user-123/avatar.jpg

Signed URL (private buckets)

// src/storage/signed-url.ts
const { data, error } = await supabase.storage
  .from('avatars')
  .createSignedUrl('user-123/avatar.jpg', 3600) // Expires in 1 hour

console.log(data.signedUrl)

Download file

// src/storage/download.ts
const { data, error } = await supabase.storage
  .from('avatars')
  .download('user-123/avatar.jpg')

if (data) {
  const url = URL.createObjectURL(data)
  // Use url to display image
}

Image transformations

Supabase allows transforming images on the fly when requesting them:

// src/storage/transform.ts
const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl('user-123/avatar.jpg', {
    transform: {
      width: 200,
      height: 200,
      resize: 'cover', // 'cover', 'contain', 'fill'
      format: 'webp', // 'origin', 'webp'
      quality: 80
    }
  })

Practical example: Thumbnails

// src/storage/thumbnails.ts
export function getAvatarUrl(userId: string, size: 'sm' | 'md' | 'lg' = 'md') {
  const sizes = {
    sm: { width: 64, height: 64 },
    md: { width: 200, height: 200 },
    lg: { width: 400, height: 400 }
  }

  const { data } = supabase.storage
    .from('avatars')
    .getPublicUrl(`${userId}/avatar.jpg`, {
      transform: {
        ...sizes[size],
        resize: 'cover',
        format: 'webp'
      }
    })

  return data.publicUrl
}

List files

// src/storage/list.ts
const { data, error } = await supabase.storage
  .from('avatars')
  .list('user-123', {
    limit: 10,
    offset: 0,
    sortBy: { column: 'created_at', order: 'desc' }
  })

data?.forEach(file => {
  console.log(file.name, file.metadata.size)
})

Delete files

// src/storage/delete.ts
const { data, error } = await supabase.storage
  .from('avatars')
  .remove(['user-123/avatar.jpg'])

Move and copy

// src/storage/move.ts
// Move file
await supabase.storage
  .from('avatars')
  .move('old-path/avatar.jpg', 'new-path/avatar.jpg')

// Copy file
await supabase.storage
  .from('avatars')
  .copy('original/avatar.jpg', 'backup/avatar.jpg')

Storage policies

Just like tables, you can use RLS on Storage:

-- Users can only upload to their own folder
CREATE POLICY "Users can upload own avatars"
ON storage.objects
FOR INSERT
WITH CHECK (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- Users can only view their own avatar (if bucket is private)
CREATE POLICY "Users can view own avatars"
ON storage.objects
FOR SELECT
USING (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- Anyone can view avatars (if bucket is public)
CREATE POLICY "Anyone can view avatars"
ON storage.objects
FOR SELECT
USING (bucket_id = 'avatars');

Complete example: Image gallery

// src/gallery.ts
import { supabase } from './lib/supabase'

export class ImageGallery {
  private bucket = 'gallery'

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

    const filePath = `${user.id}/${album}/${Date.now()}-${file.name}`
    
    const { data, error } = await supabase.storage
      .from(this.bucket)
      .upload(filePath, file)

    if (error) throw error
    return data
  }

  async getImages(album: string) {
    const { data: { user } } = await supabase.auth.getUser()
    if (!user) throw new Error('Not authenticated')

    const { data, error } = await supabase.storage
      .from(this.bucket)
      .list(`${user.id}/${album}`)

    if (error) throw error
    
    return data.map(file => ({
      name: file.name,
      url: this.getUrl(`${user.id}/${album}/${file.name}`),
      thumbnail: this.getUrl(`${user.id}/${album}/${file.name}`, 200)
    }))
  }

  private getUrl(path: string, size?: number) {
    const { data } = supabase.storage
      .from(this.bucket)
      .getPublicUrl(path, {
        transform: size ? { width: size, height: size, resize: 'cover' } : undefined
      })
    return data.publicUrl
  }
}

Advanced configuration

Maximum file size

In the dashboard or when creating the bucket:

await supabase.storage.createBucket('uploads', {
  fileSizeLimit: 10 * 1024 * 1024 // 10MB
})

Allowed file types

await supabase.storage.createBucket('documents', {
  allowedMimeTypes: [
    'application/pdf',
    'application/msword',
    'image/*'
  ]
})

CDN and caching

Files are served through CDN automatically. The cacheControl parameter controls caching:

await supabase.storage
  .from('avatars')
  .upload(path, file, {
    cacheControl: '86400' // 24 hours
  })

Common errors

"Bucket not found"

  • Verify the bucket exists

  • Check the name (case-sensitive)

"File size limit exceeded"

  • File exceeds bucket limit

  • Increase fileSizeLimit or compress the file

"Permission denied"

  • Storage policies don't allow the operation

  • Check policies in Storage > Policies

"Invalid mime type"

  • File type not in allowedMimeTypes

  • Add the type or use */* (not recommended)

What's next

In the next article we cover Realtime: how to make your app update in real-time without polling. We'll see subscriptions, broadcast, and presence.