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:
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:
// 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
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
'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):
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
'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:
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.jpgNota 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:
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 cacheaUsa 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:
// 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
const { data, error } = await supabase.storage
.from('documentos')
.createSignedUrl('user-123/foto-id.jpg', 3600, {
transform: {
width: 400,
height: 300
}
})Generar multiples URLs firmadas
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
const { data, error } = await supabase.storage
.from('avatares')
.remove(['user-123/foto-vieja.jpg'])Eliminar multiples archivos
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:
'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
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
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
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:
// 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 }
}// 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