Un cliente me llamó un jueves por la tarde en 2021 — vendedor de antigüedades con base en Bath, quería llevar sus subastas mensuales presenciales al formato en línea. Lo bastante simple, pensé. Luego dijo "y las pujas necesitan actualizarse para todos los que estén viendo, en vivo, sin refrescar la página". Claro. Fue entonces cuando un "simple trabajo de WordPress" se convirtió en una conversación de arquitectura de dos semanas.WordPress job" turned into a two-week architecture conversation.
Punto clave: Las pujas en vivo en Next.js más Supabase dependen de canales en tiempo real, seguridad a nivel de fila y validación de pujas del lado del servidor; implementa correctamente la máquina de estados antes de la interfaz.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.
He construido más de 12,000 sitios en Seahawk Media, y las funciones en tiempo real son las que te muerden si no las planificas adecuadamente desde el principio. Hacer polling cada cinco segundos suena bien hasta que tienes 200 pujadores golpeando un solo endpoint simultáneamente y tu factura de hosting se duplica de la noche a la mañana. Así que déjame mostrarte exactamente cómo construiría una plataforma de subastas en vivo adecuada hoy — usando Next.js y Supabase — basado en lo que realmente he implementado.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 Qué Next.js y Supabase para Esto Específicamente
Mira, hay una docena de formas de hacer tiempo real. Socket.io en un servidor Node, Ably, Pusher, Firebase — he usado todos en varios puntos. Pero la combinación Next.js + Supabase se merece su lugar aquí por una razón específica: Supabase Realtime está construido sobre la replicación lógica de PostgreSQL, lo que significa que tus actualizaciones de pujas en vivo y tu capa de datos persistentes son el mismo sistema. Sin sincronizar dos fuentes de verdad. Sin preguntarte si una puja que entró en el WebSocket también llegó a la base de datos.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 también te da Auth, Row Level Security y Storage listos para usar. Para un sitio de subastas, donde "solo el dueño de la subasta puede cerrar un lote" y "un usuario no puede pujar en su propio artículo" son reglas de negocio reales, las políticas RLS en Postgres son genuinamente la herramienta correcta.
Y Next.js porque — honestamente — App Router con Server Components significa que puedes renderizar el catálogo de subastas estáticamente, mantener feliz el SEO, e hidratar solo el widget de pujas en tiempo real en el cliente. Esa división importa. No quieres pagar por renderizado dinámico en una página que es 90% contenido estático.
---
Diseñar el Schema Primero (No te lo Saltes)
Aquí es donde la mayoría de la gente se apresura y se arrepiente después. Pasé tres días vergonzosos refactorizando el schema del cliente de antigüedades Bath a mitad del proyecto porque no había pensado bien el modelo de historial de pujas.
Aquí está la estructura central que uso ahora:
- `profiles` — extiende auth.users de Supabase, almacena el nombre para mostrar, la bandera de pujador verificado, y un credit_balance si estás haciendo pujas basadas en depósito -- extends Supabase's
auth.users, stores display name, verified bidder flag, and acredit_balanceif you're doing deposit-based bidding - `auctions` — el evento en sí; starts_at, ends_at, status (draft | live | closed), y created_by -- the event itself;
starts_at,ends_at,status(draft | live | closed), andcreated_by - `lots` — items individuales dentro de una subasta; reserve_price, current_bid, current_bidder_id, lot_number, ends_at (los lotes pueden tener contadores individuales) -- individual items within an auction;
reserve_price,current_bid,current_bidder_id,lot_number,ends_at(lots can have individual countdowns) - `bids` — registro de solo adición e inmutable; lot_id, bidder_id, amount, placed_at. Nunca actualices esta tabla. Nunca. -- immutable append-only log;
lot_id,bidder_id,amount,placed_at. Never update this table. Ever. - `auction_participants` — una tabla de unión que rastrea quién se ha registrado para qué subasta (útil para retenciones de depósito y segmentación de notificaciones) -- a join table tracking who has registered for which auction (useful for deposit holds and notification targeting)
Las columnas current_bid y current_bidder_id en lots están desnormalizadas intencionalmente. Sí, podrías derivarlas de la tabla bids en cada lectura, pero bajo carga concurrente esa consulta se vuelve cara rápidamente. Desnormaliza, mantén la tabla bids como tu registro de auditoría, y usa una función Postgres para actualizar lots atómicamente cuando se acepta una puja.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.
La Función de Puja Atómica
Este es el punto que la mayoría de tutoriales se saltan. Las condiciones de carrera en subastas son reales. Dos usuarios enviando £520 en el mismo milisegundo -- ¿qué pasa?real. Two users submitting £520 at the same millisecond -- what happens?
La respuesta es una función Postgres con bloqueo FOR UPDATE en la fila 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; ```
Llama esto desde tu ruta API de Next.js vía supabase.rpc('place_bid', {...}). El bloqueo FOR UPDATE significa que solo una transacción gana por lot en cualquier momento dado. La otra obtiene un error de serialización y devuelves un mensaje amistoso "alguien acaba de superarte" en el 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 -- La Capa de Reglas de Subasta
RLS es una de esas cosas que los desarrolladores aman inmediatamente o evitan porque se siente opaca. Yo estaba en el campamento de evitación hasta que un proyecto fintech en Seahawk me enseñó por las malas que forzar control de acceso solo en código de aplicación está a un route de API mal configurado de ser un desastre.
Para un sitio de subastas, aquí están las políticas que importan:
- Cualquiera puede leer lotes activos -- SELECT en lotes donde auctions.status = 'live' --
SELECTonlotswhereauctions.status = 'live' - Solo los postores autenticados y verificados pueden insertar pujas -- verificar profiles.verified_bidder = true en la política -- check
profiles.verified_bidder = truein the policy - Solo el creador de la subasta puede actualizar el estado del lote -- UPDATE en lotes donde auctions.created_by = auth.uid() --
UPDATEonlotswhereauctions.created_by = auth.uid() - El historial de pujas es legible por el creador de la subasta del lote y el propio postor -- nadie más necesita ver el historial completo de pujas en tiempo real -- no one else needs to see full bid history in real time
La documentación de RLS de Supabase es genuinamente buena aquí -- vale la pena leer la sección sobre funciones de security definer, porque interactúa con cómo funcionan las llamadas RPC como place_bid.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.
Una trampa: si usas security definer en tu función de Postgres (como arriba), se ejecuta con los privilegios del propietario de la función, saltándose RLS. Eso es intencional -- quieres que la colocación de la puja se salte el RLS del postor para poder bloquear y actualizar la fila del lote. Pero significa que debes reforzar tus propias verificaciones de lógica de negocio dentro de la función, lo que el código anterior hace.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 en Next.js
Aquí es donde realmente se vuelve satisfactorio. Supabase Realtime te permite suscribirte a cambios en una tabla Postgres usando WebSockets por debajo, y el SDK del cliente lo hace casi vergonzosamente simple.WebSockets underneath, and the client SDK makes it almost embarrassingly simple.
En tu página de lote de subasta -- un Client Component en Next.js App Router -- harías algo así:
``` '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>Oferta actual: £{currentBid.toLocaleString()}</div> } ```
Pasa initialBid desde un Server Component que obtiene datos frescos en el momento de la solicitud. El cliente entonces toma el control, escuchando eventos UPDATE en esa fila de lote específica. Cada vez que place_bid se ejecuta exitosamente, Supabase transmite el cambio y la interfaz de cada postor conectado se actualiza en aproximadamente 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.
Manejo del Temporizador de Cuenta Regresiva
Los lotes generalmente tienen una cuenta regresiva — "cierra en 3:42". No confíes en el reloj del cliente para esto. Deriva la hora de fin de lots.ends_at (almacenada en UTC en Postgres) y calcula los segundos restantes en el cliente usando Date.now(). Re-sincroniza cada 60 segundos con una nueva búsqueda en caso de desviación. Y añade lógica de "cierre suave": si una puja llega en los últimos 60 segundos, extiende ends_at dos minutos. Es el comportamiento estándar de subastas y los pujadores lo esperan.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.
---
Arquitectura de App Router de Next.js para la UI de Subastas
La estructura de página que usaría:
``app/ auctions/ page.tsx ← Server Component, lista subastas activas (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, obtiene la lista de lotes del lado del servidor LotGrid.tsx ← Client Component, se suscribe a cambios de estado del lote [lotId]/ page.tsx ← Server Component, datos iniciales del lote + metadatos para SEO BidPanel.tsx ← Client Component, exhibición de pujas en tiempo real + formulario de puja``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``
El catálogo (/auctions) usa Incremental Static Regeneration con revalidación cada 60 segundos. Las páginas de lotes individuales se renderizan del lado del servidor en la primera carga (para compartir, previsualizar, generar og:image), luego se ceden a componentes del cliente para las cosas en vivo./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.
Una cosa que siempre hago: mantener el componente BidPanel cargado de forma perezosa detrás de dynamic(() => import('./BidPanel'), { ssr: false }). Solo tiene sentido en el cliente de todas formas, y mantiene tu payload HTML inicial ligero para usuarios en conexiones lentas -- lo cual, si tu audiencia de subastas tiende a ser mayor (como suele suceder con subastas de antigüedades), importa más de lo que esperarías.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.
---
Autenticación y el Flujo de "Postor Verificado"
La autenticación estándar de Supabase con email/contraseña o magic link funciona bien para el registro. Pero las subastas a menudo necesitan un paso extra: verificación de postor. Podrías necesitar un bloqueo de tarjeta de crédito, verificación de identidad, o simplemente aprobación del administrador antes de que alguien pueda colocar una puja.
El patrón que uso: un booleano verified_bidder en la tabla profiles, con valor por defecto false. Después del registro, el usuario ve una pantalla "Completa tu registro". Una vez aprobado (manualmente por administrador, o automáticamente después de una autorización de pago en Stripe), cambias la bandera. La política RLS en bids la verifica. Pueden explorar, observar, pero no pujar hasta estar 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 retenciones de autorización de pago en Stripe, los payment intents de Stripe con capture_method: manual es el enfoque correcto -- autorizas una retención de £50, la capturas si ganan, la liberas si no. Esto reduce drásticamente las situaciones de no pago, que, créeme, son la pesadilla de todo operador de subastas en línea.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.
---
Despliegue, Rendimiento y los Detalles que te Morderán
Despliega en Vercel -- es la opción obvia para Next.js y la red edge funciona bien con la infraestructura global de Supabase. Asegúrate de que tu proyecto Supabase esté en la región AWS más cercana a tu región de despliegue de Vercel. He visto 40-60ms de latencia completamente innecesaria porque alguien desplegó Vercel en us-east-1 y Supabase en eu-west-2. Elige una región, coloca ambos ahí.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.
Hay algunas cosas que te causarán problemas si no las manejas desde el principio:
- Límites de conexión WebSocket. El tier gratuito de Supabase permite alrededor de 200 conexiones Realtime concurrentes. Si tu subasta se vuelve viral, ese límite importa. Verifica tu plan.Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
- IU optimista para pujas. Muestra la puja inmediatamente en la pantalla del pujador antes de que el servidor la confirme. Si falla (superada, condición de carrera), revierte con un error. El viaje de ida y vuelta del servidor de 200-300ms es imperceptible a menos que la IU espere por él.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 gracia para cierre de lote. Nunca cierres un lote exactamente en ends_at. Dale un búfer de 2-3 segundos en el servidor para permitir que las pujas en vuelo que se enviaron justo antes de la fecha límite se procesen. Maneja esto en tu función programada 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. - Notificaciones por correo electrónico. Usa Supabase Edge Functions con Resend o Postmark para enviar correos "Te han superado" y "¡Ganaste!". No intentes hacer esto desde tus rutas de API de Next.js -- pueden agotarse, y los participantes de la subasta se molestan genuinamente si las notificaciones son poco confiables.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
¿Cuántos pujadores concurrentes puede manejar Supabase Realtime?
El plan Pro de Supabase admite hasta 500 conexiones Realtime concurrentes por defecto, con límites superiores disponibles. Para la mayoría de sitios de subastas -- a menos que estés ejecutando algo del tamaño de Sotheby's en línea -- es más que suficiente. Si esperas miles de espectadores simultáneos, considera transmitir actualizaciones de lotes a través de un único canal del lado del servidor en lugar de suscripciones por usuario, y revisa la característica Realtime Broadcast de Supabase que es más eficiente para escenarios de alto alcance.Realtime Broadcast feature which is more efficient for high fan-out scenarios.
¿Debo usar Supabase Realtime o un servicio dedicado como Ably?
Para la mayoría de proyectos, Supabase Realtime es perfectamente adecuado y la integración es mucho más simple ya que tus datos ya están en Supabase. Solo recurriría a Ably o Pusher si necesitas latencia sub-50ms globalmente, o si estás construyendo algo con millones de conexiones concurrentes. Una subasta de antigüedades, una recaudación benéfica, la venta en línea de una pequeña galería de arte -- Supabase maneja todo esto bien.
¿Qué pasa si la conexión WebSocket de un usuario se cae en mitad de la subasta?
El SDK cliente de Supabase intentará reconectarse automáticamente. Pero siempre debes volver a obtener el estado actual del lote (current_bid, ends_at) en la reconexión en lugar de confiar en lo que estaba en el estado local antes de la caída. Agrega un escuchador de evento online/offline en tu Client Component y dispara una búsqueda de servidor fresca cuando la conexión se restablezca.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.
¿Puedo usar Next.js Server Actions para colocar pujas en lugar de una ruta API?
Sí, y lo he hecho. Server Actions en Next.js 14 son convenientes -- eliminan el boilerplate de una ruta dedicada /api/bid. La compensación es que Server Actions son un poco más difíciles de limitar por velocidad individualmente (aplicarías limitación de velocidad a nivel de middleware en lugar de por acción). Para un sitio de subasta en producción, añadiría limitación de velocidad Redis de Upstash en middleware para evitar que un usuario inunde solicitudes de pujas sin importar si usas Actions o rutas 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.
¿Cómo manejo los empates -- dos ofertas idénticas al mismo tiempo?
El bloqueo FOR UPDATE en la función place_bid de Postgres serializa pujas concurrentes, así que técnicamente los empates no pueden ocurrir a nivel de base de datos. Una tendrá éxito, la otra fallará con una respuesta "puja demasiado baja" (ya que ambas son iguales a current_bid y la verificación es p_amount <= v_lot.current_bid). Primero en llegar, primero en ser servido. Esa es la práctica estándar de subastas y la mayoría de los postores la entienden.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.
---
El anticuario de Bath, para ser honesto, ha estado ejecutando sus subastas mensuales en línea durante más de dos años. El pico de pujadores concurrentes una noche de sábado llegó a 84 -- aparentemente todo su pueblo sintonizando para ver un lote disputado de plata georgiana venderse por tres veces su precio de reserva. Supabase no se inmutó. Next.js no se inmutó. Lo único que falló fue su Wi-Fi, porque lo estaba ejecutando desde el piso de la tienda.
El tiempo real es difícil de pensar de antemano, pero una vez que el esquema es sólido y la función de puja atómica está en su lugar, el resto es principalmente plomería. Consigue la fundación correcta y pasarás tu tiempo en las partes divertidas -- las animaciones de cuenta regresiva, la UX de "va una vez, va dos veces" -- en lugar de depurar condiciones de carrera a la medianoche.
