Skip to main content
RapidDev - Software Development Agency

How to Build a Auction Platform with Replit

Build a live auction platform with Replit in 2-4 hours using Express, PostgreSQL, Drizzle ORM, and Stripe. You'll get time-limited listings, race-condition-safe bid placement with PostgreSQL transactions, countdown timers, anti-sniping logic, and post-auction Stripe Checkout for winners.

What you'll build

  • PostgreSQL-backed auction listings with start/end times, reserve price, and automatic status transitions
  • Bid placement endpoint wrapped in a SELECT FOR UPDATE transaction to prevent race conditions between simultaneous bidders
  • PostgreSQL trigger that updates current_bid and bid_count atomically on each bid insert
  • Anti-sniping logic: bids within 2 minutes of end_time automatically extend end_time by 2 minutes
  • Stripe Checkout integration for post-auction payment — winner pays after auction ends
  • Polling-based real-time bid updates (every 5 seconds, accelerating to 2 seconds in final minutes)
  • Watchlist so users can track auctions they're interested in
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced14 min read2-4 hoursReplit Core or higherApril 2026RapidDev Engineering Team
TL;DR

Build a live auction platform with Replit in 2-4 hours using Express, PostgreSQL, Drizzle ORM, and Stripe. You'll get time-limited listings, race-condition-safe bid placement with PostgreSQL transactions, countdown timers, anti-sniping logic, and post-auction Stripe Checkout for winners.

What you're building

An auction platform is one of the most technically demanding app types: it requires real-time state updates, race-condition-proof bidding, time-based automation, and deferred payment collection. Think eBay — sellers list items with a starting price and an end time, buyers compete by placing incrementally higher bids, and the winner pays after the auction closes.

Replit Agent generates the Express + Drizzle foundation quickly. The hard parts — the database-level bid locking, anti-sniping trigger, and Stripe post-auction checkout — are built step by step in this guide. Use the /stripe command in Replit to auto-provision a Stripe sandbox environment with keys and webhook wiring.

The most important architectural decision: deploy on Reserved VM, not Autoscale. A cold start during the final seconds of an auction would be catastrophic — a bidder's winning bid would time out before the server woke up. Reserved VM keeps the bid endpoint always-on. The polling approach for real-time updates avoids WebSocket complexity while still showing live bid activity.

Final result

A live auction platform with listing creation, real-time bid updates via polling, race-condition-safe bid placement, anti-sniping extension, and Stripe Checkout payment collection for auction winners.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
StripePayments
Replit AuthAuth
ReactFrontend

Prerequisites

  • A Replit Core account (required for Replit Auth and built-in PostgreSQL)
  • A Stripe account — sign up free at stripe.com (test mode is free, no real charges)
  • Basic understanding of what a database transaction means (no coding experience needed)
  • Reserved VM deployment plan — auctions need an always-on server ($10-20/month on Replit)

Build steps

1

Scaffold the project and database schema with Agent

Generate the full Express + Drizzle project with the auction schema. Getting the schema right now saves significant rework later — auctions, bids, sellers, and watchlist all need specific column types.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build a Node.js Express auction platform with Replit Auth and built-in PostgreSQL using Drizzle ORM.
3// Schema in shared/schema.ts:
4// * sellers: id serial pk, user_id text not null unique, display_name text not null,
5// stripe_customer_id text, verified boolean default false, created_at timestamp default now()
6// * auctions: id serial pk, seller_id integer references sellers(id) not null,
7// title text not null, description text, images jsonb, starting_price integer not null,
8// reserve_price integer, current_bid integer default 0, bid_count integer default 0,
9// status text default 'draft', start_time timestamp not null, end_time timestamp not null,
10// category text, created_at timestamp default now()
11// * bids: id serial pk, auction_id integer references auctions(id) not null,
12// bidder_id text not null, amount integer not null, created_at timestamp default now()
13// * watchlist: id serial pk, user_id text not null, auction_id integer references auctions(id) not null,
14// unique constraint on (user_id, auction_id)
15// * webhook_events: id serial pk, stripe_event_id text unique not null, event_type text,
16// processed_at timestamp default now()
17// Routes: POST /api/auctions, GET /api/auctions, GET /api/auctions/:id,
18// POST /api/auctions/:id/bids, POST /api/auctions/:id/watch,
19// POST /api/auctions/:id/pay, POST /api/webhooks/stripe
20// React frontend with auction card grid, countdown timers, bid history table, bid input form

