Build multi-modal AI applications using our new open-source Vision AI SDK.

React Live Shopping Tutorial

The following tutorial shows you how to build a live shopping app with Stream's Video and Chat React SDKs. We'll generate the app in one shot from a design document using Stream's AI agent skill, then walk through the code it produces.

In this tutorial we'll build VinylStream, a live shopping app for buying and selling vinyl records, in the style of Whatnot. A seller goes live on camera and sells records one at a time as short, timed auctions. Buyers watch the livestream, chat alongside it, and bid in real time.

There's a twist in how we'll build it. Instead of typing the app in piece by piece, we'll hand a short design document to Stream's AI agent skill and let it generate the whole app in one shot. That's how the real VinylStream demo was built. The rest of the tutorial walks through the generated code, focusing on the parts that matter in any live shopping app:

  • Broadcasting a livestream as the seller, and watching it as a buyer
  • Live chat next to the stream
  • The synced product catalog: keeping the item on screen, and its live price, in sync for every viewer. This is the heart of live shopping, and where we'll spend the most time.
  • Bidding, buy-it-now, and a stubbed checkout

You don't need prior experience with Stream. Whenever the code touches a Stream API, we'll stop and explain what it does.

A companion blog post, Building a Live Shopping App with One-Shot AI, covers the design thinking behind the app. This tutorial is the hands-on counterpart: run it, then read the code.

You can try the finished app live right now. The full source is on GitHub: clone and run the reference app directly at any point, or generate your own copy in Step 2.

Step 0 - Prepare your environment

You'll need:

  • Node.js 20 or higher
  • A free Stream account. It gives you the API key and secret the app authenticates with.
  • The Stream CLI (getstream), which handles project setup and installs the agent skills
  • An AI coding tool that supports agent skills, such as Claude Code, Cursor, or Codex

Step 1 - The design document

Everything the agent builds in Step 2 comes from one short markdown file: docs/vinylstream-concept.md. It's worth reading before you run anything, because it's effectively the source code for the whole app.

The document starts by naming the pattern the app is built around:

VinylStream is a demo application showing how to build a live shopping experience in the style of Whatnot, focused on a single selling vertical: vinyl records.

The vinyl vertical is chosen deliberately: every record is a specific, identifiable object (a particular pressing, in a particular condition), which makes it a strong showcase for the pattern at the heart of most live commerce apps - binding canonical product data to a live video stream.

It then lists the app's building blocks, and for each one, says where the truth lives:

  • Video. The seller broadcasts; buyers get low-latency playback.
  • Chat. Realtime chat alongside the stream.
  • Auction state. The current price, high bidder, and countdown. This state lives on the server, and only on the server. Updates may travel over the same realtime channel as chat, but neither the client nor the video stream is ever the source of truth.
  • Catalog. A small local database of real vinyl releases, built from the Discogs data dump. It powers search, item details, and market price ranges without any runtime network dependency.
  • Commerce. Orders, payouts, and shipping. Mocked in the demo.

Finally, the document pins down the demo's scope. The seller flow is: create a show, add a handful of records from the catalog, and go live. The buyer flow is: join the stream, see the item currently on the block, bid, win, and check out from a cart. Payments, shipping, and notifications are explicitly mocked.

That's the entire brief. There are no screens, no component lists, and no API signatures. It names the core pattern, says where each piece of state lives, and draws the scope line. That turns out to be exactly enough for an agent to build from.

Step 2 - Generate the app with Stream's AI agent skill

Create a project folder and put the design document in it:

Terminal (bash)
1
2
3
mkdir vinylstream && cd vinylstream mkdir docs curl -o docs/vinylstream-concept.md https://raw.githubusercontent.com/GetStream/vinylstream-live-shopping-react-demo/main/docs/vinylstream-concept.md

Next, install Stream's agent skills. A skill is a set of instructions that teaches your AI coding tool how to work with a specific technology. The stream-builder skill knows how to scaffold a Next.js app and wire up Stream's SDKs correctly, and the stream-cli skill lets the agent manage credentials and users through the getstream CLI:

