realtime-auction-nextjs-supabase.html
< BACK Image héroïque pour « Construire un site d'enchères en temps réel avec Next.js & Supabase »

Construire un site d'enchères en temps réel avec Next.js et Supabase

Un client m'a appelé un jeudi après-midi en 2021 -- un marchand d'antiquités basé à Bath, qui voulait mettre ses enchères mensuelles en personne en ligne. Assez simple, j'ai pensé. Puis il a dit « et les enchères doivent se mettre à jour pour tous ceux qui regardent, en direct, sans rafraîchir la page ». D'accord. C'est à ce moment-là qu'un « simple travail WordPress » s'est transformé en une conversation architecturale de deux semaines.WordPress job" turned into a two-week architecture conversation.

Point clé : Les enchères en direct sur Next.js plus Supabase reposent sur les canaux temps réel, la sécurité au niveau des lignes et la validation des enchères côté serveur ; maîtrisez la machine d'état avant l'interface utilisateur.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.

J'ai construit plus de 12 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. Interroger la base de données toutes les cinq secondes semble correct jusqu'à ce que vous ayez 200 enchérisseurs qui frappent un seul endpoint simultanément et que votre facture d'hébergement double du jour au lendemain. Laissez-moi vous montrer exactement comment je construirais une véritable plateforme d'enchères en direct aujourd'hui -- en utilisant Next.js et Supabase -- en me basant sur 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 -- using Next.js and Supabase -- 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 entre deux sources de vérité. Pas de doute sur le fait qu'une enchère qui est allée dans le WebSocket soit également allée 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 rendre le catalogue d'enchères de manière statique, garder le SEO heureux, et hydrater uniquement le widget d'enchères en temps réel sur le client. Cette séparation compte. Vous ne voulez pas payer pour un rendu dynamique sur une page qui est 90 % de 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, l'indicateur d'enchérisseur vérifié, et un credit_balance si vous faites des enchères basées sur dépôt -- extends Supabase's auth.users, stores display name, verified bidder flag, and a credit_balance if 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), and created_by
  • `lots` -- des 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 jointure suivant qui s'est inscrit pour quelle enchère (utile pour les blocages de dépôt 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, vous pourriez les dériver de la table bids à chaque lecture, mais sous une charge concurrente cette requête devient chère rapidement. Dénormalisez, gardez la table bids comme votre journal d'audit, et utilisez une fonction Postgres pour mettre à jour les lots atomiquement quand une enchère est acceptée.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 fonction d'enchère atomique

C'est la partie que la plupart des tutoriels ignorent. Les conditions de concurrence dans les enchères sont réelles. Deux utilisateurs soumettant £520 à la même milliseconde -- qu'est-ce qui se passe ?real. Two users submitting £520 at the same millisecond -- what happens?

