realtime-auction-nextjs-supabase.html
< BACK Imagem hero para "Construindo um Site de Leilão em Tempo Real com Next.js & Supabase"

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

Um cliente me ligou numa quinta-feira à tarde em 2021 -- negociante de antiguidades baseado em Bath, queria colocar seus leilões mensais presenciais online. Simples o bastante, pensei. Aí ele disse "e os lances precisam ser atualizados para todo mundo assistindo, ao vivo, sem refresh da página." Certo. Foi quando um "simples trabalho WordPress" virou uma conversa de arquitetura de duas semanas.WordPress job" turned into a two-week architecture conversation.

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.Live bidding on Next.js plus Supabase hinges on realtime channels, row-level security, and server-side bid validation; get the state machine right before the UI.

Construí mais de 12.000 sites na Seahawk Media, e features em tempo real são aquelas 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 duplicar da noite pro dia. Então deixa eu 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 -- using Next.js and Supabase -- based on what I've actually shipped.

---

Por que Next.js e Supabase para Isso Especificamente

Olha, existem uma dúzia de maneiras de fazer real-time. Socket.io em um servidor Node, Ably, Pusher, Firebase -- usei todos 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ão estaticamente, manter SEO feliz, e só hidratar o widget de leilão em tempo real no client. Essa separaçã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, verified bidder flag, e um credit_balance se você está fazendo leilão baseado em depósito -- extends Supabase's auth.users, stores display name, verified bidder flag, and a credit_balance if 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), and 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) -- 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 append-only; lot_id, bidder_id, amount, placed_at. Nunca atualize essa 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 congelamento de depósitos 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 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.current_bid and current_bidder_id columns on lots are denormalised intentionally. Yes, you could derive them from the bids table on every read, but under concurrent load that query gets expensive fast. Denormalise it, keep the bids table as your audit log, and use a Postgres function to update lots atomically when a bid is accepted.

A Função de Lance Atômico

Este é o ponto 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 locking FOR UPDATE na linha do lot:FOR UPDATE locking 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 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.supabase.rpc('place_bid', {...}). The FOR UPDATE lock 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:

  1. Qualquer um pode ler lotes ao vivo -- SELECT em lots onde auctions.status = 'live' -- SELECT on lots where auctions.status = 'live'
  2. Apenas licitadores autenticados e verificados podem inserir lances -- verifique profiles.verified_bidder = true na política -- check profiles.verified_bidder = true in the policy
  3. Apenas o criador do leilão pode atualizar o status do lote -- UPDATE em lots onde auctions.created_by = auth.uid() -- UPDATE on lots where 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 -- 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 RLS do Supabase é genuinamente boa aqui -- vale a pena ler a seção sobre funções security definer, porque interage com como chamadas RPC como place_bid funcionam.Supabase RLS documentation is genuinely good here -- worth reading the section on security definer functions, because it interacts with how RPC calls like place_bid work.

Uma pegadinha: 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 do lance contorne o RLS do licitador para que possa trancar e atualizar a linha do lote. Mas significa que você deve reforçar suas próprias verificações de lógica de negócio dentro da função, que o código acima faz.security definer on your Postgres function (as above), it runs with the function owner's privileges, bypassing RLS. That's intentional -- you want the 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 é 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.WebSockets underneath, and the client SDK makes it almost embarrassingly simple.

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()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.initialBid from a Server Component that fetches fresh data at request time. The client then takes over, listening for UPDATE events on that specific lot row. Every time place_bid runs 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 de término 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 caso haja desvio. E adicione lógica de "fechamento suave": se um lance chegar nos últimos 60 segundos, estenda ends_at por dois minutos. É o comportamento padrão de leilão e os licitadores esperam isso.not trust the client clock for this. Derive the end time from lots.ends_at(stored in UTC in Postgres) and calculate remaining seconds on the client using Date.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, extend ends_at by 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 oferta em tempo real + formulário de oferta``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: mantenha o componente BidPanel com lazy-loading por trás de dynamic(() => import('./BidPanel'), { ssr: false }). Faz sentido apenas no lado do 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 tende para mais velho (como leilões de antiguidades costumam ter), importa mais do que você esperaria.BidPanel component lazy-loaded behind dynamic(() => 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 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.verified_bidder boolean on the profiles table, defaulting to false. 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 autorizações de retenção de pagamento no Stripe, payment intents do Stripe com capture_method: manual é a abordagem certa -- você autoriza uma retenção de £50, captura se ganhar, libera se não ganhar. Isso reduz dramaticamente situações de não-pagamento que, acredite, são o pesadelo de todo operador de leilão online.Stripe's payment intents with capture_method: manual is 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ê

Implante no Vercel -- é a escolha óbvia para Next.js e a rede edge se integra bem com a infraestrutura global do Supabase. Certifique-se de que seu projeto Supabase está na região AWS mais próxima de sua região de implantação Vercel. Já vi latência desnecessária de 40-60ms porque alguém implantou Vercel em us-east-1 e Supabase em eu-west-2. Escolha uma região, coloque ambos 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 in us-east-1 and Supabase in eu-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 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.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 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.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 imperceptible unless the UI waits for it.
  • 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.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 your close_lot scheduled function.
  • Notificações por email. Use Supabase Edge Functions com Resend ou Postmark para enviar emails "Você foi superado" e "Você ganhou!". Não tente fazer isso de suas rotas de API do Next.js -- elas podem expirar, e participantes de leilões ficam genuinamente irritados se notificações forem não 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 maiores disponíveis. Para a maioria dos sites de leilão -- a menos que você esteja executando 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 do lado do servidor em vez de subscrições por usuário, e veja o recurso Supabase Realtime Broadcast que é mais eficiente para cenários de alto fan-out.Realtime Broadcast feature 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, um arrecadador de fundos beneficente, a 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.current_bid,ends_at) on reconnection rather than trusting whatever was in local state before the drop. Add an online/offline event 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 fiz isso. Server Actions no Next.js 14 são convenientes -- eliminam o boilerplate de uma rota dedicada /api/bid. O tradeoff é que Server Actions são um pouco mais difíceis de rate-limitar individualmente (você aplicaria rate limiting no nível de middleware em vez de por-action). Para um site de leilão em produção, eu adicionaria rate limiting com Upstash Redis em middleware para evitar que um único usuário faça spam de requisições de lance, independentemente de você usar Actions ou rotas de API./api/bid route. 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 add Upstash Redis rate 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 ao mesmo tempo?

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.FOR UPDATE lock in the place_bid Postgres 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 to current_bid and the check is p_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 franco, vem executando seus leilões mensais online há mais de dois anos. O pico de lançadores simultâneos em uma noite de sábado chegou a 84 -- aparentemente toda a aldeia dele assistindo a um lote disputado de prata georgiana ser vendido por três vezes sua reserva. Supabase não tremeu. Next.js não tremeu. A única coisa que quebrou foi o Wi-Fi dele, porque ele estava executando tudo do piso da loja.

Real-time é difícil de pensar antecipadamente, mas uma vez que o schema está sólido e a função de lance atômico está implementada, o resto é principalmente encanamento. Acerte a base e você gastará seu tempo nos bits divertidos -- as animações de contagem regressiva, a UX de "indo uma vez, indo duas vezes" -- em vez de debugar race conditions à meia-noite.

< BACK