Um cliente me ligou numa quinta-feira à tarde em 2021 — um antiquário de Bath, queria levar seus leilões mensais presenciais para online. Simples o bastante, pensei. Aí ele disse "e os lances precisam se atualizar para todo mundo assistindo, ao vivo, sem refresh de página." Certo. Foi quando um "trabalho WordPress simples" virou uma conversa de arquitetura de duas semanas.
Construí mais de 5.000 sites na Seahawk Media, e features em tempo real são aquelas que te mordem se você não as planejar direito desde o início. Fazer polling a cada cinco segundos parece ok até você ter 200 licitadores martelando um único endpoint simultaneamente e sua conta de hosting dobrar da noite para o dia. Então deixe me te mostrar exatamente como eu construiria uma plataforma de leilão ao vivo apropriada hoje — usando Next.js e Supabase — baseado no que eu realmente entreguei.Seahawk Media, and real-time features are the ones that bite you if you don't plan them properly from the start. Polling every five seconds sounds fine until you have 200 bidders hammering a single endpoint simultaneously and your hosting bill doubles overnight. So let me show you exactly how I'd build a proper live auction platform today — usingNext.jsandSupabase— based on what I've actually shipped.
---
Por que Next.js e Supabase para Isso Especificamente
Olha, existem uma dúzia de formas de fazer real-time. Socket.io num servidor Node, Ably, Pusher, Firebase — usei todos eles em vários pontos. Mas a combinação Next.js + Supabase ganha seu lugar aqui por uma razão específica: Supabase Realtime é construído em cima da replicação lógica do PostgreSQL, o que significa que seus updates de lance ao vivo e sua camada de dados persistentes são o mesmo sistema. Sem sincronizar duas fontes de verdade. Sem ficar se perguntando se um lance que entrou no WebSocket também chegou no banco de dados.same system. No syncing two sources of truth. No wondering if a bid that went into the WebSocket also made it into the database.
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 hidratar apenas o widget de lances em tempo real no cliente. Essa divisão importa. Você não quer pagar por renderização dinâmica em uma 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 nome de exibição, flag de licitante verificado e credit_balance se você está fazendo lances baseados em depósito— extends Supabase's
auth.users, stores display name, verified bidder flag, and acredit_balanceif you're doing deposit-based bidding - `auctions` — o evento em si; starts_at, ends_at, status (draft | live | closed) e created_by— the event itself;
starts_at,ends_at,status(draft | live | closed), andcreated_by - `lots` — itens individuais dentro de um leilão; reserve_price, current_bid, current_bidder_id, lot_number, ends_at (lotes podem ter contadores individuais)— individual items within an auction;
reserve_price,current_bid,current_bidder_id,lot_number,ends_at(lots can have individual countdowns) - `bids` — log imutável apenas para anexação; lot_id, bidder_id, amount, placed_at. Nunca atualize esta tabela. Nunca.— immutable append-only log;
lot_id,bidder_id,amount,placed_at. Never update this table. Ever. - `auction_participants` — uma tabela de junção que rastreia quem se registrou para qual leilão (útil para bloqueios de depósito e direcionamento de notificações)— a join table tracking who has registered for which auction (useful for deposit holds and notification targeting)
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 consulta 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.current_bidandcurrent_bidder_idcolumns onlotsare denormalised intentionally. Yes, you could derive them from thebidstable on every read, but under concurrent load that query gets expensive fast. Denormalise it, keep thebidstable as your audit log, and use a Postgres function to updatelotsatomically when a bid is accepted.
A Função de Lance Atômico
Este é o bit que a maioria dos tutoriais pula. Condições de corrida em leilões são reais. Dois usuários enviando £520 no mesmo milissegundo — o que acontece?real. Two users submitting £520 at the same millisecond — what happens?
A resposta é uma função Postgres com bloqueio FOR UPDATE na linha da lot:FOR UPDATElocking on the lot row:
``` 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 pela sua rota de API do Next.js via supabase.rpc('place_bid', {...}). O lock FOR UPDATE significa que apenas uma transação vence por lote em qualquer momento dado. A outra recebe um erro de serialização e você retorna uma mensagem amigável "alguém acabou de dar um lance maior que o seu" no cliente.supabase.rpc('place_bid', {...}). TheFOR UPDATElock means only one transaction wins per lot at any given moment. The other one gets a serialisation error and you return a friendly "someone just outbid you" message on the client.
---
Row Level Security — A Camada de Regras do 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:
- Qualquer um pode ler lotes ativos — SELECT on lots where auctions.status = 'live'—
SELECTonlotswhereauctions.status = 'live' - Apenas licitantes autenticados e verificados podem inserir lances — check profiles.verified_bidder = true na política— check
profiles.verified_bidder = truein the policy - Apenas o criador do leilão pode atualizar o status do lote — UPDATE on lots where auctions.created_by = auth.uid()—
UPDATEonlotswhereauctions.created_by = auth.uid() - O histórico de lances é legível pelo criador do leilão do lote e pelo próprio licitante — ninguém mais precisa ver o histórico completo de lances em tempo real— no one else needs to see full bid history in real time
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.Supabase RLS documentationis genuinely good here — worth reading the section on security definer functions, because it interacts with how RPC calls likeplace_bidwork.
Um detalhe importante: se você usar security definer na sua função Postgres (como acima), ela executa com os privilégios do proprietário da função, contornando RLS. Isso é intencional — você quer que a colocação de lance contorne a RLS do licitante para que possa bloquear e atualizar a linha do lote. Mas significa que você deve impor suas próprias verificações de lógica de negócio dentro da função, o que o código acima faz.security defineron your Postgres function (as above), it runs with the function owner's privileges, bypassing RLS. That's intentional — youwantthe bid placement to bypass the bidder's RLS so it can lock and update the lot row. But it means you must enforce your own business logic checks inside the function, which the code above does.
---
Configurando Supabase Realtime no Next.js
É aqui que fica realmente satisfatório. Supabase Realtime permite que você se inscreva em mudanças em uma tabela Postgres usando WebSockets por baixo, e o SDK do cliente torna tudo quase vergonhosamente simples.WebSockets underneath, and the client SDK makes it almost embarrassingly simple.
Na página do lote do leilão — um Client Component no Next.js App Router — você faria algo assim:
``` '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()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 de um Server Component que busca dados frescos no momento da requisição. O cliente então assume, escutando eventos UPDATE naquela linha específica do lote. Toda vez que place_bid roda com sucesso, Supabase transmite a mudança e a UI de cada licitador conectado se atualiza em cerca de 100–300ms tipicamente.initialBidfrom a Server Component that fetches fresh data at request time. The client then takes over, listening forUPDATEevents on that specific lot row. Every timeplace_bidruns successfully, Supabase broadcasts the change and every connected bidder's UI updates within about 100–300ms typically.
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 final de lots.ends_at (armazenado em UTC no Postgres) e calcule os segundos restantes no cliente usando Date.now(). Ressincronize a cada 60 segundos com uma busca fresca 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ões e os licitadores esperam isso.nottrust the client clock for this. Derive the end time fromlots.ends_at(stored in UTC in Postgres) and calculate remaining seconds on the client usingDate.now(). Re-sync it every 60 seconds with a fresh fetch in case of drift. And add "soft close" logic: if a bid arrives within the last 60 seconds, extendends_atby two minutes. That's standard auction behaviour and bidders expect it.
---
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 lance em tempo real + formulário de lance ``app/ auctions/ page.tsx ← Server Component, lists live auctions (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, fetches lots list server-side LotGrid.tsx ← Client Component, subscribes to lot status changes [lotId]/ page.tsx ← Server Component, initial lot data + metadata for SEO BidPanel.tsx ← Client Component, real-time bid display + bid form``
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./auctions) uses Incremental Static Regeneration with a 60-second revalidation. Individual lot pages render server-side on first load (for sharing, previewing, og:image generation), then hand off to client components for the live stuff.
Uma coisa que sempre faço: manter o componente BidPanel lazy-loaded atrás de dynamic(() => import('./BidPanel'), { ssr: false }). Só faz sentido client-side 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 tende a ser mais velho (como acontece em leilões de antiguidades), importa muito mais do que você esperaria.BidPanelcomponent lazy-loaded behinddynamic(() => import('./BidPanel'), { ssr: false }). It only makes sense client-side anyway, and it keeps your initial HTML payload lean for users on slow connections — which, if your auction audience skews older (as antique auctions tend to), matters more than you'd expect.
---
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 de perfis, padronizado como false. Após inscrição, o usuário vê uma tela "Complete seu registro". Uma vez aprovado (manualmente pelo admin, ou automaticamente após autorização de pagamento no Stripe), você muda a flag. A política RLS em bids verifica isso. Eles podem navegar, assistir, mas não fazer ofertas até serem verificados.verified_bidderboolean on theprofilestable, defaulting tofalse. After sign-up, the user sees a "Complete your registration" screen. Once approved (manually by admin, or automatically after a Stripe payment authorisation), you flip the flag. The RLS policy on bids checks it. They can browse, watch, but not bid until verified.
Para retenções de autorização de pagamento no Stripe, usar payment intents do Stripe com capture_method: manual é a abordagem correta — você autoriza uma retenção de £50, captura se ganharem, libera se não ganharem. Isso reduz drasticamente situações de não-pagamento que, acredite em mim, são o flagelo da existência de todo operador de leilão online.Stripe's payment intents with capture_method: manualis the right approach — you authorise a £50 hold, capture it if they win, release it if they don't. This dramatically reduces no-pay situations which, trust me, are the bane of every online auction operator's existence.
---
Deployment, Performance e os Detalhes Que Vão Morder Você
Deploy na Vercel — é a escolha óbvia para Next.js e a rede edge funciona bem com a infraestrutura global do Supabase. Certifique-se de que seu projeto Supabase está na região AWS mais próxima da 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 Supabase em eu-west-2. Escolha uma região, coloque os dois lá.Vercel— it's the obvious choice for Next.js and the edge network plays well with Supabase's global infrastructure. Make sure your Supabase project is in the AWS region closest to your Vercel deployment region. I've seen 40–60ms of completely unnecessary latency because someone deployed Vercel inus-east-1and Supabase ineu-west-2. Pick one region, put both there.
Algumas coisas que vão causar dor se você não lidar com elas de frente:
- Limites de conexão WebSocket. O tier gratuito do Supabase permite cerca de 200 conexões Realtime simultâneas. Se seu leilão viralizar, esse limite importa. Verifique seu plano.Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
- UI otimista para lances. Mostre o lance imediatamente na tela do leiloeiro antes do servidor confirmar. Se falhar (superado, race condition), reverta com um erro. O round-trip do servidor de 200–300ms é imperceptível a menos que a UI espere por ele.Show the bid immediately on the bidder's screen before the server confirms. If it fails (outbid, race condition), revert with an error. The 200–300ms server round-trip is imperceptibleunlessthe UI waits for it.
- Período de graça para encerramento de lote. Nunca feche um lote exatamente no ends_at. Dê a ele um buffer de 2–3 segundos no lado do servidor para permitir que lances em trânsito que foram enviados pouco antes do prazo sejam processados. Lide com isso em sua função agendada close_lot.Never close a lot exactly at
ends_at. Give it a 2–3 second server-side buffer to allow in-flight bids that were submitted just before the deadline to process. Handle this in yourclose_lotscheduled function. - 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 de suas rotas de API Next.js — elas podem expirar, e os participantes do leilão ficam genuinamente irritados se as notificações não forem confiáveis.Use Supabase Edge Functions with Resend or Postmark to send "You've been outbid" and "You won!" emails. Don't try to do this from your Next.js API routes — they can time out, and auction participants get genuinely annoyed if notifications are unreliable.
---
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 superiores 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 lote através de um único canal no lado do servidor em vez de assinaturas por usuário, e veja o recurso Supabase Realtime Broadcast que é mais eficiente para cenários de alto fan-out.Realtime Broadcastfeature which is more efficient for high fan-out scenarios.
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ó recorreria a Ably ou Pusher se você precisasse de latência inferior a 50ms globalmente, ou se estivesse construindo algo com milhões de conexões simultâneas. Um leilão de antiguidades, uma arrecadação para caridade, a venda online de uma pequena galeria de arte — Supabase lida com tudo isso perfeitamente.
O que acontece se a conexão WebSocket de um usuário cair no meio do leilão?
O SDK cliente do Supabase tentará se reconectar automaticamente. Mas você deve sempre fazer uma nova busca do estado atual do lote (current_bid, ends_at) na reconexão em vez de confiar no que estava no estado local antes da queda. Adicione um listener de evento online/offline no seu Client Component e dispare uma busca do servidor quando a conexão se restaurar.current_bid,ends_at) on reconnection rather than trusting whatever was in local state before the drop. Add anonline/offlineevent listener in your Client Component and trigger a fresh server fetch when the connection restores.
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 /api/bid dedicada. O tradeoff é que Server Actions são um pouco mais difíceis de fazer rate-limit individualmente (você aplicaria rate limiting no nível de middleware em vez de por ação). Para um site de leilão em produção, eu adicionaria rate limiting com Upstash Redis no middleware para prevenir que um único usuário faça spam de requisições de lances independentemente de você usar Actions ou rotas API./api/bidroute. The tradeoff is that Server Actions are a bit harder to rate-limit individually (you'd apply rate limiting at the middleware level rather than per-action). For a production auction site, I'd addUpstash Redisrate limiting in middleware to prevent a single user from spamming bid requests regardless of whether you use Actions or API routes.
Como faço para lidar com empates — dois lances idênticos no mesmo momento?
O lock FOR UPDATE na função Postgres place_bid serializa lances simultâneos, então tecnicamente empates não podem acontecer no nível do banco de dados. Um terá sucesso, o outro falhará com uma resposta "bid too low" (já que ambos são iguais a current_bid e a verificação é p_amount <= v_lot.current_bid). Primeiro a chegar, primeiro a ser servido. É a prática padrão de leilão e a maioria dos leiloadores entende isso.FOR UPDATElock in theplace_bidPostgres function serialises concurrent bids, so technically ties can't happen at the database level. One will succeed, the other will fail with a "bid too low" response (since both are equal tocurrent_bidand the check isp_amount <= v_lot.current_bid). First in, first served. That's standard auction practice and most bidders understand it.
---
O antiquário de Bath, para ser justo, tem executado seus leilões mensais online por mais de dois anos. Pico de leiloadores simultâneos numa noite de sábado chegou a 84 — aparentemente toda sua vila sintonizando para assistir a um lote de prata georgiana disputado ser vendido por três vezes seu valor mínimo. Supabase não piscou. Next.js não piscou. A única coisa que quebrou foi o Wi-Fi dele, porque ele estava rodando tudo 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 atômica de lance está no lugar, o resto é principalmente encanamento. 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 condições de corrida à meia-noite.
