Cambiar tema

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

Easton editorial illustration: orchestration hub with branches

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.jpguser123).

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

  1. Falta INSERT Policy → “new row violates row-level security policy”
  2. USING (true) demasiado amplio
  3. User ID no en primer nivel → foldername extrae 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 suyo
  • post-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:

  1. Storage > New bucket > nombre avatars, marca Private
  2. 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:

  1. ¿Un visitante anónimo puede ver las imágenes de los posts? (debería, la Policy SELECT lo permite)
  2. ¿Un usuario normal puede subir a post-images? (no debería; solo rol author)
  3. ¿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. 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. 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. 3

    Step 3: Subir archivos

    Con el SDK:

    • Ruta userId/filename
    • storage.from().upload()
    • cacheControl y upsert
    • Manejar errores y ruta devuelta
  4. 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?
Public: lectura sin autenticación, ideal para logos o avatares públicos. Private: requiere autenticación; permisos concretos los define RLS. Más seguro.
¿Cómo aislar usuarios con RLS?
User ID en el primer segmento de ruta:

• 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?
createSignedUrl() genera URL temporal firmada. Muy larga = insegura; muy corta = mala UX. Suele usarse 1-4 horas.
¿Límites de subida?
Estándar hasta 5 GB. Menos de 6 MB: subida estándar. Más de 6 MB: TUS con reanudación tras cortes de red.
¿Parámetros y límites de transformación de imágenes?
Parámetros:

• 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?
Sí, Pro Plan ($25/mes). Free Plan: subida/descarga básica. Transformación: 100 imágenes/mes gratis; luego $5 por cada 1000.

6 min de lectura · Publicado el: 9 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog