Un client m'a appelé un jeudi après-midi en 2021 — antiquaire basé à Bath, voulait mettre en ligne ses enchères mensuelles en personne. Assez simple, pensais-je. Puis il a dit « et les enchères doivent se mettre à jour pour tout le monde qui regarde, en direct, sans actualisation de page ». D'accord. C'est à ce moment qu'un « simple travail WordPress » s'est transformé en deux semaines de conversation architecturale.
J'ai construit plus de 5 000 sites chez Seahawk Media, et les fonctionnalités en temps réel sont celles qui vous mordent si vous ne les planifiez pas correctement dès le départ. Faire du polling toutes les cinq secondes semble correct jusqu'à ce que vous ayez 200 enchérisseurs frappant simultanément un seul point de terminaison et que votre facture d'hébergement double du jour au lendemain. Alors laissez-moi vous montrer exactement comment je construirais une plateforme d'enchères en direct appropriée aujourd'hui — en utilisant Next.js et Supabase — en fonction de ce que j'ai réellement livré.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.
---
Pourquoi Next.js et Supabase pour cela spécifiquement
Écoutez, il y a une douzaine de façons de faire du temps réel. Socket.io sur un serveur Node, Ably, Pusher, Firebase — j'ai tous utilisés à différents moments. Mais la combinaison Next.js + Supabase mérite sa place ici pour une raison spécifique : Supabase Realtime est construit sur la réplication logique de PostgreSQL, ce qui signifie que vos mises à jour d'enchères en direct et votre couche de données persistantes sont le même système. Pas de synchronisation de deux sources de vérité. Pas de doute sur le fait qu'une enchère qui est allée dans WebSocket a également atterri dans la base de données.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 vous donne aussi l'authentification, la sécurité au niveau des lignes (RLS) et le stockage d'emblée. Pour un site d'enchères, où « seul le propriétaire de l'enchère peut clôturer un lot » et « un utilisateur ne peut pas enchérir sur son propre article » sont de véritables règles métier, les politiques RLS dans Postgres sont vraiment l'outil qu'il faut.
Et Next.js parce que — honnêtement — l'App Router avec les Server Components signifie que vous pouvez afficher le catalogue des enchères en statique, garder le SEO content, et hydrater uniquement le widget d'enchères en temps réel côté client. Cette distinction compte. Vous ne voulez pas payer pour un rendu dynamique sur une page qui est 90% du contenu statique.
---
Concevoir le schéma en premier (Ne le sautez pas)
C'est là que la plupart des gens se précipitent et le regrettent plus tard. J'ai passé trois jours embarrassants à refactoriser le schéma du client Bath antiques en plein projet parce que je n'avais pas bien réfléchi au modèle d'historique des enchères.
Voici la structure fondamentale que j'utilise maintenant :
- `profiles` — étend auth.users de Supabase, stocke le nom d'affichage, le drapeau d'enchérisseur vérifié, et un credit_balance si vous faites des enchères basées sur des dépôts— extends Supabase's
auth.users, stores display name, verified bidder flag, and acredit_balanceif you're doing deposit-based bidding - `auctions` — l'événement lui-même ; starts_at, ends_at, status (draft | live | closed), et created_by— the event itself;
starts_at,ends_at,status(draft | live | closed), andcreated_by - `lots` — articles individuels au sein d'une enchère ; reserve_price, current_bid, current_bidder_id, lot_number, ends_at (les lots peuvent avoir des comptes à rebours individuels)— individual items within an auction;
reserve_price,current_bid,current_bidder_id,lot_number,ends_at(lots can have individual countdowns) - `bids` — journal immuable en ajout seul ; lot_id, bidder_id, amount, placed_at. Ne mettez jamais à jour cette table. Jamais.— immutable append-only log;
lot_id,bidder_id,amount,placed_at. Never update this table. Ever. - `auction_participants` — une table de jonction suivant qui s'est inscrit pour quelle enchère (utile pour les blocages de dépôts et le ciblage des notifications)— a join table tracking who has registered for which auction (useful for deposit holds and notification targeting)
Les colonnes current_bid et current_bidder_id sur les lots sont dénormalisées intentionnellement. Oui, tu pourrais les dériver de la table bids à chaque lecture, mais sous charge concurrente cette requête devient chère rapidement. Dénormalise, garde la table bids comme journal d'audit, et utilise une fonction Postgres pour mettre à jour les lots atomiquement quand une enchère est acceptée.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 fonction d'enchère atomique
C'est la partie que la plupart des tutoriels sautent. Les conditions de course dans les enchères sont réelles. Deux utilisateurs soumettant £520 à la même milliseconde — que se passe-t-il ?real. Two users submitting £520 at the same millisecond — what happens?
La réponse est une fonction Postgres avec verrous FOR UPDATE sur la ligne de 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; ```
Appelez ceci depuis votre route API Next.js via supabase.rpc('place_bid', {...}). Le verrou FOR UPDATE signifie qu'une seule transaction gagne par lot à un moment donné. L'autre reçoit une erreur de sérialisation et vous retournez un message convivial « quelqu'un vient de vous surenchérir » sur le client.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 couche des règles d'enchères
RLS est l'une de ces choses que les développeurs adorent immédiatement ou évitent parce que ça semble opaque. J'étais dans le camp de l'évitement jusqu'à ce qu'un projet fintech chez Seahawk m'enseigne à la dure que forcer le contrôle d'accès uniquement dans le code d'application est à une route API mal configurée près du désastre.
Pour un site d'enchères, voici les politiques qui importent :
- Tout le monde peut lire les lots en direct — SELECT on lots where auctions.status = 'live'—
SELECTonlotswhereauctions.status = 'live' - Seuls les enchérisseurs authentifiés et vérifiés peuvent insérer des enchères — check profiles.verified_bidder = true dans la politique— check
profiles.verified_bidder = truein the policy - Seul le créateur de l'enchère peut mettre à jour le statut du lot — UPDATE on lots where auctions.created_by = auth.uid()—
UPDATEonlotswhereauctions.created_by = auth.uid() - L'historique des enchères est lisible par le créateur de la vente aux enchères du lot et par l'enchérisseur lui-même — personne d'autre n'a besoin de voir l'historique complet des enchères en temps réel— no one else needs to see full bid history in real time
La documentation Supabase RLS est vraiment bonne ici — cela vaut la peine de lire la section sur les fonctions security definer, parce qu'elle interagit avec la façon dont les appels RPC comme place_bid fonctionnent.Supabase RLS documentationis genuinely good here — worth reading the section on security definer functions, because it interacts with how RPC calls likeplace_bidwork.
Un piège : si vous utilisez security definer sur votre fonction Postgres (comme ci-dessus), elle s'exécute avec les privilèges du propriétaire de la fonction, contournant le RLS. C'est intentionnel — vous voulez que le placement de l'enchère contourne le RLS de l'enchérisseur afin qu'il puisse verrouiller et mettre à jour la ligne du lot. Mais cela signifie que vous devez appliquer vos propres vérifications de logique métier à l'intérieur de la fonction, ce que le code ci-dessus fait.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.
---
Configuration de Supabase Realtime dans Next.js
C'est là que ça devient vraiment satisfaisant. Supabase Realtime vous permet de vous abonner aux modifications d'une table Postgres en utilisant WebSockets en arrière-plan, et le SDK client rend cela presque ridiculement simple.WebSockets underneath, and the client SDK makes it almost embarrassingly simple.
Sur la page de votre lot de vente aux enchères — un Client Component dans Next.js App Router — vous feriez quelque chose comme :
``` '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> } ```
Passez initialBid depuis un Server Component qui récupère les données fraîches au moment de la requête. Le client prend ensuite le relais, écoutant les événements UPDATE sur cette ligne de lot spécifique. Chaque fois que place_bid s'exécute avec succès, Supabase diffuse le changement et l'interface de chaque enchérisseur connecté se met à jour en environ 100–300ms généralement.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.
Gérer le minuteur de compte à rebours
Les lots ont généralement un compte à rebours — « ferme dans 3:42 ». Ne faites pas confiance à l'horloge du client pour cela. Dérivez l'heure de fin de lots.ends_at (stockée en UTC dans Postgres) et calculez les secondes restantes sur le client en utilisant Date.now(). Resynchronisez-la tous les 60 secondes avec une nouvelle récupération en cas de décalage. Et ajoutez la logique de « fermeture progressive » : si une enchère arrive dans les 60 dernières secondes, prolongez ends_at de deux minutes. C'est le comportement standard des enchères et les enchérisseurs s'y attendent.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.
---
Architecture Next.js App Router pour l'interface d'enchères
La structure de page que j'utiliserais :
`` app/ auctions/ page.tsx ← Server Component, liste les enchères en direct (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, récupère la liste des lots côté serveur LotGrid.tsx ← Client Component, s'abonne aux changements de statut des lots [lotId]/ page.tsx ← Server Component, données initiales du lot + métadonnées pour le SEO BidPanel.tsx ← Client Component, affichage des enchères en temps réel + formulaire d'enchère ``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``
Le catalogue (/auctions) utilise l'Incremental Static Regeneration avec une revalidation de 60 secondes. Les pages de lots individuels se rendent côté serveur au premier chargement (pour le partage, l'aperçu, la génération og:image), puis sont confiées aux composants client pour les mises à jour en direct./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.
Une chose que je fais toujours : garder le composant BidPanel en chargement différé derrière dynamic(() => import('./BidPanel'), { ssr: false }). Cela n'a de sens que côté client de toute façon, et cela maintient votre charge utile HTML initiale légère pour les utilisateurs sur des connexions lentes — ce qui, si votre audience d'enchères penche vers les personnes plus âgées (comme c'est souvent le cas pour les enchères d'antiquités), compte bien plus que vous ne le penseriez.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.
---
L'authentification et le flux « Enchérisseur Vérifié »
L'authentification standard Supabase avec email/mot de passe ou lien magique fonctionne bien pour l'inscription. Mais les enchères nécessitent souvent une étape supplémentaire : la vérification de l'enchérisseur. Vous pourriez avoir besoin d'un blocage de carte de crédit, d'une vérification d'identité, ou simplement d'une approbation administrateur avant que quelqu'un puisse réellement placer une enchère.
Le modèle que j'utilise : un booléen verified_bidder sur la table des profils, défaut à faux. Après l'inscription, l'utilisateur voit un écran « Complétez votre inscription ». Une fois approuvé (manuellement par l'admin, ou automatiquement après une autorisation de paiement Stripe), vous basculez le drapeau. La politique RLS sur les enchères le vérifie. Ils peuvent parcourir, regarder, mais pas enchérir avant d'être vérifiés.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.
Pour les blocages d'autorisation de paiement Stripe, les intentions de paiement Stripe avec capture_method: manual est la bonne approche — vous autorisez un blocage de 50 £, le capturez s'ils gagnent, le libérez s'ils ne gagnent pas. Cela réduit dramatiquement les situations de non-paiement, qui, croyez-moi, sont le cauchemar de chaque opérateur d'enchères en ligne.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.
---
Déploiement, Performance et les Points qui vous Mordront
Déployez sur Vercel — c'est le choix évident pour Next.js et le réseau edge fonctionne bien avec l'infrastructure mondiale de Supabase. Assurez-vous que votre projet Supabase est dans la région AWS la plus proche de votre région de déploiement Vercel. J'ai vu 40–60ms de latence complètement inutile parce que quelqu'un a déployé Vercel en us-east-1 et Supabase en eu-west-2. Choisissez une région, mettez les deux là-dedans.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.
Quelques points qui vous causeront des problèmes si vous ne les traitez pas en amont :
- Limites de connexion WebSocket. La couche gratuite de Supabase autorise environ 200 connexions Realtime simultanées. Si votre enchère devient virale, ce plafond compte. Vérifiez votre plan.Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
- UI optimiste pour les enchères. Affichez l'enchère immédiatement sur l'écran de l'enchérisseur avant la confirmation du serveur. Si elle échoue (surenchère, condition de course), annulez-la avec une erreur. L'aller-retour serveur de 200–300ms est imperceptible à moins que l'UI ne l'attende.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.
- Période de grâce pour la fermeture des lots. Ne fermez jamais un lot exactement à ends_at. Accordez-lui un délai de 2–3 secondes côté serveur pour permettre aux enchères en vol qui ont été soumises juste avant la limite de traitement. Gérez cela dans votre fonction programmée 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. - Notifications par email. Utilisez Supabase Edge Functions avec Resend ou Postmark pour envoyer les emails « Vous avez été surenchéri » et « Vous avez gagné ! ». N'essayez pas de le faire à partir de vos routes API Next.js — elles peuvent expirer, et les participants aux enchères sont vraiment irrités si les notifications ne sont pas fiables.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
Combien d'enchérisseurs simultanés Supabase Realtime peut-il gérer ?
Le plan Pro de Supabase supporte jusqu'à 500 connexions Realtime simultanées par défaut, avec des limites supérieures disponibles. Pour la plupart des sites d'enchères — sauf si vous exploitez quelque chose de la taille de Sotheby's en ligne — c'est plus que suffisant. Si vous prévoyez des milliers de spectateurs simultanés, envisagez de diffuser les mises à jour des lots via un seul canal côté serveur plutôt que des abonnements par utilisateur, et regardez la fonctionnalité Supabase Realtime Broadcast qui est plus efficace pour les scénarios à haut fan-out.Realtime Broadcastfeature which is more efficient for high fan-out scenarios.
Dois-je utiliser Supabase Realtime ou un service dédié comme Ably ?
Pour la plupart des projets, Supabase Realtime est tout à fait adéquat et l'intégration est beaucoup plus simple puisque vos données sont déjà dans Supabase. Je ne me tournerais vers Ably ou Pusher que si vous aviez besoin d'une latence inférieure à 50ms mondialement, ou si vous construisiez quelque chose avec des millions de connexions concurrentes. Une vente aux enchères d'antiquités, une collecte de fonds caritatif, une vente en ligne d'une petite galerie d'art — Supabase gère tout cela sans problème.
Que se passe-t-il si la connexion WebSocket d'un utilisateur se coupe au milieu de la vente aux enchères ?
Le SDK client Supabase tentera de se reconnecter automatiquement. Mais vous devriez toujours récupérer l'état actuel du lot (current_bid, ends_at) lors de la reconnexion plutôt que de faire confiance à ce qui était en état local avant la déconnexion. Ajoutez un écouteur d'événement online/offline dans votre Client Component et déclenchez une récupération serveur fraîche lorsque la connexion est rétablie.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.
Puis-je utiliser les Server Actions Next.js pour placer des enchères au lieu d'une route API ?
Oui, et je l'ai fait. Les Server Actions dans Next.js 14 sont pratiques — ils suppriment la bouillie d'une route /api/bid dédiée. Le compromis est que les Server Actions sont un peu plus difficiles à limiter individuellement (vous appliqueriez la limitation de débit au niveau du middleware plutôt que par action). Pour un site d'enchères en production, j'ajouterais une limitation de débit Upstash Redis dans le middleware pour éviter qu'un utilisateur ne bombarde les requêtes d'enchères, que vous utilisiez des Actions ou des routes 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.
Comment gérer les égalités — deux enchères identiques au même moment ?
Le verrou FOR UPDATE dans la fonction Postgres place_bid sérialise les enchères concurrentes, donc techniquement les égalités ne peuvent pas se produire au niveau de la base de données. Une réussira, l'autre échouera avec une réponse « enchère trop basse » (puisque les deux sont égales à current_bid et la vérification est p_amount <= v_lot.current_bid). Premier arrivé, premier servi. C'est la pratique standard des enchères et la plupart des enchérisseurs la comprennent.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.
---
Le marchand d'antiquités de Bath, pour ce que ça vaut, fait fonctionner ses enchères mensuelles en ligne depuis plus de deux ans maintenant. Le nombre maximal d'enchérisseurs concurrents un samedi soir a atteint 84 — tout son village apparemment en train de regarder un lot de joaillerie géorgienne disputée se vendre pour trois fois sa mise à prix. Supabase ne s'en est pas formalisé. Next.js ne s'en est pas formalisé. La seule chose qui a cédé, c'est son Wi-Fi, parce qu'il le faisait fonctionner depuis le plancher du magasin.
Le temps réel est difficile à concevoir d'avance, mais une fois que le schéma est solide et que la fonction d'enchère atomique est en place, le reste est principalement de la tuyauterie. Mettez les fondations en place et vous passerez votre temps sur les parties amusantes — les animations du compte à rebours, l'UX « une fois, deux fois » — plutôt que de déboguer des conditions de course à minuit.
