Pular para o conteúdo
📖 COOKBOOK

Subir avatar com miniatura.

O usuário escolhe a foto, o arquivo vai para um bucket público e a miniatura sai da mesma imagem pela URL de transformação — sem processar nada no seu app.

O que vamos fazer

Componente de avatar: o usuário escolhe uma imagem, vê a prévia na hora e, ao confirmar, o arquivo vai para o bucket público proj_loja_avatares, na pasta dele. A URL pública abre em qualquer <img>, e a miniatura 200×200 sai da mesma imagem, pela URL.

O fluxo completo:

  • <input type="file" accept="image/*"> com prévia no navegador.
  • Upload para {user_id}/avatar com upsert: true: trocar de foto sobrescreve o mesmo arquivo.
  • URL pública de getPublicUrl(), guardada em profiles.avatar_url.
  • Miniatura: troque /object/ por /render/image/ na URL e acrescente ?width=200&height=200&resize=cover.
app.superdb.com.br/projects/loja/storage
proj_loja_avatares ×
1// 8c4f…/avatar — 84 KB
2// 9b21…/avatar — 142 KB
3// e07a…/avatar — 56 KB

Pré-requisitos

  • Projeto SuperDB criado e SDK instalado (@superdb/client) — com os nomes do supabase-js, já traz .auth, .from() e .storage.
  • Usuário logado: o SDK troca a sessão dele por um token de storage, sozinho.
  • Tabela profiles com a coluna avatar_url, e uma policy que deixe o usuário atualizar a própria linha.
🔵

Quem lê e quem grava: num bucket público, qualquer pessoa com a URL lê. Gravar exige um usuário logado do projeto ou a chave de servidor — e hoje qualquer usuário logado grava em qualquer pasta do projeto, porque o Storage ainda não separa arquivos por usuário. A pasta {user_id}/ organiza, mas não protege.

Passo a passo

1. Criar o bucket no painel

No painel, vá em Storage → Novo bucket. Nome avatares, visibilidade Público e limite de 2 MB. O painel cria o bucket como proj_loja_avatares (proj_ + o slug do projeto + o nome) — é esse nome, o que aparece na lista, que vai no código.

O Storage não filtra o tipo de arquivo: confira file.type no app antes de subir.

2. Componente React com prévia e upload

src/components/AvatarUpload.tsx
'use client'
import { useState } from 'react'
import { db } from '@/lib/superdb'
import { useUser } from '@/lib/auth'

const BUCKET = 'proj_loja_avatares'
const TIPOS = ['image/jpeg', 'image/png', 'image/webp']

export function AvatarUpload() {
  const user = useUser()
  const [preview, setPreview] = useState<string | null>(null)
  const [thumb, setThumb] = useState<string | null>(null)
  const [busy, setBusy] = useState(false)

  async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file || !user) return
    if (!TIPOS.includes(file.type)) return alert('Envie JPEG, PNG ou WebP')
    if (file.size > 2 * 1024 * 1024) return alert('Imagem acima de 2 MB')
    setPreview(URL.createObjectURL(file))
    setBusy(true)

    const path = `${user.id}/avatar`
    // O SDK troca a sessão por um token de storage sozinho
    const { error } = await db.storage.from(BUCKET)
      .upload(path, file, { upsert: true, contentType: file.type })
    if (error) { console.error(error); setBusy(false); return }

    const { data: { publicUrl } } = db.storage.from(BUCKET).getPublicUrl(path)
    await db.from('profiles').update({ avatar_url: publicUrl }).eq('id', user.id)

    // Miniatura: a mesma imagem, pelo caminho de transformação.
    // O v= só faz o navegador buscar de novo depois da troca.
    setThumb(publicUrl.replace('/object/', '/render/image/')
      + `?width=200&height=200&resize=cover&quality=80&v=${Date.now()}`)
    setBusy(false)
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFile} disabled={busy} />
      {preview && <img src={thumb ?? preview} alt="Avatar" width={200} height={200} />}
    </div>
  )
}
💡

Dica: com upsert: true e nome fixo (avatar), cada usuário tem um arquivo só e o bucket não acumula lixo. Trocar a foto é sobrescrever o mesmo caminho.

3. Miniatura pela URL

O Storage redimensiona e recorta pela URL, sem job e sem fila. É outro caminho, não uma query na URL do arquivo:

URL de transformação
https://storage.superdb.com.br/render/image/public/proj_loja_avatares/<user_id>/avatar
  ?width=200       # largura em px
  &height=200      # altura em px
  &resize=cover    # cover | contain | fill
  &quality=80      # de 20 a 100

Sai em WebP quando o navegador aceita e, se não, no formato original. Só funciona em bucket público.

Resultado

Pronto. Você tem:

  • Avatar numa URL pública, que qualquer <img> abre.
  • Miniatura gerada pelo Storage, sem código de processamento no app.
  • A URL salva em profiles.avatar_url, para vir junto com o resto do perfil.

Variações

Recorte no navegador antes do upload

Para forçar avatar quadrado independente da imagem original, use react-image-crop. Ele aplica o recorte num <canvas>, exporta um blob, e você sobe o blob.

Vários tamanhos

Cada tamanho é só outra URL: mude width e height (32, 64, 200, 400). Não precisa guardar cópias.

Fallback Gravatar

Quando avatar_url for null, mostre o Gravatar baseado no email (md5(email.trim().toLowerCase())).

Erros comuns

⚠️

Nome curto do bucket: com 'avatares' no lugar de 'proj_loja_avatares', o Storage não acha nada — a URL pública dá 400. Use o nome como aparece no painel.

⚠️

?width= na URL do arquivo: /object/public/…?width=200 devolve a imagem original inteira. A transformação só existe em /render/image/public/….

⚠️

Bucket privado por engano: a URL de getPublicUrl() só abre em bucket público. No painel, a visibilidade do bucket precisa ser Público.

⚠️

Foto antiga na tela depois da troca: as respostas do Storage mandam o navegador revalidar, mas uma imagem já carregada na mesma página pode ficar. Acrescente &v=<timestamp> depois de cada upload, como no componente.

Essa página ajudou?