Supabase Storage en la práctica: subida, permisos y aceleración CDN

Mirando el error en la consola. La subida de avatares llevaba media hora en producción y ya llegaban reportes: todos los avatares mostraban la misma persona.
Causa: RLS Policy sin configurar. Bucket público, rutas sin aislamiento por usuario — cualquiera podía sobrescribir archivos ajenos. Un descuido de permisos casi provoca un incidente grave.
Supabase Storage parece simple, pero usarlo bien — permisos, CDN, transformación — tiene trampas. Este artículo recoge lo que aprendí.
1. Primeros pasos: subida estándar
Crear Bucket
Panel Supabase → Storage → New bucket. Nombre avatars, posts, etc. “Make this bucket public?” — no marques aún; lo veremos en permisos.
Mi regla: private para datos sensibles, public solo para estáticos realmente abiertos.
Código SDK
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
)
async function uploadFile(file: File) {
const filePath = `uploads/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
if (error) {
console.error('Error de subida:', error.message)
return null
}
return data.path
}
Clave: diseño de filePath y aislamiento por usuario (más abajo).
Límites de tamaño
Documentación: hasta 5 GB en subida estándar. En la práctica, < 6 MB va bien con subida estándar; > 6 MB conviene TUS (reanudación).
const { data, error } = await supabase.storage
.from('videos')
.upload('large-video.mp4', file, {
duplex: 'half',
})
2. Seguridad: RLS Policy
El incidente de las 3 AM: sin permisos, archivos sobrescribibles.
Storage usa PostgreSQL y RLS como la base de datos. Bucket ≈ tabla; archivo ≈ fila.
Public vs Private
Public: lectura anónima. Logos, recursos estáticos.
Private: requiere auth; RLS define quién lee/escribe. Recomendación: private por defecto.
Tipos de Policy
- SELECT: leer/descargar
- INSERT: subir
- UPDATE: sobrescribir
- DELETE: borrar
CREATE POLICY "Users manage own files"
ON storage.objects FOR ALL
USING (auth.uid()::text = (storage.foldername(name))[1]);
auth.uid() = ID del usuario; storage.foldername(name)[1] = primer segmento de ruta (user123/avatar.jpg → user123).
Aislamiento por usuario
async function uploadAvatar(userId: string, file: File) {
const filePath = `${userId}/avatar-${Date.now()}.jpg`
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, file)
return data?.path
}
URL firmada
const { data, error } = await supabase.storage
.from('avatars')
.createSignedUrl('user123/avatar.jpg', 3600)
console.log(data?.signedUrl)
1-4 horas suele ser un buen equilibrio.
const { data } = supabase.storage
.from('public-assets')
.getPublicUrl('logo.png')
Errores frecuentes
- Falta INSERT Policy → “new row violates row-level security policy”
USING (true)demasiado amplio- User ID no en primer nivel →
foldernameextrae mal (p. ej.uploads/user123/file.jpg)
Prueba policies en SQL Editor antes de producción.
3. Rendimiento: Smart CDN y transformación
Smart CDN
Cachea según frecuencia de acceso. Invalidación global en hasta 60 s. Requiere Pro Plan ($25/mes). Free Plan: sin CDN, lectura directa.
Parámetros de imagen
?width=300&height=200
?resize=contain
?resize=cover
?quality=80
?format=webp
const baseUrl = supabase.storage
.from('avatars')
.getPublicUrl('user123/avatar.jpg').data.publicUrl
const thumbnailUrl = `${baseUrl}?width=100&height=100&resize=cover`
Límites: 1-2500 px; original máx. 25 MB; JPEG, PNG, WebP, GIF, AVIF.
Facturación transformación
100 transformaciones/mes gratis; luego $5/1000. Blogs pequeños suelen quedarse en la cuota free.
Next.js Image Loader
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './supabase-image-loader.js',
}
}
export default function supabaseLoader({ src, width, quality }) {
const params = new URLSearchParams()
params.set('width', width.toString())
params.set('quality', (quality || 75).toString())
params.set('format', 'webp')
return `${src}?${params.toString()}`
}
Así, en Next.js con <Image src="..." width={300} />, los parámetros de transformación se añaden automáticamente.
Umbral del Pro Plan
Smart CDN y transformación de imágenes requieren Pro Plan. En Free Plan solo tienes subida y descarga básicas.
¿Conviene subir de plan? Depende del proyecto. Para unos pocos avatares, Free Plan basta. Con muchas imágenes y optimización de rendimiento, el CDN y la transformación del Pro Plan ahorran montar tu propio CDN y servicio de imágenes.
Mi enfoque: probar en Free Plan antes del lanzamiento y pasar a Pro cuando el tráfico se estabilice. $25/mes no es poco.
4. Caso práctico: configuración completa de un blog
Después de tanta teoría, mejor un caso completo. Esta es la configuración de Storage de mi blog, de cero a producción.
Escenario: avatar de usuario + imágenes de artículos
Necesitas dos buckets:
avatars: avatar de usuario, bucket privado; cada usuario solo gestiona el suyopost-images: imágenes de artículos, bucket privado; el autor sube y cualquiera puede leer (con signed URL)
Paso 1: crear buckets
En la consola:
- Storage > New bucket > nombre
avatars, marca Private - Igual para
post-images
Paso 2: configurar RLS Policy
Policy del bucket avatars:
CREATE POLICY "Anyone can view avatars"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
CREATE POLICY "Users manage own avatar"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);
CREATE POLICY "Users delete own avatar"
ON storage.objects FOR DELETE
USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);
Policy del bucket post-images:
CREATE POLICY "Authors can upload post images"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'post-images'
AND auth.jwt() ->> 'role' = 'author'
);
CREATE POLICY "Public read post images"
ON storage.objects FOR SELECT
USING (bucket_id = 'post-images');
Paso 3: código de subida en el frontend
Componente de subida de avatar:
async function handleAvatarUpload(file: File) {
const user = await supabase.auth.getUser()
if (!user.data.user) return alert('Inicia sesión primero')
const filePath = `${user.data.user.id}/avatar.jpg`
const { error } = await supabase.storage
.from('avatars')
.upload(filePath, file, { upsert: true })
if (!error) {
const url = supabase.storage.from('avatars').getPublicUrl(filePath)
setUserAvatar(url.data.publicUrl)
}
}
Imagen de artículo:
async function handlePostImageUpload(file: File) {
const filePath = `posts/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('post-images')
.upload(filePath, file)
if (!error) {
const { data: urlData } = await supabase.storage
.from('post-images')
.createSignedUrl(filePath, 86400)
insertImageToEditor(urlData?.signedUrl)
}
}
Paso 4: pruebas antes de lanzar
Verifica estos puntos:
- ¿Un visitante anónimo puede ver las imágenes de los posts? (debería, la Policy SELECT lo permite)
- ¿Un usuario normal puede subir a post-images? (no debería; solo rol author)
- ¿El usuario A puede sobrescribir el avatar del usuario B? (no; aislamiento por ruta)
Comprueba cada punto. No quiero repetir mi lección de las 3 AM.
Resumen
Tres pilares en Supabase Storage: subida, permisos y aceleración.
La subida es lo más simple: unas pocas líneas. Los permisos requieren más cuidado: la RLS Policy no se configura una vez y listo; hay que probarla según el caso de uso. CDN y transformación de imágenes son extras del Pro Plan, pero ahorran mucho desarrollo.
Mi enfoque: primero subida y permisos seguros; después CDN si hace falta rendimiento y transformación si hace falta procesar imágenes. Paso a paso, sin apresurarse.
Si también usas Supabase Storage y te has llevado algún susto, no eres el único.
Configuración completa de Supabase Storage
De crear Bucket a permisos y aceleración CDN
⏱️ Estimated time: 30 min
- 1
Step 1: Crear Bucket
En el panel de Supabase crea un bucket privado:
• Storage > New bucket
• Nombre (p. ej. avatars)
• Marca Private (recomendado por defecto)
• Create bucket - 2
Step 2: Configurar RLS Policy
Row Level Security para el bucket:
• Storage > Bucket > Policies
• New Policy
• Tipo SELECT/INSERT/UPDATE/DELETE
• Regla de aislamiento por usuario
• Probar en SQL Editor antes de producción - 3
Step 3: Subir archivos
Con el SDK:
• Ruta userId/filename
• storage.from().upload()
• cacheControl y upsert
• Manejar errores y ruta devuelta - 4
Step 4: CDN y transformación (opcional)
Con Pro Plan:
• Smart CDN para archivos populares
• Parámetros width/height/format en URL
• Integración Next.js Image Loader
• Vigilar cuota gratuita (100 img/mes)
FAQ
¿Diferencia entre bucket public y private?
¿Cómo aislar usuarios con RLS?
• Ruta userId/filename
• Policy: auth.uid()::text = (storage.foldername(name))[1]
• Cada usuario solo opera rutas que empiezan por su ID
¿Cómo compartir archivos de bucket private?
¿Límites de subida?
¿Parámetros y límites de transformación de imágenes?
• width/height: 1-2500 px
• resize: contain o cover
• quality: 1-100
• format: webp/jpeg/png/gif/avif
Límite: archivo original máx. 25 MB
¿Smart CDN y transformación requieren pago?
6 min de lectura · Publicado el: 9 abr 2026 · Actualizado el: 21 ago 2026
Supabase en práctica
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Supabase Auth en la práctica: verificación por email, OAuth y sesiones
Guía de Supabase Auth: verificación por email, integración OAuth, gestión de sesiones JWT y flujo PKCE. Autenticación completa en un solo sitio.
Parte 3 de 10
Siguiente
Supabase Realtime en la práctica: comparación de tres modos y desarrollo de apps colaborativas
Supabase Realtime ofrece tres modos en tiempo real: Postgres Changes, Presence y Broadcast. Este artículo compara cada modo y ofrece ejemplos completos de apps colaborativas con configuración RLS.
Parte 5 de 10



Comentarios
Inicia sesión con GitHub para dejar un comentario