< BACK Construindo um Site de Leilão em Tempo Real com Next.js & Supabase -- ilustração em arte linear

Construindo um Site de Leilão em Tempo Real com Next.js & Supabase

Um cliente me ligou numa quinta-feira à tarde em 2021, um leiloeiro de antiguidades baseado em Bath, que queria levar seus leilões mensais presenciais para online. Simples o suficiente, pensei. Aí ele disse "e os lances precisam atualizar para todo mundo assistindo, ao vivo, sem refresh de página." Certo. Foi aí que um "simples trabalho com WordPress" virou uma conversa de arquitetura de duas semanas.

Takeaway fundamental: Leilão ao vivo em Next.js mais Supabase depende de canais em tempo real, row-level security e validação de lances server-side; acerte a máquina de estados antes da UI.

Já construí mais de 12 mil sites na Seahawk Media, e features em tempo real são as que te mordem se você não planejar direito desde o início. Fazer polling a cada cinco segundos soa bem até você ter 200 leiloadores martelando um único endpoint simultaneamente e sua conta de hosting dobrar da noite para o dia. Então deixa eu te mostrar exatamente como eu construiria uma plataforma de leilão ao vivo adequada hoje, usando Next.js e Supabase, com base no que eu realmente já coloquei em produção.

---

Por que Next.js e Supabase para Isso Especificamente

Olha, há uma dúzia de formas de fazer tempo real. Socket.io num servidor Node, Ably, Pusher, Firebase, eu usei todos eles em vários pontos. Mas a combinação Next.js + Supabase merece seu lugar aqui por um motivo específico: Supabase Realtime é construído em cima da replicação lógica do PostgreSQL, o que significa que suas atualizações de lances ao vivo e sua camada de dados persistente são o mesmo sistema. Sem sincronizar duas fontes de verdade. Sem ficar se perguntando se um lance que entrou no WebSocket também entrou no banco de dados.

Supabase também oferece Auth, Row Level Security e Storage prontos para usar. Para um site de leilão, onde "apenas o proprietário do leilão pode encerrar um lote" e "um usuário não pode fazer lances em seu próprio item" são regras de negócio reais, as políticas RLS no Postgres são genuinamente a ferramenta certa.

E Next.js porque, honestamente, App Router com Server Components significa que você pode renderizar o catálogo de leilões estaticamente, manter o SEO feliz, e apenas hidratar o widget de leilão em tempo real no cliente. Essa divisão importa. Você não quer pagar por renderização dinâmica numa página que é 90% conteúdo estático.

---

Projetando o Schema Primeiro (Não Pule Isso)

É aqui que a maioria das pessoas se apressa e se arrepende depois. Passei três dias constrangedores refatorando o schema do cliente de antiguidades Bath no meio do projeto porque não havia pensado adequadamente no modelo de histórico de lances.

Aqui está a estrutura principal que uso agora:

  • `profiles`, estende auth.users do Supabase, armazena display name, flag de leiloador verificado, e um credit_balance se você estiver fazendo leilão baseado em depósito
  • `auctions`, o evento em si; starts_at, ends_at, status (draft | live | closed), e created_by
  • `lots`, itens individuais dentro de um leilão; reserve_price, current_bid, current_bidder_id, lot_number, ends_at (lotes podem ter countdowns individuais)
  • `bids`, log append-only imutável; lot_id, bidder_id, amount, placed_at. Nunca atualize essa tabela. Nunca.
  • `auction_participants`, uma tabela de junção rastreando quem se registrou para qual leilão (útil para retenção de depósitos e direcionamento de notificações)

As colunas current_bid e current_bidder_id em lots são desnormalizadas intencionalmente. Sim, você poderia derivá-las da tabela bids a cada leitura, mas sob carga concorrente essa query fica cara rápido. Desnormalize, mantenha a tabela bids como seu registro de auditoria, e use uma função Postgres para atualizar lots atomicamente quando um lance é aceito.

A Função de Lance Atômico

