Build a realtime auction platform in Lovable with live bid updates, a countdown timer, database-level bid validation via PostgreSQL triggers, and automatic 2-minute sniping protection when bids arrive in the final moments — all backed by Supabase Realtime and Edge Functions.
What you're building
An auction platform is one of the most technically challenging e-commerce patterns to build correctly, because bids must be validated atomically — a slow network connection should never allow someone to win with a lower bid than someone else's that arrived first.
This build uses a PostgreSQL trigger on the bids table to enforce bid ordering. When a bid insert arrives, the trigger checks if the amount is greater than the current highest bid. If not, the insert is rejected with a clear error message. This runs inside the database transaction, making it race-condition-proof.
Supabase Realtime pushes new bids to all connected browsers via WebSocket. The auction detail page subscribes to the bids channel for that auction_id and updates the displayed current price and bid list in real time without any polling.
Sniping protection is implemented in the same trigger: if the auction's ends_at is within 2 minutes and the bid is valid, the trigger updates ends_at to NOW() + INTERVAL '2 minutes'. This means a last-second bid always gives other bidders time to respond.
Final result
A live auction platform with atomic bid validation, realtime updates across all browsers, sniping protection, and automated winner notification.
Tech stack
Prerequisites
- Lovable Pro account (Realtime + trigger-heavy schema needs significant generation credits)
- Supabase project created at supabase.com — free tier works for development
- Supabase URL and anon key added to Cloud tab → Secrets
- An email provider for outbid notifications (Resend API key added to Secrets works well)
- A list of auction item categories and sample items to seed for testing
Build steps
Define the auction schema with bid validation trigger
The schema is the foundation. The bids table needs a PostgreSQL trigger that runs BEFORE INSERT to validate bid amount and handle sniping extension. Getting this right first prevents having to rebuild the bidding logic later.
1Create an auction platform app with Supabase. Set up these tables:23- auctions: id, seller_id (references auth.users), title, description, start_price (numeric), reserve_price (numeric nullable), current_price (numeric), buy_now_price (numeric nullable), image_urls (text[]), category (text), status (draft|active|ended|cancelled), starts_at (timestamptz), ends_at (timestamptz), created_at4- bids: id, auction_id (references auctions), bidder_id (references auth.users), amount (numeric), is_winning (bool default false), created_at5- auction_winners: id, auction_id (unique, references auctions), winner_id (references auth.users), winning_bid_id (references bids), final_price (numeric), status (pending_payment|paid|cancelled), created_at6- watchlists: id, user_id (references auth.users), auction_id (references auctions), created_at — unique(user_id, auction_id)78RLS:9- auctions: anyone can read active auctions; sellers can CRUD their own auctions10- bids: anyone can read bids; authenticated users can insert bids on active auctions11- auction_winners: readable by winner and seller only12- watchlists: users manage their own watchlist rows1314Create a PostgreSQL trigger function validate_bid() that runs BEFORE INSERT on bids:15- Fetch the auction where id = NEW.auction_id16- If auction.status != 'active', raise exception 'Auction is not active'17- If NOW() > auction.ends_at, raise exception 'Auction has ended'18- If NEW.amount <= auction.current_price, raise exception 'Bid must be higher than current price of ' || auction.current_price19- Update auctions SET current_price = NEW.amount WHERE id = NEW.auction_id20- If (auction.ends_at - NOW()) < INTERVAL '2 minutes', update auctions SET ends_at = NOW() + INTERVAL '2 minutes' WHERE id = NEW.auction_id (sniping protection)21- Set NEW.is_winning = true22- Update bids SET is_winning = false WHERE auction_id = NEW.auction_id AND id != NEW.id23- Return NEW2425Attach trigger: CREATE TRIGGER bid_validation BEFORE INSERT ON bids FOR EACH ROW EXECUTE FUNCTION validate_bid();Pro tip: Ask Lovable to also create a separate Supabase scheduled Edge Function that runs every minute, queries auctions WHERE status = 'active' AND ends_at < NOW(), and for each: inserts into auction_winners (the bidder where is_winning = true) and updates auction status to 'ended'. This handles auction closing reliably.
Expected result: All four tables are created. The trigger function is deployed. TypeScript types are generated. Testing an INSERT with a lower amount than current_price returns the exception from the trigger.
Build the auction detail page with realtime bid feed
The auction detail page subscribes to the bids channel for this auction_id and re-renders the current price and bid list whenever a new bid arrives. The subscription uses Supabase Realtime's postgres_changes event.
1import { useEffect, useState } from 'react'2import { supabase } from '@/integrations/supabase/client'3import { Badge } from '@/components/ui/badge'4import { Separator } from '@/components/ui/separator'5import { ScrollArea } from '@/components/ui/scroll-area'6import { AuctionCountdown } from '@/components/auction/AuctionCountdown'7import { BidForm } from '@/components/auction/BidForm'89type Bid = { id: string; amount: number; bidder_id: string; created_at: string; is_winning: boolean }10type Auction = { id: string; title: string; current_price: number; ends_at: string; status: string; start_price: number }1112export function AuctionDetail({ auctionId }: { auctionId: string }) {13 const [auction, setAuction] = useState<Auction | null>(null)14 const [bids, setBids] = useState<Bid[]>([])1516 useEffect(() => {17 supabase.from('auctions').select('*').eq('id', auctionId).single()18 .then(({ data }) => setAuction(data))19 supabase.from('bids').select('*').eq('auction_id', auctionId).order('created_at', { ascending: false }).limit(50)20 .then(({ data }) => setBids(data ?? []))2122 const channel = supabase23 .channel(`auction-${auctionId}`)24 .on('postgres_changes', {25 event: 'INSERT',26 schema: 'public',27 table: 'bids',28 filter: `auction_id=eq.${auctionId}`,29 }, (payload) => {30 const newBid = payload.new as Bid31 setBids((prev) => [newBid, ...prev])32 setAuction((prev) => prev ? { ...prev, current_price: newBid.amount } : prev)33 })34 .on('postgres_changes', {35 event: 'UPDATE',36 schema: 'public',37 table: 'auctions',38 filter: `id=eq.${auctionId}`,39 }, (payload) => {40 setAuction((prev) => prev ? { ...prev, ...payload.new } : prev)41 })42 .subscribe()4344 return () => { supabase.removeChannel(channel) }45 }, [auctionId])4647 if (!auction) return null4849 const fmt = (n: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)5051 return (52 <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">53 <div className="lg:col-span-2 space-y-6">54 <div>55 <h1 className="text-2xl font-bold">{auction.title}</h1>56 <div className="flex items-center gap-3 mt-2">57 <Badge variant={auction.status === 'active' ? 'default' : 'secondary'}>{auction.status}</Badge>58 <AuctionCountdown endsAt={auction.ends_at} onEnd={() => setAuction((a) => a ? { ...a, status: 'ended' } : a)} />59 </div>60 </div>61 <div className="bg-muted rounded-xl p-6">62 <p className="text-sm text-muted-foreground">Current bid</p>63 <p className="text-4xl font-bold">{fmt(auction.current_price)}</p>64 <p className="text-sm text-muted-foreground mt-1">{bids.length} bid{bids.length !== 1 ? 's' : ''}</p>65 </div>66 {auction.status === 'active' && <BidForm auctionId={auctionId} currentPrice={auction.current_price} />}67 </div>68 <div className="space-y-4">69 <h2 className="font-semibold">Bid History</h2>70 <Separator />71 <ScrollArea className="h-[400px]">72 <div className="space-y-2">73 {bids.map((bid) => (74 <div key={bid.id} className="flex justify-between items-center py-2">75 <div>76 <p className="text-sm font-medium">{fmt(bid.amount)}</p>77 <p className="text-xs text-muted-foreground">{new Date(bid.created_at).toLocaleTimeString()}</p>78 </div>79 {bid.is_winning && <Badge variant="outline" className="text-green-600">Winning</Badge>}80 </div>81 ))}82 </div>83 </ScrollArea>84 </div>85 </div>86 )87}Expected result: Opening the auction page on two browser tabs and placing a bid on one tab shows the updated price and new bid entry on the other tab within under one second.
Build the countdown timer with sniping warning
The countdown timer counts down seconds to ends_at and changes its appearance when time is running low. When the ends_at updates due to sniping protection, the timer automatically adjusts because it reads from the auction state that is kept in sync by the Realtime subscription.
1Build an AuctionCountdown component at src/components/auction/AuctionCountdown.tsx.23Props: endsAt (string ISO timestamp), onEnd (callback called when timer hits zero).45Logic:6- Use a useEffect with setInterval(fn, 1000) to calculate remaining seconds every second.7- Remaining = Math.max(0, Math.floor((new Date(endsAt).getTime() - Date.now()) / 1000))8- Format as HH:MM:SS for >1 hour, MM:SS for <1 hour.9- When remaining reaches 0, call onEnd() and clear the interval.10- Clear and restart the interval whenever endsAt prop changes (sniping extension arrives).1112Display:13- remaining > 300 seconds (5 min): muted text, normal weight14- remaining 60-300 seconds: amber text, semibold, add a Clock icon15- remaining < 60 seconds: red text, bold, add a pulsing red dot using Tailwind animate-pulse16- remaining = 0: show 'Auction Ended' Badge1718Also show a 'Sniping protection active — time extended' toast notification using shadcn/ui useToast when endsAt changes to a time in the future after it was previously within 2 minutes of ending.Pro tip: Track the previous endsAt value in a useRef so you can detect when it increases (sniping extension fired) versus just counting down. Use useRef to hold the interval ID and always clear it in the cleanup function to prevent memory leaks when the user navigates away.
Expected result: The countdown ticks accurately. Placing a bid with under 2 minutes remaining visually extends the timer and triggers the sniping toast. The timer turns red in the final minute.
Build the bid submission form with error handling
The bid form submits to Supabase and handles trigger-level errors gracefully. The trigger rejects invalid bids with an error message that the frontend should surface clearly.
1Build a BidForm component at src/components/auction/BidForm.tsx.23Props: auctionId (string), currentPrice (number).45Logic:6- Use react-hook-form with zod schema: amount must be a number > currentPrice.7- Minimum increment: $1 for items under $100, $5 for $100-$999, $10 for $1000+.8- On submit: insert into bids table { auction_id: auctionId, bidder_id: auth.uid(), amount }.9- If Supabase returns an error (trigger rejection), parse the error message and show it using shadcn/ui useToast with variant='destructive'.10- On success: show a success toast 'You are the highest bidder!' and reset the form.11- Show the minimum bid amount as placeholder text in the Input: 'Min: $X.XX'.12- Disable the submit Button while submitting and show a loading spinner.13- If the user is not logged in, show a 'Sign in to bid' Button instead of the form.1415UI layout:16- A single Input for bid amount on the left, a 'Place Bid' Button on the right, in a flex row.17- Below: muted text showing 'Current bid: $X.XX' and 'Minimum bid: $Y.YY'.Expected result: Submitting a valid bid inserts it successfully and the realtime subscription propagates the update. Submitting a bid lower than the current price shows the trigger's error message in a destructive toast.
Build the auction close Edge Function and winner notification
When an auction ends, a scheduled Edge Function identifies the winning bid, creates an auction_winner record, and sends notification emails to both the winner and the seller.
1Create a Supabase Edge Function at supabase/functions/close-auctions/index.ts.23This function runs on a Supabase cron schedule: every minute ('* * * * *').45Logic:61. Query auctions WHERE status = 'active' AND ends_at < NOW().72. For each ended auction:8 a. Find the winning bid: SELECT * FROM bids WHERE auction_id = auction.id AND is_winning = true ORDER BY created_at DESC LIMIT 1.9 b. If no bids exist OR winning bid amount < reserve_price:10 - Update auction status to 'ended', set no_winner = true.11 - Email the seller: 'Your auction for {title} ended with no winner.'12 c. If winning bid exists and meets reserve:13 - Insert into auction_winners: { auction_id, winner_id: bid.bidder_id, winning_bid_id: bid.id, final_price: bid.amount, status: 'pending_payment' }.14 - Update auction status to 'ended'.15 - Email the winner: 'Congratulations! You won {title} for {final_price}. Complete your payment at [link].'16 - Email the seller: 'Your auction for {title} sold for {final_price}.'173. Return { closed: count }1819Use Resend for emails. Store RESEND_API_KEY in Cloud tab → Secrets.Pro tip: Register this Edge Function as a Supabase cron job in the Supabase Dashboard under Database → Extensions → pg_cron, rather than an external scheduler. This keeps everything within Supabase and avoids cold start delays from external cron services.
Expected result: After an auction's end time passes, the Edge Function runs within one minute, creates the winner record, updates auction status to 'ended', and sends both notification emails.
Complete code
1import { useState } from 'react'2import { supabase } from '@/integrations/supabase/client'3import { useToast } from '@/hooks/use-toast'45type BidResult = { success: true } | { success: false; error: string }67function getMinIncrement(currentPrice: number): number {8 if (currentPrice < 100) return 19 if (currentPrice < 1000) return 510 return 1011}1213function parseTriggerError(message: string): string {14 if (message.includes('Bid must be higher than current price')) {15 const match = message.match(/current price of (\d+(\.\d+)?)/)16 return match ? `Your bid must exceed the current price of $${parseFloat(match[1]).toFixed(2)}` : 'Your bid is too low.'17 }18 if (message.includes('Auction is not active')) return 'This auction is no longer active.'19 if (message.includes('Auction has ended')) return 'This auction has already ended.'20 return 'Your bid could not be placed. Please try again.'21}2223export function useAuctionBid(auctionId: string, currentPrice: number) {24 const [loading, setLoading] = useState(false)25 const { toast } = useToast()2627 const minBid = currentPrice + getMinIncrement(currentPrice)2829 const placeBid = async (amount: number): Promise<BidResult> => {30 if (amount < minBid) {31 const msg = `Minimum bid is $${minBid.toFixed(2)}`32 toast({ title: 'Bid too low', description: msg, variant: 'destructive' })33 return { success: false, error: msg }34 }3536 setLoading(true)37 const { error } = await supabase.from('bids').insert({38 auction_id: auctionId,39 amount,40 })41 setLoading(false)4243 if (error) {44 const friendly = parseTriggerError(error.message)45 toast({ title: 'Bid failed', description: friendly, variant: 'destructive' })46 return { success: false, error: friendly }47 }4849 toast({ title: 'Bid placed!', description: `You are now the highest bidder at $${amount.toFixed(2)}` })50 return { success: true }51 }5253 return { placeBid, loading, minBid, getMinIncrement: () => getMinIncrement(currentPrice) }54}Customization ideas
Proxy bidding (automatic bidding up to a max)
Add a max_bids table (auction_id, bidder_id, max_amount). When a new bid arrives that outbids someone with a max_bid, the trigger automatically places a counter-bid at the minimum increment on behalf of the max bidder, up to their max_amount. This is the standard eBay-style autobid. Implement it in the trigger or an Edge Function called after each manual bid.
Buy It Now feature
If buy_now_price is set and a buyer clicks Buy It Now, skip the auction process entirely: create an auction_winner record immediately, set auction status to 'ended', and redirect to checkout. Remove the buy_now_price option from the UI once at least one bid has been placed — buying it now after bidding has started is not standard auction practice.
Seller reserves and minimum starting bids
When an auction ends, check if the winning bid meets the reserve_price. If not, the seller can choose to accept the highest bid anyway via a /seller/auctions/[id]/reserve-decision page, or decline and relist. Add a reserve_status (met|not_met|waived) field to auctions that updates when the auction closes.
Live auction event mode
Add an is_live_event boolean to auctions. Live events show a persistent top bar on the site with a countdown to the next lot. Add a lots table (auction_id, lot_number, current_auction_id) to manage multiple items in a single timed event. The auctioneer (admin) advances lots manually via an admin panel.
Bid retraction request workflow
Allow bidders to request bid retraction by adding a bid_retractions table (bid_id, reason, status: pending|approved|denied). Sellers and admins review retraction requests. Approved retractions mark the bid as retracted, run a re-evaluation of the winning bid, and update current_price accordingly.
Common pitfalls
Pitfall: Validating bids only in the frontend
How to avoid: The PostgreSQL trigger is the authoritative validation layer. It runs inside a database transaction that serializes concurrent inserts. The frontend validation is only for UX convenience — it should never be treated as security.
Pitfall: Not removing the Realtime channel subscription on component unmount
How to avoid: Always return a cleanup function from the useEffect that calls supabase.removeChannel(channel). Wrap the channel variable in a ref so the cleanup function always closes the correct channel instance.
Pitfall: Relying on client-side time for auction expiry
How to avoid: The trigger function uses PostgreSQL's NOW() which is the server time. The client-side countdown timer is purely for display. All real enforcement happens in the trigger and the close-auctions Edge Function.
Pitfall: Not handling the case where auctions end between page loads
How to avoid: Subscribe to the auctions table UPDATE event via Realtime alongside the bids INSERT event. When the status field changes to 'ended', immediately hide the bid form and show the 'Auction Ended' state without requiring a page refresh.
Best practices
- Put bid validation logic in the PostgreSQL trigger, not in an Edge Function. Triggers run synchronously within the INSERT transaction, making them immune to race conditions that external validation cannot prevent.
- Use Supabase Realtime's filter parameter (filter: 'auction_id=eq.' + id) to subscribe to bids for a single auction rather than all bids. This reduces WebSocket traffic and prevents client-side filtering bugs.
- Store ends_at as a timestamptz column, never as a Unix integer. PostgreSQL's interval arithmetic (NOW() + INTERVAL '2 minutes') only works cleanly with proper timestamp types.
- Index bids on (auction_id, created_at DESC) and on (auction_id, is_winning) separately. Querying the bid history and finding the winning bid are both frequent operations on hot auction rows.
- Use optimistic UI updates sparingly for bids. Since the trigger may reject the bid, show a 'Submitting...' state rather than immediately adding the user's bid to the list. Only add it to the list when the Realtime INSERT event confirms it.
- Send outbid emails asynchronously from the trigger via pg_net or from the close-auctions Edge Function, not synchronously in the bid insert transaction. A slow email API should never block a bid from being recorded.
- Keep the close-auctions Edge Function idempotent: wrapping the winner insert in an ON CONFLICT DO NOTHING clause prevents duplicate winner records if the function runs twice within the same minute.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an auction platform in Lovable with Supabase. I have a bids table with columns: id, auction_id, bidder_id, amount, is_winning, created_at. I also have an auctions table with current_price and ends_at. Write a PostgreSQL trigger function validate_bid() that runs BEFORE INSERT on bids. It should: 1) check auction status is 'active', 2) check NOW() < ends_at, 3) check NEW.amount > current_price (raise exception if not), 4) UPDATE auctions SET current_price = NEW.amount WHERE id = NEW.auction_id, 5) if (ends_at - NOW()) < 2 minutes, extend ends_at by 2 minutes (sniping protection), 6) set NEW.is_winning = true, 7) set is_winning = false on all previous bids for this auction. Return the full SQL.
Add a watchlist feature to the auction platform. Add a heart icon Button to each auction card and detail page. Clicking it toggles the auction in the user's watchlist (insert/delete from the watchlists table). Create a /watchlist page showing all watched auctions ordered by ends_at ascending. On the watchlist page, highlight auctions ending in under 1 hour with an amber Badge. Use the existing AuctionCountdown component for each row.
In Supabase, write a PostgreSQL function get_auction_summary(p_auction_id uuid) that returns a JSON object with: current_price, total_bids (count), unique_bidders (count distinct bidder_id), highest_bidder_id (bidder_id of the is_winning=true row), time_remaining_seconds (EXTRACT(EPOCH FROM (ends_at - NOW())) cast to int, min 0), reserve_met (bool: current_price >= reserve_price). This should be callable as a single RPC from the auction detail page.
Frequently asked questions
Can two users win the same auction simultaneously?
No, because the PostgreSQL trigger that validates bids runs inside a serializable transaction. When two bids arrive at exactly the same moment, the database serializes them — one runs first and the other either succeeds at a higher amount or fails the 'bid must be higher than current price' check. This is one of the key advantages of putting validation in a trigger rather than an Edge Function.
How does sniping protection work technically?
The trigger compares ends_at - NOW() to an interval of 2 minutes. If the difference is less than 2 minutes and the bid is otherwise valid, the trigger runs UPDATE auctions SET ends_at = NOW() + INTERVAL '2 minutes'. Because this happens in the same transaction as the bid insert, the extension is atomic. Other bidders see the extended ends_at via the Realtime auctions UPDATE event.
What if my Supabase project goes offline briefly during a live auction?
Supabase free and Pro tiers offer 99.9% uptime SLA for the database. The Realtime WebSocket connection will drop if the server is unreachable, and the client SDK will attempt to reconnect automatically. Implement a reconnection indicator in the auction UI that shows 'Reconnecting...' when the Realtime channel status changes to 'CLOSED', so bidders know their live feed is temporarily interrupted.
How do I handle payments from auction winners?
After the close-auctions Edge Function creates the auction_winner record, email the winner a payment link. The payment page creates a Stripe PaymentIntent for final_price using a standard server-side Edge Function (no split needed unless you have a seller payout model). On payment_intent.succeeded, update auction_winner status to 'paid' and trigger a fulfillment notification to the seller.
Can I run multiple auctions simultaneously?
Yes. Each auction is an independent row. The close-auctions Edge Function processes all ended auctions in a single cron run. Realtime subscriptions are filtered per auction_id so each auction page only receives updates for its own auction. There is no practical limit on concurrent auctions beyond your Supabase plan's connection limits.
How do I show auction listings on a homepage before they start?
Query auctions WHERE status = 'draft' AND starts_at > NOW() for upcoming auctions and display them with a 'Starts in X hours' countdown. A separate Supabase scheduled Edge Function or pg_cron job can update status from 'draft' to 'active' when starts_at passes. Add an upcoming auction watchlist so users can receive notifications when a watched item goes live.
How many concurrent users can a Supabase Realtime auction support?
Supabase Pro plan supports up to 200 concurrent Realtime connections by default, extendable to 10,000 with add-ons. For a hot auction with many watchers, optimize by using Broadcast instead of postgres_changes for the bid feed — have the Edge Function broadcast a message on bid insert rather than subscribing to database row events, which reduces database load significantly.
Is there help available for production auction platform builds?
RapidDev builds production-grade Lovable apps including auction platforms with advanced proxy bidding, Stripe escrow, and high-concurrency Realtime configurations. Reach out if you need hands-on support.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation