Guides

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}` }))
);

Was this page helpful?