< BACK Creando un Sitio de Subastas en Tiempo Real con Next.js y Supabase -- ilustración de arte lineal

Construir un Sitio de Subastas en Tiempo Real con Next.js y Supabase

Un cliente me llamó un jueves por la tarde en 2021, un vendedor de antigüedades de Bath, que quería llevar sus subastas mensuales presenciales al internet. Pensé que sería simple. Luego dijo: "y las pujas necesitan actualizarse para todos los que están mirando, en tiempo real, sin recargar la página." Claro. Fue ahí cuando un "simple trabajo de WordPress" se convirtió en una conversación de arquitectura de dos semanas.

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.

He construido más de 12,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 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 enviado a producción.

---

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 todas en varios momentos. Pero la combinación Next.js + Supabase se gana 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.

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 nombre para mostrar, bandera de pujador verificado, y un credit_balance si estás haciendo pujas basadas en depósito
  • `auctions`, el evento en sí; starts_at, ends_at, status (draft | live | closed), y created_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)
  • `bids`, registro inmutable de solo-agregar; lot_id, bidder_id, amount, placed_at. Nunca actualices esta tabla. Nunca.
  • `auction_participants`, una tabla de unión que rastrea quién se ha registrado para qué subasta (útil para retenciones de depósitos y segmentación de notificaciones)

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.

La Función de Puja Atómica

Este es el bit que la mayoría de tutoriales se saltan. Las condiciones de carrera en subastas son reales. Dos usuarios presentando £520 al mismo milisegundo, ¿qué sucede?

La respuesta es una función Postgres con bloqueo FOR UPDATE en la fila lot:

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

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

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

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

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

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

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

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.

---

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:

  1. Cualquiera puede leer lotes activos, SELECT en lots donde auctions.status = 'live'
  2. Solo los postores autenticados y verificados pueden insertar pujas, verifica profiles.verified_bidder = true en la política
  3. Solo el creador de la subasta puede actualizar el estado del lote, UPDATE en lots donde auctions.created_by = auth.uid()
  4. El historial de pujas es legible por el creador de la subasta del lote y por el mismo postor, nadie más necesita ver el historial completo de pujas en tiempo real

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.

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 enforcer tus propias verificaciones de lógica de negocio dentro de la función, que el código arriba hace.

---

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.

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()

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.

Manejo del Temporizador de Cuenta Regresiva

Los lotes usualmente tienen una cuenta regresiva, "se cierra en 3:42". No confíes en el reloj del cliente para esto. Deriva el tiempo de finalización 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 un fetch fresco 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 por dos minutos. Ese es el comportamiento estándar de subasta y los postores lo esperan.

---

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``

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.

Una cosa que siempre hago: mantener el componente BidPanel lazy-loaded detrás de dynamic(() => import('./BidPanel'), { ssr: false }). De todas formas solo tiene sentido del lado del cliente, 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 ocurrir en subastas de antigüedades), importa más de lo que esperarías.

---

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.

Para los 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 pesadilla de todo operador de subasta en línea.

---

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, pon ambos allí.

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.
  • 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.
  • 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.
  • Notificaciones por correo. Usa Supabase Edge Functions con Resend o Postmark para enviar correos "Te han superado la puja" y "¡Has ganado!". No intentes hacerlo desde tus rutas API de Next.js, pueden agotar el tiempo, y los participantes de la subasta se molestan genuinamente si las notificaciones no son confiables.

---

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 en línea, 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 explora la función Supabase Realtime Broadcast que es más eficiente para escenarios de alto fan-out.

¿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.

¿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. El tradeoff es que las Server Actions son un poco más difíciles de rate-limitear individualmente (aplicarías rate limiting a nivel de middleware en lugar de por-acción). Para un sitio de subasta en producción, yo agregaría rate limiting de Upstash Redis en middleware para evitar que un único usuario spamee solicitudes de puja sin importar si usas Actions o rutas API.

¿Cómo manejo empates, dos pujas 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.

---

El vendedor de antigüedades de Bath, para ser justos, ha estado ejecutando sus subastas mensuales en línea durante más de dos años. Los pujadores concurrentes máximos un sábado por la noche llegaron a 84, aparentemente todo su pueblo sintonizando para ver un lote disputado de plata georgiana venderse por tres veces su 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 de antemano, pero una vez que el schema es sólido y la función atómica de puja está en su lugar, el resto es mayormente plomería. Acierta la base y pasarás tu tiempo en las partes divertidas, las animaciones de cuenta atrás, la UX de "va una vez, va dos veces", en lugar de debuguear race conditions a media noche.

< BACK