Subir Archivos a Supabase Storage

Esta sección cubre todas las operaciones de archivos que vas a necesitar: subir, descargar, obtener URLs, eliminar, y los patrones que funcionan en producción.

Subir archivos con .upload()

El método .upload() recibe la ruta destino y el archivo:

typescript
const { data, error } = await supabase.storage
  .from('avatares')
  .upload('user-123/foto.jpg', file)

Subir desde un input de tipo file

El patrón más común en una aplicación web:

typescript
// Referencia al input
const fileInput = document.getElementById('avatar') as HTMLInputElement
 
fileInput.addEventListener('change', async (event) => {
  const target = event.target as HTMLInputElement
  const file = target.files?.[0]
 
  if (!file) return
 
  const { data, error } = await supabase.storage
    .from('avatares')
    .upload(`user-123/${file.name}`, file, {
      cacheControl: '3600',
      upsert: false // false = error si el archivo ya existe
    })
 
  if (error) {
    console.error('Error al subir:', error.message)
    return
  }
 
  console.log('Archivo subido:', data.path)
})

Opciones de upload

typescript
const { data, error } = await supabase.storage
  .from('avatares')
  .upload('ruta/archivo.jpg', file, {
    // Cache-Control header (en segundos)
    cacheControl: '3600',
 
    // true = reemplaza si ya existe, false = error si ya existe
    upsert: false,
 
    // Content-Type (se detecta automaticamente, pero puedes forzarlo)
    contentType: 'image/jpeg',
 
    // Metadata custom (se guarda con el archivo)
    duplex: 'half'
  })
upsert: true vs false

Con upsert: false (default), si intentas subir un archivo con un nombre que ya existe, obtienes un error. Con upsert: true, el archivo existente se reemplaza. Usa upsert: true cuando quieras permitir que el usuario actualice su avatar, por ejemplo.

Componente React para subir archivos

tsx
'use client'
 
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
 
export function AvatarUpload({ userId }: { userId: string }) {
  const [uploading, setUploading] = useState(false)
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
  const supabase = createClient()
 
  async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0]
    if (!file) return
 
    setUploading(true)
 
    // Generar nombre unico para evitar conflictos
    const fileExt = file.name.split('.').pop()
    const fileName = `${userId}/avatar.${fileExt}`
 
    const { data, error } = await supabase.storage
      .from('avatares')
      .upload(fileName, file, {
        cacheControl: '3600',
        upsert: true // Reemplazar avatar existente
      })
 
    if (error) {
      console.error('Error:', error.message)
      setUploading(false)
      return
    }
 
    // Obtener URL pública
    const { data: urlData } = supabase.storage
      .from('avatares')
      .getPublicUrl(data.path)
 
    setAvatarUrl(urlData.publicUrl)
    setUploading(false)
  }
 
  return (
    <div>
      {avatarUrl && (
        <img
          src={avatarUrl}
          alt="Avatar"
          className="w-24 h-24 rounded-full object-cover"
        />
      )}
      <label className="cursor-pointer inline-block px-4 py-2 bg-zinc-800 text-white rounded mt-4">
        {uploading ? 'Subiendo...' : 'Cambiar avatar'}
        <input
          type="file"
          accept="image/png, image/jpeg, image/webp"
          onChange={handleUpload}
          disabled={uploading}
          className="hidden"
        />
      </label>
    </div>
  )
}

Descargar archivos con .download()

.download() descarga el archivo cómo un Blob (un objeto binario que representa el contenido del archivo):

typescript
const { data, error } = await supabase.storage
  .from('documentos')
  .download('user-123/factura.pdf')
 
if (data) {
  // data es un Blob
  // Crear un link de descarga
  const url = URL.createObjectURL(data)
  const a = document.createElement('a')
  a.href = url
  a.download = 'factura.pdf'
  a.click()
  URL.revokeObjectURL(url) // Liberar memoria
}

Componente de descarga

tsx
'use client'
 
import { createClient } from '@/lib/supabase/client'
 
export function DownloadButton({ path, filename }: { path: string; filename: string }) {
  const supabase = createClient()
 
  async function handleDownload() {
    const { data, error } = await supabase.storage
      .from('documentos')
      .download(path)
 
    if (error) {
      console.error('Error al descargar:', error.message)
      return
    }
 
    const url = URL.createObjectURL(data)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
  }
 
  return (
    <button
      onClick={handleDownload}
      className="px-4 py-2 bg-zinc-800 text-white rounded"
    >
      Descargar {filename}
    </button>
  )
}

Obtener URL pública con .getPublicUrl()

Para archivos en buckets públicos:

typescript
const { data } = supabase.storage
  .from('avatares')
  .getPublicUrl('user-123/foto.jpg')
 