Pro tip: All prices are stored in cents (integer) — never store decimal dollar amounts in PostgreSQL for financial data. Display them divided by 100 in the React frontend.

Expected result: Replit creates the project structure with all tables in shared/schema.ts and placeholder route handlers for each endpoint.

2

Run the /stripe command and configure Stripe webhook

Replit's /stripe command auto-provisions a Stripe sandbox, installs the SDK, and pre-wires the webhook endpoint. After running it, add your Stripe keys to the Secrets panel.

prompt.txt
1// In the Replit Agent chat or Shell, type: /stripe
2// This automatically:
3// 1. Installs the stripe npm package
4// 2. Sets up STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY in your workspace
5// 3. Creates a basic webhook handler at POST /api/webhooks/stripe
6// 4. Adds stripe.webhooks.constructEvent() verification
7//
8// IMPORTANT: After running /stripe, open the Secrets panel (lock icon 🔒)
9// and verify these are set:
10// STRIPE_SECRET_KEY=sk_test_...
11// STRIPE_PUBLISHABLE_KEY=pk_test_...
12// STRIPE_WEBHOOK_SECRET=whsec_... (you'll get this from Stripe Dashboard after deploying)
13//
14// The webhook secret is only available after deployment.
15// Deploy first, then register your deployed URL in Stripe Dashboard:
16// Stripe Dashboard → Developers → Webhooks → Add endpoint
17// URL: https://your-repl.replit.app/api/webhooks/stripe
18// Events: checkout.session.completed
19// Copy the signing secret → add as STRIPE_WEBHOOK_SECRET in Deployment Secrets

Pro tip: Webhooks do NOT work during development in Replit — the dev server has no public URL for incoming connections. Always deploy first, then register the deployed URL with Stripe to test payment flows.

Expected result: stripe package is in package.json. Secrets panel shows STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY. The webhook handler exists at POST /api/webhooks/stripe.

3

Build the race-condition-safe bid placement route

This is the most critical route. Multiple users might bid simultaneously in the final seconds. Wrapping the validation and insert in a SELECT FOR UPDATE transaction prevents two users from both 'winning' at the same bid amount.