Essa é a parte que a maioria dos tutoriais pula. Race conditions em leilões são reais. Dois usuários enviando £520 no mesmo milissegundo, o que acontece?

A resposta é uma função Postgres com locking FOR UPDATE na linha do lot:

``` create or replace function place_bid(p_lot_id uuid, p_bidder_id uuid, p_amount numeric) returns json as $$ declare v_lot lots%rowtype; begin select * into v_lot from lots where id = p_lot_id for update;

if v_lot.status != 'live' then return json_build_object('success', false, 'error', 'Lot is not live'); end if;

if p_amount <= v_lot.current_bid then return json_build_object('success', false, 'error', 'Bid too low'); end if;

if p_bidder_id = v_lot.current_bidder_id then return json_build_object('success', false, 'error', 'You are already the highest bidder'); end if;

insert into bids (lot_id, bidder_id, amount) values (p_lot_id, p_bidder_id, p_amount);

update lots set current_bid = p_amount, current_bidder_id = p_bidder_id where id = p_lot_id;

return json_build_object('success', true, 'new_bid', p_amount); end; $$ language plpgsql security definer; ```

Chame isso da sua rota API Next.js via supabase.rpc('place_bid', {...}). O lock FOR UPDATE significa que apenas uma transação vence por lot em qualquer momento. A outra recebe um erro de serialização e você retorna uma mensagem amigável "alguém acabou de fazer um lance maior que o seu" no cliente.

---

Row Level Security, A Camada de Regras de Leilão

RLS é uma daquelas coisas que desenvolvedores ou amam imediatamente ou evitam porque parece opaca. Eu estava no campo da evasão até um projeto de fintech na Seahawk me ensinar na prática que enforçar controle de acesso apenas no código da aplicação é apenas uma rota de API mal configurada de distância de um desastre.

Para um site de leilão, aqui estão as políticas que importam:

  1. Qualquer um pode ler lotes ativos, SELECT em lots onde auctions.status = 'live'
  2. Apenas licitadores autenticados e verificados podem inserir lances, verifique profiles.verified_bidder = true na política
  3. Apenas o criador do leilão pode atualizar o status do lote, UPDATE em lots onde auctions.created_by = auth.uid()
  4. O histórico de lances é legível pelo criador do leilão do lote e pelo próprio licitador, mais ninguém precisa ver o histórico completo de lances em tempo real

A documentação de RLS do Supabase é genuinamente boa aqui, vale a pena ler a seção sobre security definer functions, porque interage com como chamadas RPC como place_bid funcionam.

Uma armadilha: se você usar security definer na sua função Postgres (como acima), ela é executada com os privilégios do proprietário da função, contornando RLS. Isso é intencional, você quer que a colocação de lance contorne o RLS do licitador para que possa bloquear e atualizar a linha do lote. Mas isso significa que você deve enforçar suas próprias verificações de lógica de negócio dentro da função, o que o código acima faz.

---

Configurando Supabase Realtime no Next.js

Aqui é onde fica realmente satisfatório. O Supabase Realtime permite que você se inscreva em mudanças em uma tabela Postgres usando WebSockets por baixo, e o SDK do cliente torna isso quase envergonhosamente simples.

Na sua página de lote de leilão, um Client Component no Next.js App Router, você faria algo como:

``` 'use client'

import { useEffect, useState } from 'react' import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'

export default function LotBidDisplay({ lotId, initialBid }) { const [currentBid, setCurrentBid] = useState(initialBid) const supabase = createClientComponentClient()

useEffect(() => { const channel = supabase.channel(lot-${lotId}).on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'lots', filter:id=eq.${lotId}}, (payload) => { setCurrentBid(payload.new.current_bid) } ).subscribe()

return () => { supabase.removeChannel(channel) } }, [lotId])

return <div>Current bid: £{currentBid.toLocaleString()}</div> } ```

Passe initialBid a partir de um Server Component que busca dados frescos no momento da requisição. O cliente então assume o controle, ouvindo eventos UPDATE naquela linha específica do lote. Toda vez que place_bid executa com sucesso, o Supabase transmite a mudança e a UI de cada licitador conectado atualiza em cerca de 100-300ms típicamente.

