Storage: Upload, transformación y delivery de archivos
Kevin Dávila
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:
Ve a Storage
Click en New Bucket
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.jpgURL 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.