server/routes/bids.js
1import { db } from '../db.js';
2import { auctions, bids } from '../../shared/schema.js';
3import { eq, sql } from 'drizzle-orm';
4
5const MIN_BID_INCREMENT = 100; // $1.00 minimum increment
6const ANTI_SNIPE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes
7const ANTI_SNIPE_EXTENSION_MS = 2 * 60 * 1000; // extend by 2 minutes
8
9export async function placeBid(req, res) {
10 const auctionId = parseInt(req.params.id);
11 const bidderId = req.get('X-Replit-User-Id');
12 const amount = parseInt(req.body.amount); // in cents
13
14 if (!bidderId) return res.status(401).json({ error: 'Not authenticated' });
15 if (!amount || amount <= 0) return res.status(400).json({ error: 'Invalid bid amount' });
16
17 // Use raw SQL transaction for SELECT FOR UPDATE (Drizzle doesn't support it natively)
18 const client = await db.$client.connect();
19 try {
20 await client.query('BEGIN');
21
22 // Lock the auction row to prevent concurrent bid races
23 const { rows: [auction] } = await client.query(
24 'SELECT * FROM auctions WHERE id = $1 FOR UPDATE',
25 [auctionId]
26 );
27
28 if (!auction) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Auction not found' }); }
29 if (auction.status !== 'active') { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction is not active' }); }
30 if (new Date(auction.end_time) <= new Date()) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction has ended' }); }
31 if (auction.seller_id === bidderId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Sellers cannot bid on their own auctions' }); }
32
33 const minBid = Math.max(auction.starting_price, auction.current_bid + MIN_BID_INCREMENT);
34 if (amount < minBid) {
35 await client.query('ROLLBACK');
36 return res.status(400).json({ error: `Minimum bid is $${(minBid / 100).toFixed(2)}` });
37 }
38
39 // Insert the bid
40 await client.query(
41 'INSERT INTO bids (auction_id, bidder_id, amount) VALUES ($1, $2, $3)',
42 [auctionId, bidderId, amount]
43 );
44
45 // Anti-sniping: extend end_time if bid is within 2 minutes of end
46 const now = new Date();
47 const endTime = new Date(auction.end_time);
48 let newEndTime = endTime;
49 if (endTime - now < ANTI_SNIPE_WINDOW_MS) {
50 newEndTime = new Date(now.getTime() + ANTI_SNIPE_EXTENSION_MS);
51 }
52
53 // Update current_bid, bid_count, and optionally end_time
54 await client.query(
55 'UPDATE auctions SET current_bid = $1, bid_count = bid_count + 1, end_time = $2 WHERE id = $3',
56 [amount, newEndTime, auctionId]
57 );
58
59 await client.query('COMMIT');
60 res.json({ success: true, newBid: amount, newEndTime, antiSniped: newEndTime > endTime });
61 } catch (err) {
62 await client.query('ROLLBACK');
63 console.error('Bid placement error:', err);
64 res.status(500).json({ error: 'Bid placement failed' });
65 } finally {
66 client.release();
67 }
68}

Pro tip: The SELECT FOR UPDATE acquires a row-level lock on the auction. Any other bid request for the same auction will wait until this transaction completes. This prevents the race condition where two simultaneous bids at the same amount both 'succeed'.

Expected result: Concurrent bid requests are serialized correctly. If two requests arrive at the same time for the same auction, only one succeeds. The other gets a 'Minimum bid is...' error because the first bid already raised current_bid.

4

Add the Stripe post-auction payment flow

Auctions don't charge at bid time — the winner pays after the auction ends. When the winner clicks 'Pay Now', create a Stripe Checkout Session for the final bid amount. The webhook confirms payment.

server/routes/payment.js
1import Stripe from 'stripe';
2const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
3
4// POST /api/auctions/:id/pay — winner initiates payment
5export async function initiatePayment(req, res) {
6 const auctionId = parseInt(req.params.id);
7 const userId = req.get('X-Replit-User-Id');
8
9 const [auction] = await db.select().from(auctions).where(eq(auctions.id, auctionId));
10 if (!auction || auction.status !== 'ended') {
11 return res.status(400).json({ error: 'Auction is not in ended status' });
12 }
13
14 // Verify the requesting user is the highest bidder
15 const [winningBid] = await db
16 .select()
17 .from(bids)
18 .where(eq(bids.auctionId, auctionId))
19 .orderBy(sql`amount DESC`)
20 .limit(1);
21
22 if (!winningBid || winningBid.bidderId !== userId) {
23 return res.status(403).json({ error: 'Only the winning bidder can pay' });
24 }
25
26 // Check reserve price
27 if (auction.reservePrice && auction.currentBid < auction.reservePrice) {
28 return res.status(400).json({ error: 'Reserve price was not met — auction is unsold' });
29 }
30
31 const session = await stripe.checkout.sessions.create({
32 payment_method_types: ['card'],
33 mode: 'payment',
34 line_items: [{
35 price_data: {
36 currency: 'usd',
37 unit_amount: auction.currentBid,
38 product_data: { name: auction.title, description: `Winning bid for auction #${auctionId}` },
39 },
40 quantity: 1,
41 }],
42 success_url: `${process.env.APP_URL}/auctions/${auctionId}/confirmation?session_id={CHECKOUT_SESSION_ID}`,
43 cancel_url: `${process.env.APP_URL}/auctions/${auctionId}`,
44 metadata: { auctionId: String(auctionId), bidderId: userId },
45 });
46
47 res.json({ url: session.url });
48}
49
50// POST /api/webhooks/stripe — handle payment confirmation
51// MUST use express.raw({ type: 'application/json' }) — register BEFORE express.json()
52export function stripeWebhook(req, res) {
53 const sig = req.headers['stripe-signature'];
54 let event;
55
56 try {
57 // constructEvent is synchronous (Node.js) — NOT constructEventAsync (Deno-only)
58 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
59 } catch (err) {
60 return res.status(400).json({ error: `Webhook signature failed: ${err.message}` });
61 }
62
63 if (event.type === 'checkout.session.completed') {
64 const session = event.data.object;
65 const { auctionId } = session.metadata;
66 db.update(auctions)
67 .set({ status: 'sold' })
68 .where(eq(auctions.id, parseInt(auctionId)))
69 .catch(console.error);
70 }
71
72 res.json({ received: true });
73}

Pro tip: Register the Stripe webhook route BEFORE express.json() middleware in server/index.js. If express.json() runs first, it parses the body and Stripe's signature verification fails because it needs the raw bytes.

Expected result: The winning bidder hits POST /api/auctions/:id/pay and gets redirected to Stripe Checkout. After payment, the webhook fires and updates auction status to 'sold'.

5

Deploy on Reserved VM for always-on bidding

Unlike most apps, auction platforms cannot tolerate cold starts. A 15-second cold start during the last 30 seconds of a heated auction would be catastrophic. Reserved VM keeps the bid endpoint always-on.

prompt.txt
1// Deploy steps:
2// 1. Click Deploy in Replit top-right → choose Reserved VM (not Autoscale)
3// 2. Select the $10/month tier for low-traffic auctions
4//
5// In Deployment Secrets (separate from workspace Secrets), add:
6// STRIPE_SECRET_KEY=sk_test_... (or sk_live_... for production)
7// STRIPE_WEBHOOK_SECRET=whsec_... (get this from Stripe Dashboard)
8// APP_URL=https://your-deployed-url.replit.app
9//
10// Register webhook in Stripe Dashboard:
11// Developers → Webhooks → Add endpoint
12// URL: https://your-deployed-url.replit.app/api/webhooks/stripe
13// Events to listen for: checkout.session.completed
14//
15// Test the full flow in Stripe test mode:
16// - Create an auction (status = active, end_time = 5 minutes from now)
17// - Place a bid from a second account (private browser)
18// - After end_time, click Pay Now as the winner
19// - Use Stripe test card: 4242 4242 4242 4242, any future expiry, any CVC
20// - Check that auction status changes to 'sold' after payment

Pro tip: Add a scheduled check that runs every minute using setInterval on Reserved VM. It queries for auctions where end_time < now() and status = 'active', then updates their status to 'ended'. This is the automatic auction closure mechanism.

Expected result: The deployed auction platform responds to bid requests within 100ms. Stripe webhooks are received and processed. Auctions transition automatically from 'active' to 'ended' when their end_time passes.

Complete code

server/routes/bids.js
1import { db } from '../db.js';
2import { auctions, bids } from '../../shared/schema.js';
3
4const MIN_INCREMENT = 100; // $1.00 minimum bid increment
5const SNIPE_WINDOW = 120000; // 2 minutes in milliseconds
6const SNIPE_EXTENSION = 120000; // extend by 2 minutes
7
8export async function placeBid(req, res) {
9 const auctionId = parseInt(req.params.id);
10 const bidderId = req.get('X-Replit-User-Id');
11 const amount = parseInt(req.body.amount);
12
13 if (!bidderId) return res.status(401).json({ error: 'Not authenticated' });
14 if (!amount || isNaN(amount) || amount <= 0) {
15 return res.status(400).json({ error: 'amount must be a positive integer in cents' });
16 }
17
18 const client = await db.$client.connect();
19 try {
20 await client.query('BEGIN');
21 const { rows: [auction] } = await client.query(
22 'SELECT * FROM auctions WHERE id = $1 FOR UPDATE', [auctionId]
23 );
24
25 if (!auction) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Auction not found' }); }
26 if (auction.status !== 'active') { await client.query('ROLLBACK'); return res.status(400).json({ error: `Auction status is '${auction.status}', not active` }); }
27 if (new Date(auction.end_time) <= new Date()) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Auction has ended' }); }
28 if (auction.bidder_id === bidderId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Cannot bid on your own auction' }); }
29
30 const minBid = Math.max(auction.starting_price, (auction.current_bid || 0) + MIN_INCREMENT);
31 if (amount < minBid) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Minimum bid: $${(minBid / 100).toFixed(2)}` }); }
32
33 await client.query('INSERT INTO bids (auction_id, bidder_id, amount) VALUES ($1, $2, $3)', [auctionId, bidderId, amount]);
34
35 const now = Date.now();
36 const endTime = new Date(auction.end_time);
37 const extended = (endTime - now) < SNIPE_WINDOW;
38 const newEndTime = extended ? new Date(now + SNIPE_EXTENSION) : endTime;
39
40 await client.query('UPDATE auctions SET current_bid=$1, bid_count=bid_count+1, end_time=$2 WHERE id=$3', [amount, newEndTime, auctionId]);
41 await client.query('COMMIT');
42
43 res.json({ success: true, amount, newEndTime, extended });
44 } catch (err) {
45 await client.query('ROLLBACK');
46 res.status(500).json({ error: 'Bid failed' });
47 } finally {
48 client.release();
49 }
50}

Customization ideas

Proxy bidding (automatic bidding)

Let users set a maximum bid. When someone bids, the system automatically counters up to the proxy maximum. Store max_proxy_bid per user per auction and apply it in the bid placement transaction.

Buy It Now price

Add a buy_now_price column to auctions. If a buyer pays the buy-it-now price directly (bypassing the auction), end the auction immediately and mark it as sold. Create a Stripe Checkout Session at the buy-it-now price.

Seller analytics dashboard

Add a /api/me/seller/stats route that returns total auctions created, auctions sold vs unsold, total revenue, average bid count per auction, and most popular category.

Common pitfalls

Pitfall: Deploying on Autoscale instead of Reserved VM

How to avoid: Deploy auction platforms on Reserved VM. The always-on server is essential for the integrity of the bidding process. The $10/month cost is negligible for a real auction platform.

Pitfall: Not using SELECT FOR UPDATE on bid placement

How to avoid: Use a PostgreSQL transaction with SELECT ... FOR UPDATE on the auction row. This serializes concurrent bid requests and ensures only one can read-validate-insert at a time.

Pitfall: Registering the Stripe webhook route after express.json()

How to avoid: In server/index.js, register app.post('/api/webhooks/stripe', express.raw({type:'application/json'}), stripeWebhook) BEFORE app.use(express.json()).

Best practices

  • Store all prices as integers in cents — never store $10.50 as a float, always as 1050.
  • Wrap bid placement in a BEGIN / SELECT FOR UPDATE / INSERT / COMMIT transaction to prevent race conditions.
  • Deploy on Reserved VM for auction platforms — cold starts during live bidding are unacceptable.
  • Implement anti-sniping in the same transaction as the bid insert — never as a separate async operation.
  • Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET to Deployment Secrets separately from workspace Secrets.
  • Use constructEvent() (synchronous) not constructEventAsync() for Stripe webhook verification in Node.js.
  • Run an auction-closure setInterval every 60 seconds to transition expired active auctions to 'ended' status.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an auction platform with Express and PostgreSQL using Drizzle ORM. I need to implement a bid placement endpoint that prevents race conditions when multiple users bid simultaneously. Help me write the transaction logic using SELECT FOR UPDATE to lock the auction row, validate the new bid amount is greater than current_bid plus the minimum increment, insert the bid, update current_bid and bid_count atomically, and implement anti-sniping by extending end_time by 2 minutes if the bid arrives within 2 minutes of end_time.

Build Prompt

Add a bid notification system to the auction platform. When a user is outbid (someone places a higher bid on an auction they previously bid on), call the Twilio API to send them an SMS alert: 'You've been outbid on [title]. Current bid: $X. Bid now: [link]'. Store TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER in Replit Secrets. Trigger the notification in the bid placement transaction after updating current_bid.

Frequently asked questions

How do I handle the case where nobody bids on an auction?

In the auction closure setInterval, if an auction's end_time has passed and bid_count is 0 (or current_bid is less than reserve_price), set status to 'unsold'. The seller's dashboard should show unsold auctions with an option to relist them.

What is the reserve price and how does it work?

The reserve price is the minimum amount the seller is willing to accept. It's hidden from bidders. When the auction ends, if current_bid is less than reserve_price, the auction is marked 'unsold' and no payment is collected. Show a 'Reserve not met' label in the UI when this happens.

Can I accept payments at bid time instead of after the auction?

Yes, but it's more complex. You'd collect a payment authorization (not capture) at bid time with Stripe PaymentIntents in manual capture mode. When the bidder is outbid, release the authorization. When the auction ends, capture only the winner's authorization. This requires careful handling of multiple active authorizations.

Why use polling instead of WebSockets for real-time bid updates?

WebSockets require persistent connections, which work well on Reserved VM but are tricky to implement correctly with reconnection logic. Polling every 5 seconds is simpler, sufficiently responsive for most auctions, and works on any deployment type. Reduce the polling interval to 2 seconds in the final 5 minutes for a more exciting experience.

Do I need Replit Core for this build?

Yes. Replit Auth (used for bidder/seller authentication) requires Replit Core or higher. The built-in PostgreSQL is also included in Core. Additionally, Reserved VM deployment (needed for always-on bidding) requires Core.

Can RapidDev help me build a custom auction platform?

Yes. RapidDev has built 600+ apps including auction and marketplace platforms with advanced features like proxy bidding, Stripe Connect payouts to sellers, and fraud detection. Contact us for a free consultation.

How do I go from Stripe test mode to live mode?

In Stripe Dashboard, toggle from Test to Live mode. Go to Stripe Marketplace, install the Replit Integrated Payments app to activate your account. Then swap STRIPE_SECRET_KEY (sk_test_ → sk_live_) and STRIPE_WEBHOOK_SECRET in your Deployment Secrets. Register a new live webhook endpoint in Stripe Dashboard pointing to your deployed URL.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.