Tratando o Contador Regressivo

Lotes geralmente têm uma contagem regressiva, "fecha em 3:42". Não confie no relógio do cliente para isso. Derive o horário de término de lots.ends_at (armazenado em UTC no Postgres) e calcule segundos restantes no cliente usando Date.now(). Ressincronize a cada 60 segundos com uma busca nova em caso de desvio. E adicione lógica de "soft close": se um lance chegar nos últimos 60 segundos, estenda ends_at por dois minutos. Esse é o comportamento padrão de leilão e os licitadores esperam isso.

---

Arquitetura do App Router do Next.js para a UI do Leilão

A estrutura de página que eu usaria:

``app/ auctions/ page.tsx ← Server Component, lista leilões ao vivo (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, busca lista de lotes no servidor LotGrid.tsx ← Client Component, se inscreve em mudanças de status do lote [lotId]/ page.tsx ← Server Component, dados iniciais do lote + metadados para SEO BidPanel.tsx ← Client Component, exibição de oferta em tempo real + formulário de oferta``

O catálogo (/auctions) usa Incremental Static Regeneration com revalidação a cada 60 segundos. Páginas individuais de lotes são renderizadas no servidor no primeiro carregamento (para compartilhamento, visualização, geração de og:image), depois são transferidas para componentes client-side para as funcionalidades dinâmicas.

Uma coisa que sempre faço: manter o componente BidPanel lazy-loaded atrás de dynamic(() => import('./BidPanel'), { ssr: false }). Só faz sentido no lado cliente mesmo, e mantém seu payload HTML inicial enxuto para usuários em conexões lentas, o que, se seu público de leilão é mais velho (como costuma ser em leilões de antiguidades), importa muito mais do que você esperaria.

---

Autenticação e o Fluxo de "Bidder Verificado"

Autenticação padrão do Supabase com email/senha ou magic link funciona bem para inscrição. Mas leilões frequentemente precisam de um passo extra: verificação de bidder. Você pode precisar de bloqueio de cartão de crédito, verificação de identidade, ou apenas aprovação do admin antes de alguém poder realmente fazer uma oferta.

O padrão que uso: um booleano verified_bidder na tabela profiles, padronizando como false. Após o cadastro, o usuário vê uma tela "Conclua seu registro". Uma vez aprovado (manualmente por admin, ou automaticamente após uma autorização de pagamento Stripe), você muda a flag. A política RLS em bids a verifica. Eles podem navegar, observar, mas não licitam até serem verificados.

Para holds de autorização de pagamento no Stripe, payment intents do Stripe com capture_method: manual é a abordagem certa — você autoriza um hold de £50, captura se ele ganhar, libera se não ganhar. Isso reduz drasticamente situações de não-pagamento que, confie em mim, são o tormento de todo operador de leilão online.

---

Deployment, Performance e os Detalhes Que Vão Morder Você

Faça deploy no Vercel, é a escolha óbvia para Next.js e a edge network funciona bem com a infraestrutura global do Supabase. Certifique-se de que seu projeto Supabase está na região AWS mais próxima da sua região de deployment do Vercel. Já vi 40-60ms de latência completamente desnecessária porque alguém fez deploy do Vercel em us-east-1 e do Supabase em eu-west-2. Escolha uma região, coloque os dois lá.

Algumas coisas que vão causar dor se você não lidar com elas de frente:

  • Limites de conexão WebSocket. O nível gratuito do Supabase permite cerca de 200 conexões Realtime simultâneas. Se seu leilão ficar viral, esse limite importa. Verifique seu plano.
  • UI otimista para lances. Mostre o lance imediatamente na tela do licitador antes do servidor confirmar. Se falhar (superado, condição de corrida), reverta com um erro. O round-trip do servidor de 200-300ms é imperceptível a menos que a UI aguarde por ele.
  • Período de tolerância de término do lote. Nunca feche um lote exatamente em ends_at. Dê a ele um buffer de 2-3 segundos no lado do servidor para permitir que lances em trânsito que foram submetidos logo antes do prazo sejam processados. Trate isso em sua função agendada close_lot.
  • Notificações por email. Use Supabase Edge Functions com Resend ou Postmark para enviar emails "Você foi superado" e "Você venceu!". Não tente fazer isso a partir de suas API routes do Next.js, elas podem dar timeout, e os participantes do leilão ficam genuinamente irritados se as notificações não forem confiáveis.

---

FAQ

Quantos leiloeiros simultâneos o Supabase Realtime consegue lidar?

O plano Pro do Supabase suporta até 500 conexões Realtime simultâneas por padrão, com limites mais altos disponíveis. Para a maioria dos sites de leilão, a menos que você esteja rodando algo do tamanho do Sotheby's online, isso é mais que suficiente. Se você espera milhares de visualizadores simultâneos, considere transmitir atualizações de lotes através de um único canal server-side em vez de inscrições por usuário, e veja o Supabase Realtime Broadcast feature que é mais eficiente para cenários de alto fan-out.

Devo usar Supabase Realtime ou um serviço dedicado como Ably?

Para a maioria dos projetos, Supabase Realtime é perfeitamente adequado e a integração é muito mais simples já que seus dados já estão no Supabase. Eu só buscaria Ably ou Pusher se você precisasse de latência sub-50ms globalmente, ou se estivesse construindo algo com milhões de conexões simultâneas. Um leilão de antiguidades, uma arrecadação de caridade, uma venda online de uma pequena galeria de arte, Supabase lida com todos esses casos muito bem.

O que acontece se a conexão WebSocket de um usuário cair no meio do leilão?

O SDK cliente do Supabase tentará reconectar automaticamente. Mas você sempre deve buscar novamente o estado atual do lote (current_bid, ends_at) ao reconectar em vez de confiar em qualquer coisa que estivesse no estado local antes da queda. Adicione um ouvinte de evento online/offline em seu Client Component e dispare uma busca no servidor quando a conexão for restaurada.

Posso usar Server Actions do Next.js para colocar lances em vez de uma rota API?

Sim, e eu já fiz. Server Actions no Next.js 14 são convenientes, removem o boilerplate de uma rota dedicada /api/bid. O tradeoff é que Server Actions são um pouco mais difíceis de rate-limit individualmente (você aplicaria rate limiting no nível do middleware em vez de por-ação). Para um site de leilão em produção, eu adicionaria rate limiting Redis do Upstash no middleware para evitar que um único usuário spammeie requisições de bid independentemente de usar Actions ou API routes.

Como eu lido com empates, dois bids idênticos no mesmo momento?

O bloqueio FOR UPDATE na função place_bid do Postgres serializa lances concorrentes, então tecnicamente empates não podem acontecer no nível de banco de dados. Um terá sucesso, o outro falhará com uma resposta "lance muito baixo" (já que ambos são iguais a current_bid e a verificação é p_amount <= v_lot.current_bid). Primeiro a chegar, primeiro servido. Essa é a prática padrão de leilão e a maioria dos licitantes entende isso.

---

O antiquário de Bath, para ser honesto, está rodando seus leilões mensais online há mais de dois anos. Pico de licitadores simultâneos em um sábado à noite chegou a 84, toda sua aldeia aparentemente sintonizando para ver um lote disputado de prata georgiana ir para três vezes sua reserva. Supabase não piscou. Next.js não piscou. A única coisa que quebrou foi seu Wi-Fi, porque ele estava rodando do chão da loja.

Real-time é difícil de pensar antecipadamente, mas uma vez que o schema está sólido e a função de bid atômica está no lugar, o resto é principalmente encanação. Acerte a fundação e você gastará seu tempo nas partes divertidas, as animações de contagem regressiva, a UX "indo uma vez, indo duas vezes", em vez de debugar race conditions na meia-noite.

< BACK