La réponse est une fonction Postgres avec verrouillage FOR UPDATE sur la ligne du 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; ```

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 amical « quelqu'un vient de vous surenchérir » sur le client.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 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 :

  1. N'importe qui peut lire les lots en direct -- SELECT on lots where auctions.status = 'live' -- SELECT on lots where auctions.status = 'live'
  2. Seuls les enchérisseurs authentifiés et vérifiés peuvent insérer des enchères -- vérifiez profiles.verified_bidder = true dans la politique -- check profiles.verified_bidder = true in the policy
  3. Seul le créateur de l'enchère peut mettre à jour le statut du lot -- UPDATE on lots where auctions.created_by = auth.uid() -- UPDATE on lots where auctions.created_by = auth.uid()
  4. L'historique des enchères est lisible par le créateur de l'enchère 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 véritablement bonne ici -- cela vaut la peine de lire la section sur les fonctions security definer, car elle interagit avec la façon dont les appels RPC comme place_bid fonctionnent.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.

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 RLS. C'est intentionnel -- vous voulez que le placement de l'enchère contourne la RLS de l'enchérisseur pour pouvoir verrouiller et mettre à jour la ligne du lot. Mais cela signifie que vous devez imposer vos propres vérifications de logique métier à l'intérieur de la fonction, ce que le code ci-dessus fait.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.

---

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 le rend presque honteusement simple.WebSockets underneath, and the client SDK makes it almost embarrassingly simple.

Sur votre page de lot d'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, en é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 utilisateur de chaque enchérisseur connecté se met à jour en environ 100-300ms généralement.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.

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-le toutes les 60 secondes avec une nouvelle requête au cas où il y aurait une dérive. Et ajoutez une 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.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.

---

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 chargé en différé derrière dynamic(() => import('./BidPanel'), { ssr: false }). Cela n'a de sens que côté client de toute façon, et cela garde votre payload HTML initial léger pour les utilisateurs sur les connexions lentes -- ce qui, si votre audience d'enchères penche vers les gens plus âgés (comme c'est souvent le cas pour les enchères d'antiquités), compte davantage que vous ne le penseriez.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.

---

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éfini par défaut à false. Après l'inscription, l'utilisateur voit un écran « Complétez votre enregistrement ». Une fois approuvé (manuellement par l'administrateur, ou automatiquement après une autorisation de paiement Stripe), vous basculez l'indicateur. La politique RLS sur les enchères la vérifie. Ils peuvent parcourir, suivre, mais pas enchérir jusqu'à vérification.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.

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 considérablement les situations de non-paiement qui, croyez-moi, sont la plaie de l'existence de tout opérateur d'enchères en ligne.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.

---

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 se trouve 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 avait déployé Vercel en us-east-1 et Supabase en eu-west-2. Choisissez une région, mettez-y les deux.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.

Quelques points qui vous causeront des problèmes si vous ne les traitez pas en amont :

  • Limites de connexion WebSocket. Le niveau gratuit de Supabase permet 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.
  • Interface utilisateur optimiste pour les enchères. Affichez l'enchère immédiatement sur l'écran de l'enchérisseur avant que le serveur ne la confirme. Si elle échoue (surenchère, condition de concurrence), annulez avec une erreur. L'aller-retour serveur de 200-300 ms est imperceptible sauf si l'interface utilisateur l'attend.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.
  • Période de grâce de fermeture du lot. Ne fermez jamais un lot exactement à ends_at. Donnez-lui un tampon côté serveur de 2-3 secondes pour permettre aux enchères en vol qui ont été soumises juste avant la limite de temps de traiter. Gérez cela dans votre fonction planifié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 your close_lot scheduled function.
  • Notifications par email. Utilisez les 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 d'API Next.js -- elles peuvent expirer, et les participants aux enchères sont vraiment contrariés si les notifications sont peu 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 plus élevées disponibles. Pour la plupart des sites d'enchères -- sauf si vous exploitez quelque chose de la taille des enchères en ligne de Sotheby's -- c'est plus que suffisant. Si vous attendez 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 examinez la fonctionnalité Supabase Realtime Broadcast qui est plus efficace pour les scénarios de haute propagation.Realtime Broadcast feature 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 parfaitement 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 à 50 ms à l'échelle mondiale, ou si vous construisiez quelque chose avec des millions de connexions simultanées. Une enchère d'antiquités, une collecte de fonds caritative, la vente en ligne d'une petite galerie d'art -- Supabase gère tout cela très bien.

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 de Supabase tentera de se reconnecter automatiquement. Mais tu devrais 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 dans l'état local avant la déconnexion. Ajoute un écouteur d'événement online/offline dans ton Client Component et déclenche une récupération serveur fraîche quand la connexion se rétablit.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.

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 le boilerplate d'une route /api/bid dédiée. Le compromis est que les Server Actions sont un peu plus difficiles à limiter en taux individuellement (vous appliqueriez la limitation de taux au niveau du middleware plutôt que par action). Pour un site d'enchères en production, j'ajouterais une limitation de taux Upstash Redis dans le middleware pour éviter qu'un seul utilisateur ne spamme les demandes d'enchère, que vous utilisiez des Actions ou des routes 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.

Comment gérer les égalités -- deux offres identiques au même moment ?

Le verrou FOR UPDATE dans la fonction Postgres place_bid sérialise les enchères simultanées, donc techniquement les égalités ne peuvent pas survenir au niveau de la base de données. L'une réussira, l'autre échouera avec une réponse « enchère trop faible » (puisque les deux égalent 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 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.

---

Le marchand d'antiquités de Bath, pour ce que ça vaut, organise ses enchères mensuelles en ligne depuis plus de deux ans maintenant. Le pic de soumissionnaires simultanés un samedi soir a atteint 84 -- apparemment tout son village était connecté pour regarder un lot contesté d'argenterie georgienne se vendre trois fois sa mise à prix. Supabase n'a pas flanché. Next.js n'a pas flanché. La seule chose qui a lâché, c'était son Wi-Fi, parce qu'il le faisait tourner depuis le plancher du magasin.

Le temps réel est difficile à anticiper, mais une fois que le schéma est solide et que la fonction d'enchère atomique est en place, le reste est surtout de la tuyauterie. Mettez les fondations en place et vous passerez votre temps sur les parties amusantes -- les animations de compte à rebours, l'UX « une fois, deux fois » -- plutôt que de déboguer des conditions de course à minuit.

< BACK