# How to Build an Auction Platform with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
Create an auction platform app with Supabase. Set up these tables:

- 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_at
- bids: id, auction_id (references auctions), bidder_id (references auth.users), amount (numeric), is_winning (bool default false), created_at
- 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_at
- watchlists: id, user_id (references auth.users), auction_id (references auctions), created_at — unique(user_id, auction_id)

RLS:
- auctions: anyone can read active auctions; sellers can CRUD their own auctions
- bids: anyone can read bids; authenticated users can insert bids on active auctions
- auction_winners: readable by winner and seller only
- watchlists: users manage their own watchlist rows

Create a PostgreSQL trigger function validate_bid() that runs BEFORE INSERT on bids:
- Fetch the auction where id = NEW.auction_id
- If auction.status != 'active', raise exception 'Auction is not active'
- If NOW() > auction.ends_at, raise exception 'Auction has ended'
- If NEW.amount <= auction.current_price, raise exception 'Bid must be higher than current price of ' || auction.current_price
- Update auctions SET current_price = NEW.amount WHERE id = NEW.auction_id
- If (auction.ends_at - NOW()) < INTERVAL '2 minutes', update auctions SET ends_at = NOW() + INTERVAL '2 minutes' WHERE id = NEW.auction_id (sniping protection)
- Set NEW.is_winning = true
- Update bids SET is_winning = false WHERE auction_id = NEW.auction_id AND id != NEW.id
- Return NEW

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

### 2. 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.

```
import { useEffect, useState } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import { AuctionCountdown } from '@/components/auction/AuctionCountdown'
import { BidForm } from '@/components/auction/BidForm'

type Bid = { id: string; amount: number; bidder_id: string; created_at: string; is_winning: boolean }
type Auction = { id: string; title: string; current_price: number; ends_at: string; status: string; start_price: number }

export function AuctionDetail({ auctionId }: { auctionId: string }) {
  const [auction, setAuction] = useState<Auction | null>(null)
  const [bids, setBids] = useState<Bid[]>([])

  useEffect(() => {
    supabase.from('auctions').select('*').eq('id', auctionId).single()
      .then(({ data }) => setAuction(data))
    supabase.from('bids').select('*').eq('auction_id', auctionId).order('created_at', { ascending: false }).limit(50)
      .then(({ data }) => setBids(data ?? []))

    const channel = supabase
      .channel(`auction-${auctionId}`)
      .on('postgres_changes', {
        event: 'INSERT',
        schema: 'public',
        table: 'bids',
        filter: `auction_id=eq.${auctionId}`,
      }, (payload) => {
        const newBid = payload.new as Bid
        setBids((prev) => [newBid, ...prev])
        setAuction((prev) => prev ? { ...prev, current_price: newBid.amount } : prev)
      })
      .on('postgres_changes', {
        event: 'UPDATE',
        schema: 'public',
        table: 'auctions',
        filter: `id=eq.${auctionId}`,
      }, (payload) => {
        setAuction((prev) => prev ? { ...prev, ...payload.new } : prev)
      })
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [auctionId])

  if (!auction) return null

  const fmt = (n: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)

  return (
    <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
      <div className="lg:col-span-2 space-y-6">
        <div>
          <h1 className="text-2xl font-bold">{auction.title}</h1>
          <div className="flex items-center gap-3 mt-2">
            <Badge variant={auction.status === 'active' ? 'default' : 'secondary'}>{auction.status}</Badge>
            <AuctionCountdown endsAt={auction.ends_at} onEnd={() => setAuction((a) => a ? { ...a, status: 'ended' } : a)} />
          </div>
        </div>
        <div className="bg-muted rounded-xl p-6">
          <p className="text-sm text-muted-foreground">Current bid</p>
          <p className="text-4xl font-bold">{fmt(auction.current_price)}</p>
          <p className="text-sm text-muted-foreground mt-1">{bids.length} bid{bids.length !== 1 ? 's' : ''}</p>
        </div>
        {auction.status === 'active' && <BidForm auctionId={auctionId} currentPrice={auction.current_price} />}
      </div>
      <div className="space-y-4">
        <h2 className="font-semibold">Bid History</h2>
        <Separator />
        <ScrollArea className="h-[400px]">
          <div className="space-y-2">
            {bids.map((bid) => (
              <div key={bid.id} className="flex justify-between items-center py-2">
                <div>
                  <p className="text-sm font-medium">{fmt(bid.amount)}</p>
                  <p className="text-xs text-muted-foreground">{new Date(bid.created_at).toLocaleTimeString()}</p>
                </div>
                {bid.is_winning && <Badge variant="outline" className="text-green-600">Winning</Badge>}
              </div>
            ))}
          </div>
        </ScrollArea>
      </div>
    </div>
  )
}
```

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

### 3. 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.

```
Build an AuctionCountdown component at src/components/auction/AuctionCountdown.tsx.

Props: endsAt (string ISO timestamp), onEnd (callback called when timer hits zero).

Logic:
- Use a useEffect with setInterval(fn, 1000) to calculate remaining seconds every second.
- Remaining = Math.max(0, Math.floor((new Date(endsAt).getTime() - Date.now()) / 1000))
- Format as HH:MM:SS for >1 hour, MM:SS for <1 hour.
- When remaining reaches 0, call onEnd() and clear the interval.
- Clear and restart the interval whenever endsAt prop changes (sniping extension arrives).

Display:
- remaining > 300 seconds (5 min): muted text, normal weight
- remaining 60-300 seconds: amber text, semibold, add a Clock icon
- remaining < 60 seconds: red text, bold, add a pulsing red dot using Tailwind animate-pulse
- remaining = 0: show 'Auction Ended' Badge

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

### 4. 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.

```
Build a BidForm component at src/components/auction/BidForm.tsx.