Terminal (bash)
1
2
npx skills add https://github.com/getstream/agent-skills --skill stream-cli npx skills add https://github.com/getstream/agent-skills --skill stream-builder

Run getstream init to log in and pick (or create) the Stream app this project will use. Then open your AI coding tool in the project folder and give it one prompt:

Prompt (markdown)
1
Build me the app described in docs/vinylstream-concept.md using the stream-builder skill.

The agent scaffolds a Next.js app, installs the two Stream SDKs (@stream-io/video-react-sdk for video, stream-chat-react for chat), and works through the design document layer by layer. Generating the reference app took about 90k output tokens with Claude Opus; other current models land in a similar range.

Generation isn't deterministic, so your copy won't match the reference app line for line. It will implement the same design, though, and your code will look recognizably like the excerpts in the rest of this tutorial.

When the agent finishes, start the app:

Terminal (bash)
1
npm run dev

Open http://localhost:3000 and sign in with any display name. Each browser tab acts as a separate user, so open the app in two tabs: host a show in one and watch it from the other. That two-tab setup is the easiest way to see everything in this tutorial working live.

Want to skip generation and run the finished app instead? Clone the reference implementation with git clone https://github.com/GetStream/vinylstream-live-shopping-react-demo.git, run npm install, copy .env.example to .env and fill in your Stream API key and secret, then npm run dev. All code excerpts below come from this reference implementation.

Step 3 - Auth and the Stream clients

Stream authenticates users with tokens. Your server holds the API secret and uses it to mint a token for each user; the browser then connects to Stream with that token, and the secret never leaves the server.

VinylStream does this in one small API route. When you sign in with a name, the route registers the user with both Stream products and returns a token for each. upsertUsers means "create this user, or update it if it already exists", so the same route works for new and returning users:

app/api/token/route.ts (ts)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export async function GET(req: NextRequest) { const raw = req.nextUrl.searchParams.get("user_id") if (!raw) return NextResponse.json({ error: "user_id required" }, { status: 400 }) const name = cleanLabel(raw) const userId = tenantUser(await getTenant(), name) await Promise.all([ chatServer.upsertUsers([{ id: userId, name, role: "user" }]), videoServer.upsertUsers([{ id: userId, name, role: "user" }]), ]) return NextResponse.json({ apiKey: process.env.STREAM_API_KEY, userId, name, chatToken: chatServer.createToken(userId), videoToken: videoServer.generateUserToken({ user_id: userId }), }) }

One detail you can safely ignore: tenantUser prefixes the user id with a per-visitor sandbox id, so that visitors to the public demo can't see each other's shows. That's demo plumbing, not a Stream requirement. The part worth copying is the shape: a single server-side route that mints both a chat token and a video token, using the StreamChat and StreamClient server SDKs.

Back in the browser, the app creates one chat client and one video client when you sign in, at the top of the component tree, and every screen shares them:

components/stream-providers.tsx (tsx)
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
const chatClient = useCreateChatClient({ apiKey: auth.apiKey, tokenOrProvider: auth.chatToken, userData: { id: auth.userId, name: auth.name }, }) const [videoClient, setVideoClient] = useState<StreamVideoClient>() useEffect(() => { const tokenProvider = () => fetch(`/api/token?user_id=${encodeURIComponent(auth.name)}`) .then((r) => r.json()) .then((d) => d.videoToken as string) const c = StreamVideoClient.getOrCreateInstance({ apiKey: auth.apiKey, user: { id: auth.userId, name: auth.name }, tokenProvider, }) setVideoClient(c) }, [auth.apiKey, auth.userId, auth.name]) return ( <Chat client={chatClient} theme={themeClass}> <StreamVideo client={videoClient}>{children}</StreamVideo> </Chat> )

Two things to note here:

  • useCreateChatClient is the chat SDK's hook for creating a client and connecting the user. It handles cleanup on unmount for you.
  • The video client receives a tokenProvider instead of a fixed token. That's a callback the SDK invokes on its own whenever the token expires, so long sessions never get logged out mid-show.

The <Chat> and <StreamVideo> providers make these clients available to every Stream component further down the tree. The rule to take away: create one client per product per session, at the app shell, and never per screen.

