Un cliente me llamó una tarde de jueves en 2021 — comerciante de antigüedades con base en Bath, quería llevar sus subastas mensuales presenciales al formato en línea. Lo suficientemente simple, pensé. Luego dijo "y las pujas necesitan actualizarse para todos los que están mirando, en vivo, sin recargar la página." Claro. Ahí fue cuando un "simple trabajo de WordPress" se convirtió en una conversación de arquitectura de dos semanas.
He construido más de 5,000 sitios en Seahawk Media, y las características en tiempo real son las que te muerden si no las planificas correctamente desde el inicio. Hacer polling cada cinco segundos suena bien hasta que tienes 200 pujadores golpeando un único 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 — basándome 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 — usingNext.jsandSupabase— 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 momentos. 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 persistente 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 contento 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 los auth.users de Supabase, almacena nombre de usuario, bandera de pujador verificado, y un credit_balance si haces 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` — artículos individuales dentro de una subasta; reserve_price, current_bid, current_bidder_id, lot_number, ends_at (los lotes pueden tener cuentas regresivas 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 añadir, 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 registra quién se ha registrado para cada subasta (útil para retenciones de depósitos y direccionamiento 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 costosa rápidamente. Desnormalízalas, mantén la tabla bids como tu registro de auditoría, y usa una función de Postgres para actualizar lots atómicamente cuando se acepta una puja.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.
La Función de Puja Atómica
Esto es lo 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é sucede?real. Two users submitting £520 at the same millisecond — what happens?
La respuesta es una función de Postgres con bloqueo FOR UPDATE en la fila del lote: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; ```
Llama esto desde tu ruta API de Next.js via supabase.rpc('place_bid', {...}). El bloqueo FOR UPDATE significa que solo una transacción gana por lote en cualquier momento. La otra recibe un error de serialización y retornas un mensaje amigable "alguien acaba de superarte" en el 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 — La capa de reglas de subastas
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 en vivo — SELECT en lots donde auctions.status = 'live'—
SELECTonlotswhereauctions.status = 'live' - Solo postores autenticados y verificados pueden insertar pujas — verifica 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 lots donde auctions.created_by = auth.uid()—
UPDATEonlotswhereauctions.created_by = auth.uid() - El historial de pujas es legible solo para quien creó la subasta del lote y el postor mismo — 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 security definer, porque interactúa con cómo funcionan las llamadas RPC como place_bid.Supabase RLS documentationis genuinely good here — worth reading the section on security definer functions, because it interacts with how RPC calls likeplace_bidwork.
Una trampa: si usas security definer en tu función Postgres (como arriba), se ejecuta con los privilegios del propietario de la función, omitiendo RLS. Eso es intencional — quieres que la colocación de puja omita el RLS del postor para que pueda bloquear y actualizar la fila del lote. Pero significa que debes aplicar tus propias verificaciones de lógica de negocio dentro de la función, que el código de arriba hace.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 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 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>Oferta actual: £{currentBid.toLocaleString()}</div> } ```
Pasa initialBid desde un Server Component que obtiene datos frescos en el momento de la solicitud. El cliente luego 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 UI de cada postor conectado se actualiza típicamente en 100–300ms.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.
Manejo del Temporizador de Cuenta Regresiva
Los lotes usualmente tienen una cuenta regresiva — "cierra en 3:42". No confíes en el reloj del cliente para esto. Deriva la hora de cierre de lots.ends_at (almacenado en UTC en Postgres) y calcula los segundos restantes en el cliente usando Date.now(). Re-sincroniza cada 60 segundos con una obtención fresca en caso de desvío. Y agrega lógica de "cierre suave": si llega una oferta en los últimos 60 segundos, extiende ends_at por dos minutos. Es el comportamiento estándar de subastas y los postores lo esperan.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.
---
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 en vivo (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, obtiene lista de lotes del lado del servidor LotGrid.tsx ← Client Component, se suscribe a cambios de estado de lotes [lotId]/ page.tsx ← Server Component, datos iniciales de lote + metadatos para SEO BidPanel.tsx ← Client Component, visualización de oferta en tiempo real + formulario 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``
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 }). De todas formas solo tiene sentido del lado del cliente, y mantiene tu payload inicial de HTML delgado para usuarios con conexiones lentas — lo cual, si tu audiencia de subastas tiende a ser mayor (como ocurre con las subastas de antigüedades), importa más de lo que esperarías.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.
---
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 de perfiles, por defecto false. Después del registro, el usuario ve una pantalla "Completa tu registro". Una vez aprobado (manualmente por el administrador, o automáticamente después de una autorización de pago en Stripe), cambias el indicador. La política RLS en las pujas lo verifica. Pueden explorar, observar, pero no pujar hasta estar 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 bloqueos de autorización de pago en Stripe, los payment intents de Stripe con capture_method: manual es el enfoque correcto — autorizas un bloqueo de £50, lo capturas si ganan, lo liberas si no. Esto reduce dramáticamente las situaciones de no pago que, créeme, son la perdición de la existencia de todo operador de subasta en línea.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.
---
Despliegue, Rendimiento y los Detalles que te Morderán
Despliega a 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 de Supabase esté en la región de 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, pon ambos allí.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.
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 viraliza, ese límite importa. Revisa tu plan.Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
- UI 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 UI lo espere.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 gracia al cerrar un lote. Nunca cierres un lote exactamente en ends_at. Dale un buffer de 2–3 segundos del lado del servidor para permitir que las pujas en tránsito que fueron enviadas 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 email. Usa Supabase Edge Functions con Resend o Postmark para enviar emails de "Te han superado" y "¡Ganaste!". No intentes hacer esto desde tus rutas API de Next.js — pueden agotar el tiempo, y los participantes en 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 soporta hasta 500 conexiones Realtime concurrentes por defecto, con límites más altos disponibles. Para la mayoría de sitios de subastas — a menos que estés ejecutando algo del tamaño de Sotheby's online — eso 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 función Realtime Broadcast de Supabase que es más eficiente para escenarios de alto fan-out.Realtime Broadcastfeature 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ías a Ably o Pusher si necesitas latencia global menor a 50ms, o si estás construyendo algo con millones de conexiones concurrentes. Una subasta de antigüedades, una recaudación de fondos benéfica, la venta online 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 deberías volver a traer el estado actual del lote (current_bid, ends_at) en la reconexión en lugar de confiar en lo que estuviera en el estado local antes de la caída. Añade un event listener de online/offline en tu Client Component y dispara una búsqueda fresca del servidor cuando la conexión se restaure.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.
¿Puedo usar Next.js Server Actions para colocar pujas en lugar de una ruta API?
Sí, y lo he hecho. Las Server Actions en Next.js 14 son convenientes — eliminan el boilerplate de una ruta /api/bid dedicada. La contrapartida es que las Server Actions son un poco más difíciles de limitar de forma individual (aplicarías rate limiting a nivel de middleware en lugar de por acción). Para un sitio de subasta en producción, añadiría rate limiting con Upstash Redis en middleware para evitar que un único usuario inunde de solicitudes de pujas sin importar si usas Actions o rutas 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.
¿Cómo manejo los empates — dos pujas idénticas al mismo tiempo?
El bloqueo FOR UPDATE en la función place_bid de Postgres serializa las pujas concurrentes, así que técnicamente los empates no pueden suceder 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 servido. Esa es la práctica estándar en subastas y la mayoría de los pujadores la entienden.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.
---
El comerciante de antigüedades de Bath, para que conste, ha estado ejecutando sus subastas mensuales online durante más de dos años. El número máximo de pujadores concurrentes una noche de sábado alcanzó 84 — aparentemente todo su pueblo sintonizado 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 se rompió fue su Wi-Fi, porque lo estaba ejecutando desde el piso de la tienda.
El tiempo real es difícil de pensar desde el principio, 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 tuberías. Consigue la base 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 las tres de la madrugada.
