Build a Real-Time Bidding System With Next.js and Stream

Real-time applications are becoming more vital than ever in today’s digital world, providing users with instant updates and interactive experiences. Technologies like web sockets and reactive streams enable this type of interaction. They can:
- Show the presence of users and typing indicators (for instance, when an online user is typing or was last seen).
- Read receipts and delivery status.
- Push notifications to users of new messages.
- Add multimedia support and rich messaging (voice notes, file sharing, etc.).
- Thread and group conversations.
Creating real-time applications can be tricky. Users expect a high level of interactivity in their applications.
This tutorial will show how to build a real-time bidding web application with Next.js and the React Chat SDK. This application will use Shadcn for a customizable and responsive UI, Stream React components for real-time updates and messaging, Next.js server-side data fetching for fast and secure auction data rendering and Next.js API routes for seamless bid processing. It will support real-time messaging with dedicated chat channels for different products, automatic posting of bids in the chats to keep all users up to date and a smooth, scalable experience for bidders.
Check out a deployed version of the bidding application here: https://stream-bidding-site.vercel.app/
Stream’s React Chat SDK includes the JavaScript client, which will help us build a fully functional bidding application with support for rich messages, reactions, threads, image uploads, videos and all the above features. The StreamChat
client abstracts API calls into functions and handles state and real-time updates.
Prerequisites
Before you begin, ensure you have the following:
- Node.js and pnpm installed or your preferred package manager
- A Stream account, API key and API secret
- Basic knowledge of Next.js
- A Vercel account to deploy to Vercel and use this in production
Project Setup
We’ve set up a starter code to keep this guide concise and user-friendly. It includes static data, letting you instantly view all products available for bidding without hassle. For the streaming features, we’ll set up Stream React SDKs and the Stream Chat API, so we won’t need to build from scratch. We’ve also integrated Shadcn UI components, delivering a polished interface to explore. With this setup, you’ll hit the ground running.
Start by cloning the starter branch of our repository and installing dependencies:
1 2 3 4 5 6 7 8 |
``` git clone --branch starter-template --single-branch git@github.com:daveclinton/stream-bidding-site.git cd stream-bidding-site pnpm install ``` |
The starter template is already set up with the required dependencies. When you run pnpm install
, it will install:
- The necessary UI components from Shadcn
stream-chat
andstream-chat-react
, which we’ll use to set up Stream’s Chat API and connect our users to the stream
Project Structure
Our project is organized in the following structure:
├── app
│ ├── api # API routes for handling auctions and stream tokens
│ ├── auction # Auction pages for each product
├── components # UI components
├── lib # Utility functions and data
├── public # Static assets
└── types # Type definitions
Step 1: Setting up Stream
Sign up for Stream and create an application. Retrieve your API key and secret from the dashboard.
Create a .env.development.local
file and add the variables:
1 2 3 4 5 6 7 8 9 |
```bash NEXT_PUBLIC_STREAM_KEY=your-key-here STREAM_API_SECRET=your-secret-here NEXT_PUBLIC_API_URL=http://localhost:3000 ``` |
Step 2: Implementing User Authentication
This API route app/api/stream-tone/route.ts
is responsible for generating a Stream Chat authentication token for a user who wants to participate in a bidding auction. It ensures the user exists, creates a messaging channel if necessary, and adds the user to the auction. Here’s how it works:
We retrieve the apiKey
and apiSecret
from environment variables. If either is missing, we return an error response:
1 2 3 4 5 6 7 8 9 10 11 |
``` const apiKey = process.env.NEXT_PUBLIC_STREAM_KEY; const apiSecret = process.env.STREAM_API_SECRET; if (!apiKey || !apiSecret) { console.error("Missing Stream API credentials:", { apiKey, apiSecret }); return NextResponse.json( { error: "Server configuration error" }, { status: 500 } ); } ``` |
Next, we parse the incoming request body to extract the userId
and productId
. If productId
is not provided, it defaults to "product-1"
.
1 2 3 4 5 6 7 |
``` const body = await req.json(); const { userId, productId = "product-1" } = body as { userId?: string; productId?: string; }; ``` |
To ensure it’s successful, we check that a valid userId
is provided. If not, we return a 400
error indicating the missing or invalid userId
.
1 2 3 4 5 6 7 8 |
``` if (!userId || typeof userId !== "string") { return NextResponse.json( { error: "Valid user ID is required" }, { status: 400 } ); } ``` |
To retrieve a product, use the provided productId
to look it up in the PRODUCTS
object. If the product associated with the productId
is found, return its details. If no matching product exists in the PRODUCTS
object, return a 404
error to indicate that the product could not be found.
1 2 3 4 5 6 |
``` const product = PRODUCTS[productId]; if (!product) { return NextResponse.json({ error: "Product not found" }, { status: 404 }); } ``` |
To set up the StreamChat functionality, initialize an instance of the StreamChat client by providing the apiKey
and apiSecret
as parameters. This instance will enable interaction with the StreamChat service using the specified credentials.
const serverClient = StreamChat.getInstance(apiKey, apiSecret);
To manage user data in the chat system, ensure the user is updated by adding or updating their information in the chat service. Use the provided userId
to identify the user and assign them the user
role during this process. This step guarantees that the user’s details are either created if they don’t exist or updated if they already do.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
``` await serverClient.upsertUser({ id: userId, name: userId, role: "user", }); ``` |
To set up a bidding channel for an auction, define the channelId
using the format auction-${productId}
, where productId
uniquely identifies the product being auctioned. Then, attempt to create a channel for bidding with this channelId. Include a try block to handle the creation process, catching any exceptions if the channel already exists to avoid errors and ensure smooth execution.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
``` const channelId = `auction-${productId}`; const channel = serverClient.channel("messaging", channelId, { name: `Bidding for ${product.name}`, product: product, auctionEnd: product.endTime.toISOString(), created_by_id: "system", }); try { await channel.create(); console.log(`Channel ${channelId} created or already exists`); } catch (error) { console.log( "Channel creation error (likely exists):", (error as Error).message ); } |
To include the user in the bidding process, add them as a member of the newly created auction channel. Use their userId to register them within the channel, ensuring they have access to participate in the auction activities.
await channel.addMembers([userId]);
Finally, we calculate an expiration time (seven days from now) and generate a token for the user to authenticate with the chat service.
1 2 3 4 5 6 7 |
``` const expirationTime = Math.floor(Date.now() / 1000) + 604800; const token = serverClient.createToken(userId, expirationTime); ``` |
The generated token is logged with its expiration time, and the response includes the token and product data.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
``` console.log( "Generated token for user:", userId, "expires:", new Date(expirationTime * 1000).toISOString() ); return NextResponse.json({ token, product, }); ``` |
If an error occurs at any step, we catch it, log the details and return a 500
error with the error message.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
``` } catch (error) { const typedError = error as Error; console.error("Stream token error details:", { message: typedError.message, stack: typedError.stack, }); return NextResponse.json( { error: "Failed to process request", details: typedError.message }, { status: 500 } ); } ``` |
Step 3: Fetching Products
This API route retrieves product details from the PRODUCTS
data set. It can return either a single product (by id
) or a list of all products.
We will use the URL
constructor to extract the id
query parameter from the request URL.
const productId = new URL(req.url).searchParams.get("id");
When a productId
is provided, search for the corresponding product within the PRODUCTS
data using the given identifier. If the product is located, proceed with the retrieved information. If no product matches the provided productId
, return a 404
error to indicate that the product could not be found.
1 2 3 4 5 6 7 |
``` if (productId) { const product = PRODUCTS[productId]; if (!product) return NextResponse.json({ error: "Product not found" }, { status: 404 }); return NextResponse.json(product); } ``` |
If no productId is provided, retrieve all products from the PRODUCTS
object by converting it into an array using Object.values()
. This will return the complete list of products for further processing or display.
return NextResponse.json(Object.values(PRODUCTS));
If any errors occur during the process (invalid URL, database errors), we catch them and return a 500
error with a message.
1 2 3 4 5 6 7 8 9 |
``` } catch (error) { return NextResponse.json({ error: "Failed to fetch products" }, { status: 500 }); } ``` |
Step 4: Implementing Real-Time Bidding
In the API route app/api/finalize-auction/route.ts
, finalize an auction by performing two key actions: Send a message to the auction channel to notify participants and update the channel’s status to reflect the auction’s completion.
Begin by extracting the Stream API key and secret from the environment variables. Before proceeding, verify that the NEXT_PUBLIC_STREAM_KEY
and STREAM_API_SECRET
are available, ensuring the StreamChat client can be properly initialized for these operations.
1 2 3 4 5 6 7 8 |
``` const { NEXT_PUBLIC_STREAM_KEY: apiKey, STREAM_API_SECRET: apiSecret } = process.env; if (!apiKey || !apiSecret) { return NextResponse.json({ error: "Server configuration error" }, { status: 500 }); } ``` |
To enable chat functionality for the auction, create an instance of the StreamChat client by initializing it with the API credentials. Use the Stream API key and secret extracted from the environment variables to set up the client, allowing interaction with the chat service for subsequent operations.
const serverClient = StreamChat.getInstance(apiKey, apiSecret);
To process the auction finalization, extract the productId, winner
and amount
from the request body. Verify that all these required fields are present in the request to ensure the necessary information is available to complete the operation successfully.
1 2 3 4 5 6 7 8 |
``` const { productId, winner, amount } = await req.json(); if (!productId || !winner || !amount) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } ``` |
To interact with the auction’s chat, retrieve the associated chat channel using the previously defined channelId (auction-${productId}
). Use the StreamChat client instance to access this channel for further actions, such as sending messages or updating its status.
const channel = serverClient.channel("messaging", auction-${productId});
Conclude the auction process by sending a message via the chat channel to announce the result. Use the StreamChat client and the retrieved channel to broadcast the outcome, including details such as the winner
and amount
, informing all channel members of the finalized auction.
1 2 3 4 5 6 7 8 9 |
``` await channel.sendMessage({ text: `🏆 Auction for ${productId} is over. ${winner} won with $${amount.toFixed(2)}`, user_id: "system", auction_finalized: true, winner, final_amount: amount, }); ``` |
Finalize the auction’s status by updating the channel metadata to mark it as completed. Modify the channel’s data using the StreamChat client, setting an appropriate field (status: 'completed'
) to reflect that the auction has concluded.
1 2 3 4 5 6 7 8 |
``` await channel.update({ auction_status: "completed", winner, final_amount: amount, completed_at: new Date().toISOString(), }); ``` |
To confirm the auction process’s completion, return a successful response to the requester. Use an appropriate HTTP status code (e.g., 200 OK) and a message or data indicating the auction has been finalized.
return NextResponse.json({ success: true, message: "Auction finalized successfully" });
To handle potential issues during the auction finalization, wrap the process in a try-catch block. If any errors occur, catch them and return an error response with an appropriate HTTP status code (500 for server errors) and a message detailing the issue, ensuring the requester is informed of the failure.
1 2 3 4 5 |
``` } catch (error) { return NextResponse.json({ error: "Failed to finalize auction", details: (error as Error).message }, { status: 500 }); } ``` |
Step 5: All Products Interface
On this page, we create an async server component page.tsx
at the root of our layout that fetches products during server-side rendering.
It uses the modern Next.js data fetching pattern with a dedicated getProducts
function.
The imported client components will manage our UI states, search functionality and refresh actions.
This component also contains a dedicated loading skeleton and Suspense for better loading state management.
> 💡
We have already created the
ProductListClient.tsx
andProductsPageSkeleton.tsx
files in the starter template within our components directory. You just need to import and use them here.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
``` import { Suspense } from "react"; import ProductsList from "@/components/ProductListClient"; import { ProductsPageSkeleton } from "@/components/ProductPageSkelton"; import { getAllProducts } from "@/lib/products"; export default async function Page() { const products = await getAllProducts(); return ( <main className="container mx-auto py-12 px-4 max-w-7xl"> <div className="space-y-8"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> <div> <h1 className="text-4xl font-bold tracking-tight">Live Auctions</h1> <p className="text-muted-foreground mt-2"> Discover unique items and place your bids before time runs out </p> </div> </div> <Suspense fallback={<ProductsPageSkeleton />}> <ProductsList initialProducts={products} /> </Suspense> </div> </main> ); } ``` |
At this point, run the development server, go to http://localhost:3000 in a browser, and you should see the list of products:
Step 6: Bidding Page Interface
This page will act as our data-fetching layer for the bidding page of a single product. It will retrieve the product information on the server and delegate the task of rendering the UI to the client component.
In the snippet, the page is accessed via a dynamic route from the previous page that displayed all products. The productId
extracted from the URL parameters is used in the getProductById
function that we already set up to fetch a single product’s data.
In the next section, we’ll implement the ClientBiddingPage
, which holds the client logic of Stream API and all the UI components for our bidding chat interface.
Server-side benefits: Fetching data on the server reduces client-side load, improves SEO and allows direct access to backend resources.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
``` import { Product } from "@/types/product"; import ClientBiddingPage from "./ClientBiddingPage"; import { getProductById } from "@/lib/products"; import { notFound } from "next/navigation"; export default async function ServerBiddingPage({ params, }: { params: Promise<{ productId: string }>; }) { const { productId } = await params; let product: Product | null = null; let error: string | null = null; try { product = await getProductById(productId); if (!product) { notFound(); } } catch (err) { console.error("Failed to fetch product data:", err); error = "Failed to load product information"; } return <ClientBiddingPage product={product} error={error} />; } ``` |
Step 7: Client Bidding Page
This section contains the real-time auction logic implemented using StreamChat. This particular component will allow users to:
- Join the auction rooms for a specific product.
- View product details and auction status.
- Place bids in real time.
- Chat with other participants.
- Track time remaining and auction results.
Managing Bidding State
It’s important to track the connection status with StreamChat, the current auction state, such as bids and remaining time, user interface states and the user’s identity.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
``` const [client, setClient] = useState<StreamChat<DefaultGenerics> | null>( null ); const [channel, setChannel] = useState<StreamChannel<DefaultGenerics> | null>( null ); const [currentBid, setCurrentBid] = useState<number>(0); const [highestBidder, setHighestBidder] = useState<string | null>(null); const [bidInput, setBidInput] = useState<string>(""); const [error, setError] = useState<string | null>(initialError); const [, setIsLoading] = useState<boolean>(false); const [userId, setUserId] = useState<string>(""); const [isConnecting, setIsConnecting] = useState<boolean>(false); const [isJoining, setIsJoining] = useState<boolean>(false); const [timeRemaining, setTimeRemaining] = useState<string>(""); const [isAuctionEnded, setIsAuctionEnded] = useState<boolean>(false); const [winner, setWinner] = useState<string | null>(null); ``` |
Initial Setup on Component Mount
To set up a function that will generate a random user ID, since we have not set up an authentication system in this demo, we’ll need to have this done when the component mounts.
Based on previous conversations, this effect will also set the initial bid amount and check whether the auction has ended.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
``` useEffect(() => { setUserId(`user-${Math.random().toString(36).substring(2, 7)}`); if (product) { setCurrentBid(product.currentBid || product.startingBid); const endTime = new Date(product.endTime); if (endTime <= new Date() || product.status === "ended") { setIsAuctionEnded(true); setTimeRemaining("Auction ended"); } } }, [product]); ``` |
The Auction Timer
Set up a timer that updates every second. This will ensure that it calculates the remaining time until an auction ends and automatically declares a winner when time runs out.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
``` useEffect(() => { if (!product) return; const timer = setInterval(() => { const now = new Date(); const endTime = new Date(product.endTime); const diff = endTime.getTime() - now.getTime(); if (diff <= 0) { clearInterval(timer); setTimeRemaining("Auction ended"); setIsAuctionEnded(true); if (channel && highestBidder) { declareWinner(); } } else { const hours = Math.floor(diff / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((diff % (1000 * 60)) / 1000); if (hours > 0) { setTimeRemaining(`${hours}h ${minutes}m ${seconds}s`); } else { setTimeRemaining(`${minutes}m ${seconds}s`); } } }, 1000); return () => clearInterval(timer); }, [product, channel, highestBidder]); ``` |
Connecting to Stream Chat
To connect to a StreamChat
, we’ll need to fetch a token from our app/api/stream-token/route.ts
and initialize the Stream Chat Client.
This function, which a button triggers, will set up connection monitoring for automatic reconnection and join an auction channel once connected.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
``` const handleConnect = async () => { if (!userId || !product) return; try { setError(null); setIsConnecting(true); // Get authentication token from backend const res = await fetch("/api/stream-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, productId: product.id }), }); if (!res.ok) { const errorData = await res.json(); throw new Error(errorData.error || "Failed to fetch token"); } const { token } = (await res.json()) as { token: string }; const apiKey = process.env.NEXT_PUBLIC_STREAM_KEY; if (!apiKey) { throw new Error("Stream API key is not configured"); } // Disconnect existing user if connected if (client) { await client.disconnectUser(); } // Create and connect new client const chatClient = StreamChat.getInstance<DefaultGenerics>(apiKey); await chatClient.connectUser( { id: userId, name: userId, image: "<https://i.imgur.com/fR9Jz14.png>", // Avatar image }, token ); setClient(chatClient); // Set up reconnection logic chatClient.on((event: Event<DefaultGenerics>) => { if (event.type === "connection.changed" && !event.online) { console.log("Connection lost, attempting to reconnect..."); setError("Connection lost. Reconnecting..."); handleConnect(); } }); await joinChannel(chatClient); } catch (err) { const typedError = err as Error; console.error("Connect error:", typedError.message); setError(`Failed to connect: ${typedError.message}`); } finally { setIsConnecting(false); } }; ``` |
Joining the Auction Channel
This function will create a chat channel unique to the product and load the message history to find the current highest bid. It will also register us as real-time listeners for new bids and auction status updates and parse bid information from regex messages.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
``` const joinChannel = async (chatClient: StreamChat<DefaultGenerics>) => { if (!chatClient.user || !product) { setError("Client not connected or product not available. Please reconnect."); handleConnect(); return; } try { setIsJoining(true); setError(null); // Create or join channel for this specific auction const channelId = `auction-${product.id}`; const chatChannel = chatClient.channel("messaging", channelId, { name: `Bidding for ${product.name}`, product: product, auctionEnd: new Date(product.endTime).toISOString(), }); // Start watching for messages await chatChannel.watch(); setChannel(chatChannel); // Load existing messages and find current highest bid const response = await chatChannel.query({ messages: { limit: 100 } }); const messages = response.messages || []; // Check if auction has already ended const auctionEndMessage = messages.find((msg) => msg.auctionEnd === true); if (auctionEndMessage) { setIsAuctionEnded(true); setWinner((auctionEndMessage.winner as string) || null); if (typeof auctionEndMessage.finalBid === "number") { setCurrentBid(auctionEndMessage.finalBid); } } // Parse bid history from messages const bidMessages: BidMessage[] = messages .map((msg) => { const text = msg.text || ""; const match = text.match(/(\\w+) placed a bid of \\$(\\d+\\.?\\d*)/); if (match) { const [, bidder, amount] = match; return { bidder, amount: Number.parseFloat(amount) }; } return null; }) .filter((bid): bid is BidMessage => bid !== null); // Set current highest bid if (bidMessages.length > 0) { const highestBid = bidMessages.reduce((prev, current) => prev.amount > current.amount ? prev : current ); setCurrentBid(Math.max(highestBid.amount, product.startingBid)); setHighestBidder(highestBid.bidder); } else { setCurrentBid(product.startingBid); } // Listen for new messages/bids chatChannel.on((event: Event<DefaultGenerics>) => { if (event.type === "message.new") { const messageText = event.message?.text || ""; if (event.message?.auctionEnd === true) { setIsAuctionEnded(true); setWinner((event.message.winner as string) || null); return; } const match = messageText.match(/(\\w+) placed a bid of \\$(\\d+\\.?\\d*)/); if (match) { const [, bidder, amount] = match; const bidValue = Number.parseFloat(amount); if (bidValue > currentBid) { setCurrentBid(bidValue); setHighestBidder(bidder); } } }const handleBid = async () => { if (!channel || !product) { setError("Please join the channel first."); return; } if (isAuctionEnded) { setError("This auction has ended."); return; } const bidValue = Number.parseFloat(bidInput); if (isNaN(bidValue)) { setError("Please enter a valid number."); return; } if (bidValue <= currentBid) { setError( `Your bid must be higher than the current bid of $${currentBid.toFixed(2)}.` ); return; } try { setIsLoading(true); setError(null); await channel.sendMessage({ text: `${userId} placed a bid of $${bidValue.toFixed(2)}`, }); setCurrentBid(bidValue); setHighestBidder(userId); setBidInput(""); } catch (err) { const typedError = err as Error; console.error("Bid error:", typedError.message); setError(`Failed to place bid: ${typedError.message}`); } finally { setIsLoading(false); } }; }); } catch (err) { const typedError = err as Error; console.error("Join channel error:", typedError.message); setError(`Failed to join bidding room: ${typedError.message}`); } finally { setIsJoining(false); } }; ``` |
Placing Bids
This bidding method will validate a bid amount by checking if it’s a number higher than the current bid. It will also prevent us from placing bids on ended auctions and will send the bid as a formatted message to the channel. It also updates the local state to reflect the new bid.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
``` const handleBid = async () => { if (!channel || !product) { setError("Please join the channel first."); return; } if (isAuctionEnded) { setError("This auction has ended."); return; } const bidValue = Number.parseFloat(bidInput); if (isNaN(bidValue)) { setError("Please enter a valid number."); return; } if (bidValue <= currentBid) { setError( `Your bid must be higher than the current bid of $${currentBid.toFixed(2)}.` ); return; } try { setIsLoading(true); setError(null); await channel.sendMessage({ text: `${userId} placed a bid of $${bidValue.toFixed(2)}`, }); setCurrentBid(bidValue); setHighestBidder(userId); setBidInput(""); } catch (err) { const typedError = err as Error; console.error("Bid error:", typedError.message); setError(`Failed to place bid: ${typedError.message}`); } finally { setIsLoading(false); } }; ``` |
Auction Finalization
Since the aim of an auction is to sell to the highest bidder, this section will include the metadata about the winning bid and call our final-auction
API to record the auction result.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
``` const declareWinner = async () => { if (!channel || !highestBidder || !product) return; try { await channel.sendMessage({ text: `🎉 Auction ended! ${highestBidder} won with a bid of $${currentBid.toFixed(2)}`, auctionEnd: true, winner: highestBidder, finalBid: currentBid, }); setWinner(highestBidder); await fetch("/api/finalize-auction", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ productId: product.id, winner: highestBidder, amount: currentBid, }), }); } catch (err) { console.error("Failed to declare winner:", err); setError("Failed to finalize auction"); } }; ``` |
StreamChat’s Chat Interface
app/components/ChatInterface.tsx
We have a separate reusable component designed to simplify handling complex real-time communication behind the scenes. It’s a thin wrapper around Stream Chat’s React components that adds auction-specific logic, like disabling chat after the auction ends.
This is the structure of the props that we pass to it:
client
: The Stream Chat client instance that handles the connectionchannel
: The specific auction chat channelisJoining
andisConnecting
: Status flags for UI feedbackhandleConnect
: Function to connect the user to the chatisAuctionEnded
: Flag to disable chat input when the auction ends
It uses the Stream Chat React SDK components that will handle all the complex real-time messaging functionality:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
``` import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { Chat, Channel, Window, ChannelHeader, MessageList, MessageInput, } from "stream-chat-react"; import "stream-chat-react/dist/css/v2/index.css"; import { StreamChat, type Channel as StreamChannel, type DefaultGenerics, } from "stream-chat"; type ChatInterfaceProps = { client: StreamChat<DefaultGenerics> | null; channel: StreamChannel<DefaultGenerics> | null; isJoining: boolean; isConnecting: boolean; handleConnect: () => Promise<void>; isAuctionEnded: boolean; }; export default function ChatInterface({ client, channel, isJoining, isConnecting, handleConnect, isAuctionEnded, }: ChatInterfaceProps) { return ( <div className="w-full md:w-2/3 h-screen"> {client && channel ? ( <div className="h-full"> <Chat client={client} theme="messaging light"> <Channel channel={channel}> <Window> <ChannelHeader /> <MessageList /> <MessageInput disabled={isAuctionEnded} /> </Window> </Channel> </Chat> </div> ) : ( <div className={cn( "flex justify-center items-center h-full", "bg-muted/30" )} > <div className="text-center p-8 max-w-md"> <h2 className="text-xl font-semibold mb-4">Live Auction Chat</h2> <p className="text-muted-foreground mb-6"> Join the auction to view the live bidding chat and interact with other bidders </p> {isJoining ? ( <div className="flex justify-center"> <Loader2 className="h-8 w-8 animate-spin text-primary" /> </div> ) : ( <Button onClick={handleConnect} disabled={isConnecting}> Join Now </Button> )} </div> </div> )} </div> ); } ``` |
Rendering the UI
In the return statement, you’ll just need to add the UI layout we made. We have separated concerns into modular components that do the following:
ProductDetails
: This component shows the details of a particular auction item, and we just parse the data from this component on the server page we made.AuctionStatus
: It shows the status of the component from when it started, the countdown timer, the user and the winner.BiddingInterface
: This contains the input field for entering our bid, and the button lets us establish a connection to a stream chat.ChatInterface
: This provides a real-time chat feature for the auction. These handle all the complex real-time messaging functionality.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
``` return ( <div className="flex flex-col md:flex-row min-h-screen bg-background"> <div className="w-full md:w-1/3 p-6 border-r"> <Button variant="ghost" size="sm" className="mb-6" asChild> <Link href="/"> <ArrowLeft className="mr-2 h-4 w-4" /> Back to All Auctions </Link> </Button> <div className="space-y-6"> <ProductDetails product={product} /> <AuctionStatus isAuctionEnded={isAuctionEnded} timeRemaining={timeRemaining} currentBid={currentBid} highestBidder={highestBidder} winner={winner} userId={userId} /> <BiddingInterface client={client} userId={userId} currentBid={currentBid} isAuctionEnded={isAuctionEnded} isConnecting={isConnecting} isLoading={isJoining} handleConnect={handleConnect} handleBid={handleBid} error={error} winner={winner} bidInput={bidInput} setBidInput={setBidInput} /> </div> </div> <ChatInterface client={client} channel={channel} isJoining={isJoining} isConnecting={isConnecting} handleConnect={handleConnect} isAuctionEnded={isAuctionEnded} /> </div> ); ``` |
Finally, run the development server, go to http://localhost:3000 in a browser, and you should see the list of products. Then click a single product and place a bid:
Step 8: Deploying to Vercel From the Terminal
This is a straightforward step-by-step guide for deploying to Vercel using the command line. It includes setting the environment variables straight from our CLI.
Install the Vercel CLI
npm install -g vercel
Log In to Vercel
vercel login
At the root of our bidding project, just prompt:
vercel
And you will get a prompt with a few questions to set up your project:
Adding Environment Variables
vercel env add
Extending the Application
This application can be further improved by adding new features to make it a real-time, sophisticated application:
- Add user authentication and replace the random IDs we used with proper user accounts.
- Integrate payment gateways for automatic checkout.
- Add notifications to notify users of bidding events and the auction end.
- Add admin controls for sellers to monitor and manage auctions.
- Add visualizations of bid history over time.
Conclusion
In this article, you’ve learned how to build a bidding application and set up Next.js API routes that handle server communications. This familiarity has made it easy for us to build a complex bidding application without independently setting up complex real-time messaging functionalities. We’ve also seen that Stream can power live bid feeds, notify users of outbids or even enable chat between bidders, enhancing engagement.
This stack leverages each tool’s strengths to create a robust, user-friendly experience in a bidding app where speed, reliability and real-time interaction are non-negotiable.