Step 4 - Broadcasting the show (seller)

In VinylStream, a "show" starts as an ordinary database row the seller creates from a form. The interesting moment is when the seller clicks go live. On the server, that does two things: it creates the show's chat channel, and it marks the show live in the database:

app/api/shows/[id]/golive/route.ts (ts)
1
2
await ensureShowChannel(show.channel_id, show.seller_id, show.title) await setShowStatus(id, "live")

A channel is Stream Chat's unit of conversation. Like everything in Stream, a channel has a type and an id: the type (here livestream) sets defaults and permissions appropriate for a broadcast audience, and the id identifies this particular show's chat. Keep this channel in mind, because in Step 8 it will carry more than chat.

The video side happens in the seller's browser. Stream Video's unit is the call, and calls also have a type and an id. The seller's page creates the call, joins it as host, and turns on the camera and microphone:

app/seller/[id]/live/page.tsx (tsx)
1
2
3
4
5
6
7
8
const c = videoClient.call("livestream", id) const joined = c .join({ create: true, data: { members: [{ user_id: auth.userId, role: "host" }] } }) .then(async () => { setCall(c) await c.camera.enable() await c.microphone.enable() })

call.join({ create: true }) registers the call on Stream's servers if it doesn't exist yet, and opens the realtime connection for audio and video in the same step.

Notice the ids: the show's database id doubles as both the call id and the channel id. Any page that knows which show it's on can derive both, with no lookups.

To render the seller's own preview, the app uses the SDK's built-in LivestreamLayout component (see Watching a livestream) with the standard call controls overlaid:

app/seller/[id]/live/page.tsx (tsx)
1
2
3
4
5
6
<StreamCall call={call}> <LivestreamLayout showLiveBadge showParticipantCount showDuration enableFullScreen={false} /> <div className="absolute right-0 bottom-2 left-0 flex justify-center"> <CallControls /> </div> </StreamCall>

That's the whole broadcast. The livestream call type gives you low-latency delivery to any number of viewers, replicated across Stream's edge network. Everything we add from here (chat, the catalog, the auction) is application logic layered on top of this call.

Step 5 - Watching the livestream (buyer)

On the buyer's side, the video is a single component:

app/watch/[id]/page.tsx (tsx)
1
<LivestreamPlayer callType="livestream" callId={id} />

LivestreamPlayer finds the call by type and id and plays it. The buyer never publishes anything and never touches the camera; they're only receiving.

The buyer does connect to the show's chat channel, though. Stream Chat distinguishes joining a channel (becoming a member) from watching it, which means receiving its messages and events live without being a member. Viewers of a livestream watch:

app/watch/[id]/page.tsx (tsx)
1
2
3
4
5
6
7
8
useEffect(() => { if (!chatClient) return const ch = chatClient.channel("livestream", id) ch.watch().then(() => setChannel(ch)) return () => { ch.stopWatching().catch(() => {}) } }, [chatClient, id])

Note the cleanup: the effect stops watching when the buyer navigates away. And remember, this channel will do double duty. It carries the visible chat from Step 7, and in Step 8 it becomes the pipe that delivers auction updates to everyone watching.

Step 6 - The synced product catalog

Now for the heart of the app. Live shopping only works if every viewer agrees on what's being sold right now and what it costs. That's the "synced product catalog" from the design document.

The catalog itself is ordinary application data, with no Stream involvement. VinylStream ships a small local database of real vinyl releases, modeled the way Discogs models them: a master represents the album, and each release is a specific pressing from a particular year, country, and label, with its own market price range. A production app would build the same database from the Discogs monthly data dump; the design document's "Why Discogs" section explains that choice.

When a seller adds a record to a show, they pick the exact pressing they physically have, then add the things no catalog can know: condition grades, personal notes, and a starting bid or buy-it-now price. The listing stores a reference back to the release, and every screen reads the two joined together. Canonical product data and live listing data are never duplicated, so they can never drift apart.

The result is the item card both sides see during the show: cover, pressing details, and the Discogs market value range, with the live price right underneath. Keeping that live price in sync for every viewer is Stream's job, and it's covered in Step 8.