Props: auctionId (string), currentPrice (number).

Logic:
- Use react-hook-form with zod schema: amount must be a number > currentPrice.
- Minimum increment: $1 for items under $100, $5 for $100-$999, $10 for $1000+.
- On submit: insert into bids table { auction_id: auctionId, bidder_id: auth.uid(), amount }.
- If Supabase returns an error (trigger rejection), parse the error message and show it using shadcn/ui useToast with variant='destructive'.
- On success: show a success toast 'You are the highest bidder!' and reset the form.
- Show the minimum bid amount as placeholder text in the Input: 'Min: $X.XX'.
- Disable the submit Button while submitting and show a loading spinner.
- If the user is not logged in, show a 'Sign in to bid' Button instead of the form.

UI layout:
- A single Input for bid amount on the left, a 'Place Bid' Button on the right, in a flex row.
- 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.

### 5. 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.

```
Create a Supabase Edge Function at supabase/functions/close-auctions/index.ts.

This function runs on a Supabase cron schedule: every minute ('* * * * *').

Logic:
1. Query auctions WHERE status = 'active' AND ends_at < NOW().
2. For each ended auction:
   a. Find the winning bid: SELECT * FROM bids WHERE auction_id = auction.id AND is_winning = true ORDER BY created_at DESC LIMIT 1.
   b. If no bids exist OR winning bid amount < reserve_price:
      - Update auction status to 'ended', set no_winner = true.
      - Email the seller: 'Your auction for {title} ended with no winner.'
   c. If winning bid exists and meets reserve:
      - Insert into auction_winners: { auction_id, winner_id: bid.bidder_id, winning_bid_id: bid.id, final_price: bid.amount, status: 'pending_payment' }.
      - Update auction status to 'ended'.
      - Email the winner: 'Congratulations! You won {title} for {final_price}. Complete your payment at [link].'
      - Email the seller: 'Your auction for {title} sold for {final_price}.'
3. Return { closed: count }

Use 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 example

File: `src/hooks/useAuctionBid.ts`

```typescript
import { useState } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { useToast } from '@/hooks/use-toast'

type BidResult = { success: true } | { success: false; error: string }

function getMinIncrement(currentPrice: number): number {
  if (currentPrice < 100) return 1
  if (currentPrice < 1000) return 5
  return 10
}

function parseTriggerError(message: string): string {
  if (message.includes('Bid must be higher than current price')) {
    const match = message.match(/current price of (\d+(\.\d+)?)/)
    return match ? `Your bid must exceed the current price of $${parseFloat(match[1]).toFixed(2)}` : 'Your bid is too low.'
  }
  if (message.includes('Auction is not active')) return 'This auction is no longer active.'
  if (message.includes('Auction has ended')) return 'This auction has already ended.'
  return 'Your bid could not be placed. Please try again.'
}

export function useAuctionBid(auctionId: string, currentPrice: number) {
  const [loading, setLoading] = useState(false)
  const { toast } = useToast()

  const minBid = currentPrice + getMinIncrement(currentPrice)

  const placeBid = async (amount: number): Promise<BidResult> => {
    if (amount < minBid) {
      const msg = `Minimum bid is $${minBid.toFixed(2)}`
      toast({ title: 'Bid too low', description: msg, variant: 'destructive' })
      return { success: false, error: msg }
    }

    setLoading(true)
    const { error } = await supabase.from('bids').insert({
      auction_id: auctionId,
      amount,
    })
    setLoading(false)

    if (error) {
      const friendly = parseTriggerError(error.message)
      toast({ title: 'Bid failed', description: friendly, variant: 'destructive' })
      return { success: false, error: friendly }
    }

    toast({ title: 'Bid placed!', description: `You are now the highest bidder at $${amount.toFixed(2)}` })
    return { success: true }
  }

  return { placeBid, loading, minBid, getMinIncrement: () => getMinIncrement(currentPrice) }
}
```

## Common mistakes

- **Validating bids only in the frontend** — JavaScript validation can be bypassed by sending a direct API request to Supabase with a lower bid amount. Two simultaneous requests at the exact same millisecond could both pass a frontend check. Fix: 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.
- **Not removing the Realtime channel subscription on component unmount** — If the cleanup function is missing, navigating away and back to an auction page creates a second subscription. Over time, this causes duplicate bid entries in the UI and wasted server connections. Fix: 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.
- **Relying on client-side time for auction expiry** — Browser clocks can drift and users can manually set their system clock. A client-side check on new Date() > new Date(ends_at) will not stop a determined bidder from submitting a bid after the auction ends. Fix: 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.
- **Not handling the case where auctions end between page loads** — If a user loads the auction page and it ends while they are reading it, the bid form may still be visible and submittable from the UI perspective even though the trigger will reject it. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/auction-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/auction-platform
