realtime-auction-nextjs-supabase.html
< BACK Leerer Auktionssaal mit nummerierten Losnummernschild unter warmem Scheinwerferlicht, Samtsessel und hölzerne Gebotspaddles, kinematografischer Redaktionsstil

Echtzeit-Auktionsplattform mit Next.js und Supabase bauen

Ein Kunde rief mich an einem Donnerstagnachmittag 2021 an — Antikhändler aus Bath, wollte seine monatlichen persönlichen Auktionen online nehmen. Einfach genug, dachte ich. Dann sagte er: „Und die Gebote müssen sich für alle Zuschauer live aktualisieren, ohne Seitenaktualisierung." Richtig. Das war der Moment, in dem ein „einfacher WordPress-Job" zu einem zweiwöchigen Architektur-Gespräch wurde.

Ich habe über 5.000 Seiten bei Seahawk Media gebaut, und Echtzeit-Features sind diejenigen, die dir in den Hintern beißen, wenn du sie von Anfang an nicht richtig planst. Alle fünf Sekunden zu pollen klingt gut, bis du 200 Bieter hast, die gleichzeitig auf einen einzigen Endpunkt hämmern und deine Hosting-Rechnung sich über Nacht verdoppelt. Deshalb zeige ich dir genau, wie ich heute eine richtige Live-Auktionsplattform bauen würde — mit Next.js und Supabase — basierend auf dem, was ich tatsächlich ausgeliefert habe.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.

---

Warum Next.js und Supabase speziell dafür

Es gibt ein Dutzend Wege, um Echtzeit umzusetzen. Socket.io auf einem Node-Server, Ably, Pusher, Firebase — ich habe alle an verschiedenen Punkten verwendet. Aber die Kombination Next.js + Supabase verdient sich ihren Platz hier aus einem bestimmten Grund: Supabase Realtime basiert auf PostgreSQLs logischer Replikation, was bedeutet, dass deine Live-Gebotsaktualisierungen und deine persistente Datenschicht dasselbe System sind. Keine zwei Wahrheitsquellen zum Synchronisieren. Keine Frage, ob ein Gebot, das in den WebSocket ging, auch es in die Datenbank geschafft hat.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 bietet dir auch Auth, Row Level Security und Storage von Anfang an. Bei einer Auktionsseite, bei der „nur der Auktionseigentümer eine Charge beenden kann" und „ein Nutzer kann nicht auf sein eigenes Objekt bieten" tatsächliche Geschäftsregeln sind, sind RLS-Richtlinien in Postgres wirklich das richtige Werkzeug.

Und Next.js, weil — ehrlich gesagt — App Router mit Server Components bedeutet, dass du den Auktionskatalog statisch rendern kannst, SEO zufrieden hältst und nur das Widget für Echtzeit-Gebote auf dem Client hydratisierst. Diese Aufteilung ist wichtig. Du willst nicht für dynamisches Rendering auf einer Seite zahlen, die zu 90 % aus statischem Inhalt besteht.

---

Das Schema zuerst entwerfen (Überspring das nicht)

Das ist der Punkt, wo die meisten Menschen sich beeilen und es später bereuen. Ich habe peinliche drei Tage damit verbracht, das Schema des Bath-Antiquitäten-Kunden mitten im Projekt umzugestalten, weil ich das Gebotsverlaufsmodell nicht richtig durchdacht hatte.

Hier ist die Kernstruktur, die ich jetzt verwende:

  • `profiles` — erweitert Supabases auth.users, speichert Anzeigename, Verified-Bidder-Flag und einen credit_balance, wenn du deposit-basierte Gebote nutzt— extends Supabase'sauth.users, stores display name, verified bidder flag, and acredit_balanceif you're doing deposit-based bidding
  • `auctions` — das Ereignis selbst; starts_at, ends_at, status (draft | live | closed), und created_by— the event itself;starts_at,ends_at,status(draft | live | closed), andcreated_by
  • `lots` — einzelne Objekte innerhalb einer Auktion; reserve_price, current_bid, current_bidder_id, lot_number, ends_at (Chargen können einzelne Countdowns haben)— individual items within an auction;reserve_price,current_bid,current_bidder_id,lot_number,ends_at(lots can have individual countdowns)
  • `bids` — unveränderliches Nur-Anfügen-Log; lot_id, bidder_id, amount, placed_at. Diese Tabelle niemals aktualisieren. Jemals.— immutable append-only log;lot_id,bidder_id,amount,placed_at. Never update this table. Ever.
  • `auction_participants` — eine Verbindungstabelle, die nachverfolgt, wer sich für welche Auktion registriert hat (nützlich für Kaution-Sperrungen und Benachrichtigungsziele)— a join table tracking who has registered for which auction (useful for deposit holds and notification targeting)

Die Spalten current_bid und current_bidder_id in lots sind absichtlich denormalisiert. Ja, du könntest sie bei jedem Lesezugriff aus der bids-Tabelle ableiten, aber unter gleichzeitiger Last wird diese Abfrage schnell teuer. Denormalisiere sie, behalte die bids-Tabelle als dein Audit-Log, und nutze eine Postgres-Funktion, um lots atomar zu aktualisieren, wenn ein Gebot akzeptiert wird.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.

Die Atomic Bid Function

Das ist der Teil, den die meisten Tutorials auslassen. Race Conditions bei Auktionen sind real. Zwei Nutzer submitten £520 in der gleichen Millisekunde — was passiert?real. Two users submitting £520 at the same millisecond — what happens?

Die Antwort ist eine Postgres-Funktion mit FOR UPDATE Locking auf der lot-Zeile: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; ```

Rufen Sie dies über Ihre Next.js API-Route via supabase.rpc('place_bid', {...}) auf. Das FOR UPDATE-Lock bedeutet, dass pro Lot nur eine Transaktion gleichzeitig gewinnt. Die andere erhält einen Serialisierungsfehler und Sie geben dem Client eine freundliche Meldung „jemand hat dich gerade übergeboten" zurück.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 — Die Schicht der Auktionsregeln

RLS ist eine dieser Dinge, die Entwickler entweder sofort lieben oder meiden, weil es sich undurchsichtig anfühlt. Ich war im Meidungs-Lager, bis mich ein FinTech-Projekt bei Seahawk die harte Tour lehrte, dass die Erzwingung der Zugriffskontrolle nur im Anwendungscode eine falsch konfigurierte API-Route entfernt vom Desaster ist.

Für eine Auktionsseite sind dies die Richtlinien, die zählen:

  1. Jeder kann Live-Lots lesen — SELECT auf lots where auctions.status = 'live'SELECTonlotswhereauctions.status = 'live'
  2. Nur authentifizierte, verifizierte Bieter können Gebote einfügen — check profiles.verified_bidder = true in der Richtlinie— checkprofiles.verified_bidder = truein the policy
  3. Nur der Auktionsgründer kann den Los-Status aktualisieren — UPDATE auf lots where auctions.created_by = auth.uid()UPDATEonlotswhereauctions.created_by = auth.uid()
  4. Die Gebotshistorie ist für den Auktionsersteigerer des Loses und den Bieter selbst lesbar — niemand sonst muss die vollständige Gebotshistorie in Echtzeit sehen.— no one else needs to see full bid history in real time

Die Supabase-RLS-Dokumentation ist hier wirklich gut — es lohnt sich, den Abschnitt zu Security-Definer-Funktionen zu lesen, da er damit interagiert, wie RPC-Aufrufe wie place_bid funktionieren.Supabase RLS documentationis genuinely good here — worth reading the section on security definer functions, because it interacts with how RPC calls likeplace_bidwork.

Ein Stolperstein: Wenn du Security Definer auf deine Postgres-Funktion anwendest (wie oben), läuft sie mit den Privilegien des Funktionseigentümers und umgeht RLS. Das ist beabsichtigt — du möchtest, dass die Gebotsplatzierung das RLS des Bieters umgeht, damit es die Los-Reihe sperren und aktualisieren kann. Aber das bedeutet, dass du deine eigenen Business-Logic-Checks innerhalb der Funktion erzwingen musst, was der obige Code tut.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.

---

Supabase Realtime in Next.js einrichten

Hier wird es eigentlich zufriedenstellend. Supabase Realtime lässt dich dich über WebSockets auf Änderungen einer Postgres-Tabelle abonnieren, und das Client-SDK macht es fast beschämend einfach.WebSockets underneath, and the client SDK makes it almost embarrassingly simple.

Auf deiner Auktionslot-Seite — eine Client Component in Next.js App Router — würdest du etwa folgendes tun:

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

Übergib initialBid von einer Server Component, die aktuelle Daten zur Request-Zeit abruft. Der Client übernimmt dann und lauscht auf UPDATE-Events in dieser spezifischen Lot-Zeile. Jedes Mal, wenn place_bid erfolgreich ausgeführt wird, sendet Supabase die Änderung und die UI jedes verbundenen Bieters aktualisiert sich typischerweise innerhalb von etwa 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.

Handling des Countdown-Timers

Lots haben normalerweise einen Countdown — „schließt in 3:42". Vertrau der Client-Uhr nicht dafür. Leite die End-Zeit von lots.ends_at ab (in UTC in Postgres gespeichert) und berechne die verbleibenden Sekunden auf dem Client mit Date.now(). Synchronisiere sie alle 60 Sekunden mit einem frischen Abruf neu, falls es zu Drift kommt. Und füge „Soft Close"-Logik hinzu: Wenn ein Gebot in den letzten 60 Sekunden ankommt, verlängere ends_at um zwei Minuten. Das ist Standard-Auktionsverhalten und Bieter erwarten es.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.

---

Next.js App Router Architecture für die Auctions-UI

Die Seitenstruktur, die ich verwenden würde:

`` app/ auctions/ page.tsx ← Server Component, listet Live-Auktionen auf (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, ruft Lots-Liste server-seitig ab LotGrid.tsx ← Client Component, abonniert Lot-Status-Änderungen [lotId]/ page.tsx ← Server Component, initiale Lot-Daten + Metadaten für SEO BidPanel.tsx ← Client Component, Echtzeit-Bid-Anzeige + Bid-Formular ``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``

Der Katalog (/auctions) nutzt Incremental Static Regeneration mit einer 60-Sekunden-Revalidierung. Einzelne Lot-Seiten werden beim ersten Laden server-seitig gerendert (zum Teilen, Vorschau und og:image-Generierung), dann wird die Kontrolle an Client-Komponenten für die Live-Funktionen übergeben./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.

Eine Sache, die ich immer mache: Die BidPanel-Komponente mit dynamic(() => import('./BidPanel'), { ssr: false }) lazy-loaded halten. Das macht ohnehin nur client-seitig Sinn, und es hält deine initiale HTML-Payload für Nutzer mit langsamen Verbindungen schlank — was, falls dein Auktions-Publikum älter ist (wie es bei Antiquitätsauktionen oft der Fall ist), wichtiger ist, als man erwarten würde.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.

---

Authentifizierung und der "Verified Bidder"-Flow

Standard-Supabase-Auth mit E-Mail/Passwort oder Magic Link funktioniert für die Registrierung einwandfrei. Aber Auktionen benötigen oft einen zusätzlichen Schritt: Bieter-Verifizierung. Du könntest einen Kreditkarteneintrag, eine ID-Verifizierung oder nur eine Admin-Genehmigung brauchen, bevor jemand tatsächlich ein Gebot abgeben kann.

Das Muster, das ich verwende: ein verified_bidder-Boolean in der profiles-Tabelle, standardmäßig auf false gesetzt. Nach der Registrierung sieht der Nutzer einen „Registrierung abschließen"-Bildschirm. Sobald genehmigt (manuell durch Admin oder automatisch nach einer Stripe-Zahlungsautorisierung), flippt man das Flag. Die RLS-Policy auf bids prüft es. Sie können browsen, beobachten, aber nicht bieten, bis sie verifiziert sind.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.

Für Stripe-Zahlungsautorisierungs-Sperrungen ist Stripes payment intents mit capture_method: manual der richtige Ansatz — du autorisierst eine £50-Sperre, erfasst sie, wenn sie gewinnen, gibst sie frei, wenn sie nicht gewinnen. Das reduziert Zahlungsausfälle drastisch, die mir glauben, der Albtraum jedes Online-Auktions-Betreibers sind.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.

---

Deployment, Performance und die Stolpersteine

Deploy zu Vercel — das ist die offensichtliche Wahl für Next.js und das Edge-Netzwerk funktioniert gut mit Supabase's globaler Infrastruktur. Achte darauf, dass dein Supabase-Projekt in der AWS-Region am nächsten zu deiner Vercel-Deployment-Region liegt. Ich habe 40–60ms vollständig unnötige Latenz gesehen, weil jemand Vercel in us-east-1 und Supabase in eu-west-2 deployed hat. Wähle eine Region, lege beide dort ab.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.

Ein paar Dinge, die dir Kopfschmerzen bereiten, wenn du sie nicht von Anfang an richtig angehst:

  • WebSocket-Verbindungsgrenzen. Supabase's kostenloses Tier erlaubt etwa 200 gleichzeitige Realtime-Verbindungen. Wenn deine Auktion viral geht, ist diese Obergrenze entscheidend. Überprüfe deinen Plan.Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
  • Optimistische UI für Gebote. Zeige das Gebot sofort auf dem Bildschirm des Bieters an, bevor der Server es bestätigt. Falls es fehlschlägt (übergeboten, Race Condition), rückgängig machen mit einer Fehlermeldung. Die 200–300ms Server-Roundtrip-Zeit ist unmerklich, solange die UI nicht darauf wartet.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.
  • Losgültigkeit Schonfrist. Schließe ein Los niemals exakt bei ends_at. Gib ihm auf der Serverseite einen 2–3 Sekunden Puffer, damit eingeflogene Gebote, die kurz vor der Frist eingereicht wurden, noch verarbeitet werden können. Handhabe das in deiner close_lot geplanten Funktion.Never close a lot exactly atends_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.
  • E-Mail-Benachrichtigungen. Verwende Supabase Edge Functions mit Resend oder Postmark, um "Du wurdest übergeboten"- und "Du hast gewonnen!"-E-Mails zu versenden. Versuche nicht, das von deinen Next.js API Routes aus zu tun — sie können Timeout-Fehler werfen, und Auktionsbeteiliger werden es dir nicht danken, wenn Benachrichtigungen unzuverlässig sind.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

Wie viele gleichzeitige Bieter kann Supabase Realtime verarbeiten?

Supabase's Pro-Plan unterstützt standardmäßig bis zu 500 gleichzeitige Realtime-Verbindungen, mit höheren Limits verfügbar. Für die meisten Auktionsseiten — es sei denn, du betreibst etwas in der Größe von Sotheby's online — reicht das völlig aus. Falls du tausende simultane Zuschauer erwartest, erwäge, Lot-Updates über einen einzelnen serverseitigen Channel statt per-Benutzer-Abos zu übertragen, und schau dir Supabase's Realtime Broadcast Feature an, das für High-Fan-out-Szenarien effizienter ist.Realtime Broadcastfeature which is more efficient for high fan-out scenarios.

Sollte ich Supabase Realtime oder einen dedizierten Service wie Ably nutzen?

Bei den meisten Projekten ist Supabase Realtime vollkommen ausreichend und die Integration ist viel einfacher, da deine Daten bereits in Supabase sind. Ich würde nur zu Ably oder Pusher greifen, wenn du eine globale Latenz unter 50ms brauchst oder etwas mit Millionen gleichzeitiger Verbindungen aufbaust. Eine Antiquitätenauktion, eine Benefizveranstaltung, ein Online-Verkauf einer kleinen Kunstgalerie — Supabase verarbeitet das alles problemlos.

Was passiert, wenn die WebSocket-Verbindung eines Benutzers während einer Auktion abbricht?

Das Client SDK von Supabase versucht automatisch, sich erneut zu verbinden. Du solltest aber immer den aktuellen Los-Status (current_bid, ends_at) bei der Wiederverbindung neu abrufen, anstatt darauf zu vertrauen, was vorher lokal gespeichert war. Füge einen Online-/Offline-Event-Listener in deiner Client Component ein und rufe einen neuen Server-Fetch auf, wenn die Verbindung wiederhergestellt ist.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.

Kann ich Next.js Server Actions verwenden, um Gebote abzugeben statt einer API-Route?

Ja, und ich habe es getan. Server Actions in Next.js 14 sind praktisch — sie entfernen die Boilerplate-Code einer dedizierten /api/bid-Route. Der Nachteil ist, dass Server Actions etwas schwieriger einzeln zu rate-limitieren sind (du würdest Rate Limiting auf Middleware-Ebene statt pro Action anwenden). Für eine produktive Auktionsseite würde ich Upstash Redis Rate Limiting in der Middleware hinzufügen, um zu verhindern, dass ein einzelner Benutzer Gebotsanfragen spammt — unabhängig davon, ob du Actions oder API Routes verwendest./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.

Wie gehe ich mit Gleichständen um — zwei identische Gebote zur gleichen Zeit?

Das FOR UPDATE Lock in der place_bid Postgres-Funktion serialisiert gleichzeitige Gebote, also können Gleichstände auf der Datenbankebene technisch nicht vorkommen. Eines wird erfolgreich sein, das andere schlägt mit einer "Gebot zu niedrig"-Antwort fehl (da beide gleich current_bid sind und die Prüfung p_amount <= v_lot.current_bid ist). Wer zuerst kommt, mahlt zuerst. Das ist Standard-Auktionspraxis und die meisten Bieter verstehen das.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.

---

Der Antiquitätenhändler aus Bath betreibt seine monatlichen Auktionen übrigens schon seit über zwei Jahren online. Der Spitzenwert an gleichzeitigen Bietern an einem Samstagabend lag bei 84 — sein ganzes Dorf scheinbar dabei, um zuzuschauen, wie ein umstrittenes Lot georgisches Silber für das Dreifache der Mindestgebote erzielte. Supabase hat nicht gezuckt. Next.js hat nicht gezuckt. Das Einzige, das kaputtging, war sein WLAN, weil er es vom Ladenboden aus betrieb.

Real-time ist schwer von vornherein zu durchdenken, aber sobald das Schema solide ist und die atomare Gebot-Funktion eingerichtet ist, ist der Rest größtenteils Rohrleitungen. Mach die Grundlagen richtig und du wirst deine Zeit auf die spaßigen Teile verwenden — die Countdown-Animationen, das "einmal, zweimal" UX — anstatt um Mitternacht Race Conditions zu debuggen.

< BACK