Step 7 - Live chat

The chat panel is a thin wrapper around Stream's prebuilt React Chat components, pointed at the channel the buyer is already watching:

components/show-chat.tsx (tsx)
1
2
3
4
5
6
<Channel channel={channel}> <Window> <MessageList /> <MessageComposer /> </Window> </Channel>

That really is the entire component. The message list, the input, history, and typing indicators all come from the SDK. There is no custom chat logic anywhere in VinylStream.

Step 8 - Auction state, bidding, and buy-it-now

The auction is server-authoritative, exactly as the design document demands. All the auction rules live in plain application code on the server: bid validation, the minimum increment, the anti-snipe rule that extends the countdown when a bid lands in the final seconds, and buy-it-now's first-claim-wins. A client only ever sends an intent ("bid $30", "claim this") and reads back what the server decided. Nothing about who's winning is ever taken from a client message.

Stream's role here is delivery. When the server commits a change, every viewer needs to see it immediately. The server does that by sending a custom event on the show's chat channel. Custom events are Stream Chat's mechanism for pushing your own typed payloads to everyone watching a channel. They arrive over the same realtime connection as chat messages, but they aren't messages, so they never appear in the chat itself:

lib/stream-server.ts (ts)
1
2
3
4
5
export async function broadcast(channelId: string, kind: EventKind, payload: unknown) { const channel = chatServer.channel("livestream", channelId) const event = { type: kind, user_id: SYSTEM_USER_ID, data: JSON.stringify(payload) } await channel.sendEvent(event as unknown as Parameters<typeof channel.sendEvent>[0]) }

The server-side chat client sends the event, and every browser watching the channel receives it. On the client, one listener turns those events into UI updates for the item card and the show state:

lib/use-show-state.ts (ts)
1
2
3
4
5
6
channel.on((e: Event) => { if (e.type === EVT_AUCTION_UPDATE || e.type === EVT_ITEM_SOLD) { const item = JSON.parse((e as unknown as { data: string }).data).item as ShowItem applyItem(item) } })

This is the design document's "bid events can ride the same realtime channels as chat" made concrete. The channel is a delivery pipe, not the source of truth. The database transaction commits before the event is sent, and the client also polls the server every few seconds as a fallback, so even a missed event can't leave a viewer stuck on a stale price. Being outbid works through the same event: when an update arrives showing someone else as high bidder, the app raises a toast.

Checkout is mocked, as the design document required, so there's no Stream call to show there. By checkout time, the auction's results (winner and final price) have already reached every client through the flow above.

Recap

To recap what we've learned:

  • The design document was the real input to the build: one page naming the core pattern, where each piece of state lives, and what's out of scope.
  • Stream's stream-builder agent skill generated the whole app from that document in one shot, and we read the generated code instead of retyping it.
  • One server route mints both tokens; one chat client and one video client are created at the app shell and shared by every screen.
  • The seller broadcasts by joining a livestream call; buyers play it with LivestreamPlayer.
  • The product catalog is plain local data joined onto each listing. No Stream calls involved.
  • Auction rules run server-side only. Results reach every viewer as custom events on the show's chat channel, with polling as a fallback.

The hard realtime work in this app (fanning a broadcast out to every viewer, delivering chat and events reliably) belongs to Stream Video and Stream Chat, and thanks to the agent skill, even the wiring didn't need to be typed by hand. What's left is the part only you can build: your catalog, your auction rules, your checkout.

We hope you enjoyed this tutorial. If you have questions or feedback, let us know via the feedback button. For the design thinking behind the document in Step 1, read Building a Live Shopping App with One-Shot AI. The full source for VinylStream, including the Discogs ETL script and the sandboxing setup we skipped over, is on GitHub.

Final Thoughts

In this video app tutorial we combined Stream's Video and Chat React SDKs with an AI agent skill to build a fully functioning live shopping app: a synced product catalog, a server-authoritative auction, and realtime chat, generated from a one-page design document.

Both the video SDK for React and the API have plenty more features available to support more advanced use-cases.

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.