console.log(data.publicUrl)
// https://tu-proyecto.supabase.co/storage/v1/object/public/avatares/user-123/foto.jpg

Nota que .getPublicUrl() no es async -- no hace una petición al servidor. Solo construye la URL basada en la configuración del proyecto.

Transformaciones de imagen

Supabase permite transformar imágenes al vuelo al obtener la URL pública:

typescript
const { data } = supabase.storage
  .from('avatares')
  .getPublicUrl('user-123/foto.jpg', {
    transform: {
      width: 200,
      height: 200,
      resize: 'cover' // 'cover', 'contain', 'fill'
    }
  })
 
// La URL incluye parametros de transformacion
// Supabase genera la version transformada y la cachea
Usa transformaciones para thumbnails

En vez de subir multiples versiones de una imagen (original, thumbnail, medium), sube solo el original y usa transformaciones en la URL. Supabase cachea las versiones transformadas automáticamente.

Crear URLs firmadas con .createSignedUrl()

Para archivos en buckets privados:

typescript
// URL que expira en 1 hora (3600 segundos)
const { data, error } = await supabase.storage
  .from('documentos')
  .createSignedUrl('user-123/factura.pdf', 3600)
 
if (data) {
  console.log(data.signedUrl)
}

URLs firmadas con transformacion

typescript
const { data, error } = await supabase.storage
  .from('documentos')
  .createSignedUrl('user-123/foto-id.jpg', 3600, {
    transform: {
      width: 400,
      height: 300
    }
  })

Generar multiples URLs firmadas

typescript
const archivos = [
  'user-123/factura-01.pdf',
  'user-123/factura-02.pdf',
  'user-123/factura-03.pdf'
]
 
const { data, error } = await supabase.storage
  .from('documentos')
  .createSignedUrls(archivos, 3600)
 
// data es un array: [{ signedUrl, path, error }, ...]

Eliminar archivos

Eliminar un archivo

typescript
const { data, error } = await supabase.storage
  .from('avatares')
  .remove(['user-123/foto-vieja.jpg'])

Eliminar multiples archivos

typescript
const { data, error } = await supabase.storage
  .from('avatares')
  .remove([
    'user-123/foto-1.jpg',
    'user-123/foto-2.jpg',
    'user-123/foto-3.jpg'
  ])
remove() recibe un array

Aunque elimines un solo archivo, .remove() siempre recibe un array de rutas. Es un error común pasar un string directamente.

Subir imágenes con preview

Un patrón completo para subir imágenes con preview antes de confirmar:

tsx
'use client'
 
