Build a Live Auction
This guide walks through building a live auction where all bids are validated and published by the server. Viewers get read-only pipes, bids go through a server API, and access is revoked when the auction ends.
An auction needs three event types: individual bid notifications, periodic status updates with the current price, and a final event when the auction closes. All of these are published by the server — clients never publish directly.
// lib/realtime-events.ts
import { z } from 'zod';
export const realtimeEvents = {
'bid.placed': z.object({
bidId: z.string(),
amount: z.number(),
bidderName: z.string(),
placedAt: z.number(),
}),
'auction.status': z.object({
currentPrice: z.number(),
bidCount: z.number(),
endsAt: z.number(),
}),
'auction.ended': z.object({
winnerId: z.string(),
winnerName: z.string(),
finalPrice: z.number(),
}),
} as const;Viewers only need subscribe access. By omitting publish from the pipe permissions, clients have no way to push events — even if they try, the API rejects it. This is the key pattern for server-authoritative data: the pipe is a one-way broadcast channel.
// app/api/realtime/[...all]/route.ts
import { createPipeHandler } from 'hotpipe/server';
export const { POST } = createPipeHandler({
secret: process.env.HOTPIPE_SECRET!,
authorize: async (req) => {
const { session } = await getAuth(req);
if (!session) return null;
const auctionId = await getActiveAuctionId(session.userId);
return {
userId: session.userId,
pipes: {
// subscribe only — bids are submitted through the server, not published by clients
[`auction-${auctionId}`]: { subscribe: true },
},
};
},
});Subscribe to the auction pipe and display incoming bids in real time. The auction.status event keeps the current price updated between bids.
function BidFeed({ auctionId }: { auctionId: string }) {
const [bids, setBids] = useState<Bid[]>([]);
const [currentPrice, setCurrentPrice] = useState(0);
usePipe(`auction-${auctionId}`, {
'bid.placed': (data) => {
setBids((prev) => [data, ...prev]);
},
'auction.status': (data) => {
setCurrentPrice(data.currentPrice);
},
});
return (
<div>
<div className="text-2xl font-bold">
Current price: ${currentPrice.toLocaleString()}
</div>
<ul>
{bids.map((bid) => (
<li key={bid.bidId}>
{bid.bidderName} bid ${bid.amount.toLocaleString()}
</li>
))}
</ul>
</div>
);
}Bids go through a regular API route, not through the pipe. The server validates the bid amount, saves it to the database, then publishes both a bid.placed event and an updated auction.status event. This ensures every bid is validated before anyone sees it.
// app/api/bids/route.ts
import { realtime } from '@/lib/realtime-server';
export async function POST(req: Request) {
const { session } = await getAuth(req);
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const { auctionId, amount } = await req.json();
// validate the bid server-side
const auction = await db.auctions.findById(auctionId);
if (!auction || auction.status !== 'active') {
return Response.json({ error: 'Auction not active' }, { status: 400 });
}
if (amount <= auction.currentPrice) {
return Response.json({ error: 'Bid too low' }, { status: 400 });
}
// save the bid
const bid = await db.bids.create({
auctionId,
userId: session.userId,
amount,
});
// broadcast to all viewers
const pipe = realtime.pipe(`auction-${auctionId}`);
await pipe.publish('bid.placed', {
bidId: bid.id,
amount: bid.amount,
bidderName: session.userName,
placedAt: Date.now(),
});
await pipe.publish('auction.status', {
currentPrice: amount,
bidCount: auction.bidCount + 1,
endsAt: auction.endsAt,
});
return Response.json({ success: true });
}The client submits bids with a regular fetch call. No pipe publishing needed — the server handles the broadcast after validation.
function PlaceBid({ auctionId }: { auctionId: string }) {
const [amount, setAmount] = useState('');
const [error, setError] = useState('');
async function submitBid() {
setError('');
const res = await fetch('/api/bids', {
method: 'POST',
body: JSON.stringify({ auctionId, amount: Number(amount) }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error);
}
setAmount('');
}
return (
<form onSubmit={(e) => { e.preventDefault(); submitBid(); }}>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Enter bid amount"
/>
<button type="submit">Place bid</button>
{error && <p className="text-red-500">{error}</p>}
</form>
);
}When the auction ends, publish an auction.ended event with the winner details. The client listens for this event and swaps the bidding UI for a results screen.
// app/api/auctions/[id]/end/route.ts
import { realtime } from '@/lib/realtime-server';
import { pipeAdmin } from '@/lib/realtime-admin';
export async function POST(req: Request, { params }: { params: { id: string } }) {
const auction = await db.auctions.findById(params.id);
if (!auction) return Response.json({ error: 'Not found' }, { status: 404 });
// determine the winner
const winningBid = await db.bids.findHighest(auction.id);
// update the auction record
await db.auctions.update(auction.id, { status: 'ended' });
// notify all viewers that the auction is over
await realtime.pipe(`auction-${auction.id}`).publish('auction.ended', {
winnerId: winningBid.userId,
winnerName: winningBid.userName,
finalPrice: winningBid.amount,
});
return Response.json({ success: true });
}function AuctionRoom({ auctionId }: { auctionId: string }) {
const [winner, setWinner] = useState<{ name: string; price: number } | null>(null);
usePipe(`auction-${auctionId}`, {
'bid.placed': (data) => { /* ... */ },
'auction.status': (data) => { /* ... */ },
'auction.ended': (data) => {
setWinner({ name: data.winnerName, price: data.finalPrice });
},
});
if (winner) {
return (
<div className="text-center">
<h2>Auction ended</h2>
<p>{winner.name} won with a bid of ${winner.price.toLocaleString()}</p>
</div>
);
}
return (
<div>
<BidFeed auctionId={auctionId} />
<PlaceBid auctionId={auctionId} />
</div>
);
}After the auction ends, revoke all viewer access to the pipe. This disconnects everyone cleanly and prevents stale connections from lingering. Use revokeBatch to revoke all viewers in a single call.
// after the auction ends, revoke everyone's access to the pipe
const viewers = await db.auctionViewers.findByAuctionId(auction.id);
await pipeAdmin.revokeBatch(
viewers.map((v) => ({ userId: v.userId, pipe: `auction-${auction.id}` }))
);
