Codea Bien Logo
Storage: Upload, transformación y delivery de archivos
Supabase

Storage: Upload, transformación y delivery de archivos

Kevin Dávila

supabase storagefile uploadimage transformationcloud storagesupabase files

Pregunta rápida: ¿cuánto tiempo de tu vida has perdido peleando con buckets, CORS, URLs firmadas y workers de imágenes? Si al leerlo te volvió un mal recuerdo, sigue leyendo.

Supabase Storage se encarga de todo eso: uploads directos desde el cliente, transformaciones de imágenes al vuelo y todo protegido con RLS. Aquí te muestro cómo implementarlo.

Qué es Supabase Storage

Supabase Storage es un servicio de almacenamiento de archivos construido sobre S3. Ofrece:

  • Upload/download de archivos

  • Transformaciones de imágenes (resize, crop, format)

  • CDN integrado

  • Políticas de seguridad con RLS

  • Organización en buckets

Crear un bucket

Desde el dashboard:

  1. Ve a Storage

  2. Click en New Bucket

  3. Elige nombre y si es público o privado

Desde código:

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

// Crear un bucket
const { data, error } = await supabase.storage.createBucket('avatars', {
  public: false, // Privado, requiere auth para acceder
  fileSizeLimit: 5 * 1024 * 1024, // 5MB
  allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp']
})

Upload de archivos

Upload simple

// 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 // Sobreescribir si existe
    })

  if (error) throw error
  return data
}

Upload con input HTML

// 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)
})

Descargar archivos

URL pública (buckets públicos)

// 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

URL firmada (buckets privados)

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

console.log(data.signedUrl)

Descargar archivo

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

if (data) {
  const url = URL.createObjectURL(data)
  // Usar url para mostrar la imagen
}

Transformaciones de imágenes

Supabase permite transformar imágenes al vuelo al solicitarlas:

// 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
    }
  })

Ejemplo práctico: 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
}

Listar archivos

// 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)
})

Eliminar archivos

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

Mover y copiar

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

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

Políticas de Storage

Al igual que las tablas, puedes usar RLS en Storage:

-- Los usuarios solo pueden subir a su propia carpeta
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]
);

-- Los usuarios solo pueden ver su propio avatar (si el bucket es privado)
CREATE POLICY "Users can view own avatars"
ON storage.objects
FOR SELECT
USING (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- Cualquiera puede ver avatares (si el bucket es público)
CREATE POLICY "Anyone can view avatars"
ON storage.objects
FOR SELECT
USING (bucket_id = 'avatars');

Ejemplo completo: Galería de imágenes

// 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
  }
}

Configuración avanzada

Tamaño máximo de archivo

En el dashboard o al crear el bucket:

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

Tipos de archivo permitidos

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

CDN y caché

Los archivos se sirven a través de CDN automáticamente. El parámetro cacheControl controla el caché:

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

Errores comunes

"Bucket not found"

  • Verifica que el bucket existe

  • Revisa el nombre (case-sensitive)

"File size limit exceeded"

  • El archivo excede el límite del bucket

  • Aumenta fileSizeLimit o comprime el archivo

"Permission denied"

  • Las políticas de storage no permiten la operación

  • Revisa las políticas en Storage > Policies

"Invalid mime type"

  • El tipo de archivo no está en allowedMimeTypes

  • Agrega el tipo o usa */* (no recomendado)

Qué sigue

En el próximo artículo vamos con Realtime: cómo hacer que tu app se actualice en tiempo real sin polling. Vamos a ver suscripciones, broadcast, y presence.