import { useState, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'
 
export function ImageUploadWithPreview({ userId }: { userId: string }) {
  const [preview, setPreview] = useState<string | null>(null)
  const [file, setFile] = useState<File | null>(null)
  const [uploading, setUploading] = useState(false)
  const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const supabase = createClient()
 
  function handleSelect(event: React.ChangeEvent<HTMLInputElement>) {
    const selected = event.target.files?.[0]
    if (!selected) return
 
    // Validar tipo
    if (!selected.type.startsWith('image/')) {
      alert('Solo se permiten imagenes')
      return
    }
 
    // Validar tamano (5MB max)
    if (selected.size > 5 * 1024 * 1024) {
      alert('La imagen no puede pesar mas de 5MB')
      return
    }
 
    setFile(selected)
 
    // Crear preview local (sin subir al servidor)
    const reader = new FileReader()
    reader.onload = (e) => {
      setPreview(e.target?.result as string)
    }
    reader.readAsDataURL(selected)
  }
 
  async function handleUpload() {
    if (!file) return
 
    setUploading(true)
 
    const fileExt = file.name.split('.').pop()
    const fileName = `${userId}/${crypto.randomUUID()}.${fileExt}`
 
    const { data, error } = await supabase.storage
      .from('imagenes')
      .upload(fileName, file, {
        cacheControl: '3600',
        upsert: false
      })
 
    if (error) {
      console.error('Error:', error.message)
      setUploading(false)
      return
    }
 
    const { data: urlData } = supabase.storage
      .from('imagenes')
      .getPublicUrl(data.path)
 
    setUploadedUrl(urlData.publicUrl)
    setUploading(false)
  }
 
  function handleCancel() {
    setFile(null)
    setPreview(null)
    if (inputRef.current) inputRef.current.value = ''
  }
 
  return (
    <div className="space-y-4">
      {preview ? (
        <div>
          <img
            src={preview}
            alt="Preview"
            className="max-w-sm rounded border border-zinc-700"
          />
          <div className="flex gap-2 mt-2">
            <button
              onClick={handleUpload}
              disabled={uploading}
              className="px-4 py-2 bg-green-700 text-white rounded"
            >
              {uploading ? 'Subiendo...' : 'Confirmar'}
            </button>
            <button
              onClick={handleCancel}
              disabled={uploading}
              className="px-4 py-2 bg-zinc-700 text-white rounded"
            >
              Cancelar
            </button>
          </div>
        </div>
      ) : (
        <label className="cursor-pointer inline-block px-4 py-2 bg-zinc-800 text-white rounded">
          Seleccionar imagen
          <input
            ref={inputRef}
            type="file"
            accept="image/*"
            onChange={handleSelect}
            className="hidden"
          />
        </label>
      )}
 
      {uploadedUrl && (
        <p className="text-green-400 text-sm">Imagen subida correctamente</p>
      )}
    </div>
  )
}

Estrategias de nombrado de archivos

El nombre del archivo en Storage determina su ruta de acceso. Una buena estrategia de nombrado evita conflictos y facilita la organización.

Problema: nombres duplicados

Si dos usuarios suben foto.jpg, se sobreescriben mutuamente (con upsert: true) o uno obtiene un error (con upsert: false).

Solución 1: carpeta por usuario + UUID

typescript
const fileName = `${userId}/${crypto.randomUUID()}.${fileExt}`
// Resultado: "d0a3e4c8-.../f7b2c1a9-...-4321.jpg"

Ventajas: nunca hay conflictos, fácil de asociar con el usuario.

Solución 2: timestamp + nombre original

typescript
const fileName = `${userId}/${Date.now()}-${file.name}`
// Resultado: "d0a3e4c8-.../1709234567890-foto-vacaciones.jpg"

Ventajas: conserva el nombre original, ordenable por fecha.

Solución 3: hash del contenido

typescript
async function hashFile(file: File): Promise<string> {
  const buffer = await file.arrayBuffer()
  const hash = await crypto.subtle.digest('SHA-256', buffer)
  const hashArray = Array.from(new Uint8Array(hash))
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
 
const hash = await hashFile(file)
const fileName = `${userId}/${hash}.${fileExt}`
// Resultado: "d0a3e4c8-.../a1b2c3d4e5f6...7890.jpg"

Ventajas: el mismo archivo siempre tiene el mismo nombre (deduplicación natural).

Recomendación

Para la mayoria de los casos, carpeta por usuario + UUID es la mejor opción. Es simple, no tiene conflictos, y facilita las políticas RLS basadas en carpeta.

Subir desde Server Actions (NextJS)

Si prefieres que el upload pase por tu servidor en vez de ir directo del cliente a Supabase:

typescript
// app/actions/upload.ts
'use server'
 
import { createClient } from '@/lib/supabase/server'
 
export async function uploadAvatar(formData: FormData) {
  const supabase = await createClient()
  const file = formData.get('avatar') as File
 
  if (!file || file.size === 0) {
    return { error: 'No se selecciono archivo' }
  }
 
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) {
    return { error: 'No autenticado' }
  }
 
  const fileExt = file.name.split('.').pop()
  const fileName = `${user.id}/avatar.${fileExt}`
 
  const { data, error } = await supabase.storage
    .from('avatares')
    .upload(fileName, file, {
      cacheControl: '3600',
      upsert: true
    })
 
  if (error) {
    return { error: error.message }
  }
 
  const { data: urlData } = supabase.storage
    .from('avatares')
    .getPublicUrl(data.path)
 
  // Actualizar el perfil del usuario con la nueva URL
  await supabase
    .from('perfiles')
    .update({ avatar_url: urlData.publicUrl })
    .eq('id', user.id)
 
  return { url: urlData.publicUrl }
}
tsx
// Componente que usa la Server Action
'use client'
 
import { uploadAvatar } from '@/app/actions/upload'
 
export function AvatarForm() {
  async function handleSubmit(formData: FormData) {
    const result = await uploadAvatar(formData)
 
    if (result.error) {
      console.error(result.error)
      return
    }
 
    console.log('Avatar actualizado:', result.url)
  }
 
  return (
    <form action={handleSubmit}>
      <input type="file" name="avatar" accept="image/*" required />
      <button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded">
        Subir avatar
      </button>
    </form>
  )
}

Resumen

  • .upload() sube archivos con opciones de cache, upsert y content type
  • .download() descarga archivos como Blob
  • .getPublicUrl() genera URLs para buckets públicos (sincrono, no hace request)
  • .createSignedUrl() genera URLs temporales para buckets privados
  • .remove() elimina archivos (recibe un array, no un string)
  • Usa crypto.randomUUID() para evitar conflictos en nombres de archivo
  • Organiza archivos en carpetas por usuario para simplificar las políticas RLS
  • Valida tipo y tamaño en el cliente antes de subir