How to Build Video Consultations in Headless E-Commerce with Stream Video SDK

Dumebi Okolo
Dumebi Okolo
Published June 17, 2025

Shoppers crave connection, and nothing builds trust like a real-time video chat with a product expert. In this tutorial, you’ll learn how to build a “Talk to a Product Expert” feature in a headless e-commerce site by combining:

By the end, your customers will browse a product page, click a button, and instantly join a secure 1:1 video room with an expert—no third-party widgets or Zoom pop-ups required.

All the code for this tutorial can be found in this GitHub Repo.

Architecture Overview


Prerequisites

  • Node.js 14+ and npm/yarn
  • Next.js 13 (App Router)
  • A Stream account (API Key & Secret)
  • A Contentful space (Space ID & Delivery API Token)
  • Basic familiarity with React and Next.js

Contentful Setup

  1. Sign up and create a Contentful Space.
  1. Define a “Product” content model with fields:

    • Name (Short text)
    • Slug (Short text, unique)
    • Image (Media)
    • Description (Long text)
    • Price (Number)
  2. Populate entries with a few products, e.g. blue-dress, red-shirt.


Next.js Setup and Dependencies Installation

js
1
2
3
4
5
npx create-next-app@latest video-consult --typescript cd video-consult npm install contentful @stream-io/node-sdk @stream-io/video-react-sdk

Create .env file:

Next is to create a .env file in the project root directory. The .env file is to store our personal IDs, keys, secrets ir token from Contentful and Stream.

  • Create a new file named .env
  • Populate the file with the code below. Make sure to replace the placeholder ellipses with your actual data.
bash
1
2
3
4
5
6
7
CONTENTFUL_SPACE_ID=... CONTENTFUL_ACCESS_TOKEN=... STREAM_API_KEY=... STREAM_API_SECRET=...

Connecting Contentful Product to Next.js

Pull the data from your Contentful page to the Next.js app you’re building. Read it, and render it when and where needed.

  • In your project directory, create a folder, lib.
  • Create a new file, contentful.ts, where you will fetch the data from Contentful.
  • In the file, contentful.ts, input:
js
1
2
3
4
5
6
import { createClient } from 'contentful'; export const contentfulClient = createClient({ space: process.env.CONTENTFUL_SPACE_ID || '', accessToken: process.env.CONTENTFUL_ACCESS_TOKEN || '', });

Rendering Product Pages

You’ll now integrate your Contentful Product page with the Next.js project you’re building.

  • Create a folder named pages.
  • Create another folder inside pages, components.
  • Create a file [slug].tsx. This enables you to fetch your various Contentful products dynamically through their respective slugs.
  • Create a type for your Product.
  • Embed the “Talk to Expert” button on this page.
js
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
'use client'; import Link from 'next/link'; import { contentfulClient } from '@/lib/contentful'; type Product = { name: string; slug: string; description: string; imageUrl: string; }; export default async function ProductPage({ params }: { params: { slug: string } }) { const { slug } = params; const { items } = await contentfulClient.getEntries({ content_type: 'product', //enter your product type, gotten from your contentful page. 'fields.slug': slug, }); if (!items[0]) return <p>Product not found</p>; const { name, slug: s, description, image } = items[0].fields; const product: Product = { name, slug: s, description, imageUrl: 'https:' + (image.fields.file.url as string), }; return ( <div className="p-8 max-w-2xl mx-auto"> <h1 className="text-3xl font-bold mb-4">{product.name}</h1> <img src={product.imageUrl} alt={product.name} className="w-full mb-6 rounded" /> <p className="mb-8">{product.description}</p> <Link href={`/call/${product.slug}`}> <button className="bg-blue-600 text-white px-4 py-2 rounded"> Talk to a Product Expert </button> </Link> </div> ); }

Serverless Room & Token Endpoint

Now that your Contentful page is set up and working, it’s time to instantiate the Stream API in the app.

  • Create a folder under pages, api.
  • Create a file, create-room.ts.
  • Populate the file with:
js
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
import { StreamClient } from '@stream-io/node-sdk'; import type { NextApiRequest, NextApiResponse } from 'next'; const apiKey = process.env.STREAM_API_KEY!; const apiSecret = process.env.STREAM_API_SECRET!; // Instantiate the Stream client const serverClient = new StreamClient(apiKey, apiSecret); export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({ message: 'Method Not Allowed' }); } const { productSlug, userId } = req.body; if (!productSlug || !userId) { return res.status(400).json({ message: 'Missing productSlug or userId' }); } try { const callId = `product-${productSlug}`; const call = serverClient.video.call('default', callId); await call.getOrCreate({ data: { created_by_id: userId } }); const token = serverClient.createToken(userId); return res.status(200).json({ callId, token, apiKey }); } catch (err: any) { console.error('Error in create-room:', err); return res.status(500).json({ message: 'Internal Server Error' }); } }

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

You’ll build two components. One will cater to customers accessing the Stream Video Call API to connect with an Expert, and the other will be the Expert joining the call when a customer calls.

Customer-Side Video Session

  • Create a new file in the components folder, VideoSession.tsx.
  • Populate it with:
js
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
'use client'; import { useEffect, useState } from 'react'; import { StreamVideo, StreamVideoClient, StreamCall, StreamTheme, SpeakerLayout, CallControls, useCallStateHooks, } from '@stream-io/video-react-sdk'; import '@stream-io/video-react-sdk/dist/css/styles.css'; interface VideoSessionProps { apiKey: string; token: string; callId: string; onEnd: () => void; } export function VideoSession({ apiKey, token, callId, onEnd }: VideoSessionProps) { const [client] = useState(() => new StreamVideoClient({ apiKey, user: { id: token }, token })); const [call] = useState(() => client.call('default', callId)); // join + publish tracks on mount useEffect(() => { let mounted = true; (async () => { await client.connectUser({ id: token, name: 'Expert' }); await call.join({ create: false }); })(); return () => { if (!mounted) return; call.leave().catch(() => {}); client.disconnectUser().catch(() => {}); mounted = false; }; }, [client, call]); // useCallStateHooks is now safely scoped here const { useParticipants, useLocalParticipant } = useCallStateHooks(); const participants = useParticipants(); const local = useLocalParticipant(); const remoteCount = participants.filter(p => p.sessionId !== local?.sessionId).length; return ( <div className="relative"> <div className="mb-4 p-2 rounded bg-green-100 text-green-800"> {remoteCount < 1 ? 'Waiting for expert to join…' : '✅ Expert has joined!'} </div> <StreamVideo client={client}> <StreamCall call={call}> <StreamTheme> <SpeakerLayout /> </StreamTheme> <button onClick={onEnd} className="absolute top-4 right-4 bg-red-500 text-white p-2 rounded-full" ></button> <CallControls /> </StreamCall> </StreamVideo> </div> ); }

Go back to the [slug].tsx file and update the code to import VideoSession. Then create a function to associate with your “Talk to Expert” button.

The updated code will look like:

js
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
'use client'; import { useState, useEffect } from 'react'; import { GetStaticPaths, GetStaticProps } from 'next'; import Link from 'next/link'; import { contentfulClient } from '../lib/contentful'; import { Product } from '../types/product'; import { StreamVideo, StreamVideoClient, StreamCall, StreamTheme, SpeakerLayout, CallControls, useCallStateHooks, } from '@stream-io/video-react-sdk'; import '@stream-io/video-react-sdk/dist/css/styles.css'; import { VideoSession } from './components/VideoSession'; type SessionData = { apiKey: string; token: string; callId: string; }; type Props = { product: Product }; export default function ProductPage({ product }: Props) { // —— State Hooks —— const [videoClient, setVideoClient] = useState<StreamVideoClient>(); const [call, setCall] = useState<any>(); const [joined, setJoined] = useState(false); const [showFeedback, setShowFeedback] = useState(false); const [rating, setRating] = useState(0); const [session, setSession] = useState<SessionData | null>(null); // —— Hook to count remote participants —— const { useParticipants, useLocalParticipant } = useCallStateHooks(); const participants = useParticipants(); const local = useLocalParticipant(); const remoteCount = participants.filter(p => p.sessionId !== local?.sessionId).length; // —— Cleanup on unmount —— useEffect(() => { return () => { call?.leave().catch(() => {}); videoClient?.disconnectUser().catch(() => {}); }; }, [call, videoClient]); // —— Join Call Handler —— const joinCall = async () => { const userId = `user-${Math.random().toString(36).substr(2, 9)}`; const res = await fetch('/api/create-room', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productSlug: product.slug, userId }), }); const { callId, token, apiKey } = await res.json(); const client = new StreamVideoClient({ apiKey, user: { id: userId }, token }); await client.connectUser({ id: userId, name: 'User' }); const callObj = client.call('default', callId); await callObj.join({ create: true }); setVideoClient(client); setCall(callObj); setJoined(true); }; // —— End Call Handler —— const endCall = async () => { await call?.leave(); await videoClient?.disconnectUser(); setJoined(false); setShowFeedback(true); }; // —— Feedback Submit Handler —— const submitFeedback = () => { console.log('User rating:', rating); setShowFeedback(false); }; // —— Render Logic —— if (showFeedback) { // Post-call feedback UI return ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center"> <div className="bg-white p-6 rounded shadow w-80"> <h2 className="text-2xl font-bold mb-4">Rate Your Session</h2> <div className="flex space-x-2 mb-4"> {[1,2,3,4,5].map(star => ( <button key={star} onClick={() => setRating(star)} className={`text-4xl ${rating >= star ? 'text-yellow-400' : 'text-gray-300'}`} ></button> ))} </div> <button onClick={submitFeedback} className="w-full bg-blue-600 text-white py-2 rounded" > Submit </button> </div> </div> ); } if (session) { return ( <VideoSession apiKey={session.apiKey} token={session.token} callId={session.callId} onEnd={endCall} /> ); } if (!joined) { // Before joining: product info + join button return ( <div className="min-h-screen p-8"> <h1 className="text-4xl font-bold mb-4">{product.name}</h1> <img src={product.imageUrl} alt={product.name} className="mb-4 max-w-md" /> <p className="mb-8">{product.description}</p> <button onClick={joinCall} className="bg-blue-600 text-white px-6 py-3 rounded hover:bg-blue-700" > Talk to a Product Expert </button> <Link href={`/agent/${product.slug}`}> View Expert Dashboard </Link> </div> ); } // Joined state: in-call UI return ( <div className="min-h-screen p-8"> <h1 className="text-2xl font-semibold mb-2">In Call: {product.name}</h1> <div className="mb-4 p-2 rounded bg-green-100 text-green-800"> {remoteCount < 1 ? 'Waiting for expert to join…' : '✅ Expert has joined!'} </div> <StreamCall call={call!}> <StreamVideo client={videoClient!}> <StreamTheme> <SpeakerLayout /> </StreamTheme> {/* Hang-up button */} <button onClick={endCall} className="absolute top-4 right-4 bg-red-500 text-white p-2 rounded-full" ></button> <CallControls /> </StreamVideo> </StreamCall> </div> ); } // —— Data Fetching —— export const getStaticPaths: GetStaticPaths = async () => { const entries = await contentfulClient.getEntries({ content_type: 'pageProduct' }); const paths = entries.items.map((i: any) => ({ params: { slug: i.fields.slug } })); return { paths, fallback: false }; }; export const getStaticProps: GetStaticProps<Props> = async ({ params }) => { const slug = params!.slug as string; const entries = await contentfulClient.getEntries({ content_type: 'pageProduct', 'fields.slug': slug, }); const item = entries.items[0] as any; if (!item) return { notFound: true }; const product: Product = { name: item.fields.name as string, slug: item.fields.slug as string, description: item.fields.description as string, imageUrl: 'https:' + (item.fields.image?.fields.file.url as string), }; return { props: { product } }; };

When you click on the “Talk to Expert” button, a “Waiting for Expert” line will appear while you wait for the expert to join the call.


Camera & Microphone Permissions

When initiating the call, the browser will prompt for camera/mic access:

await call.join({ create: true, audio: true, video: true });


Expert-Side Video Session

Next, you’ll create the Expert-side video session.

  • Create a new folder named agent.
  • Create a new folder in the agent named [slug].
  • Create a new file, page.tsx.
  • This will enable the expert to join all calls using the dynamic routes of specific slugs.
  • Populate the file with:
js
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
'use client'; import { useParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { StreamVideo, StreamVideoClient, StreamCall, StreamTheme, SpeakerLayout, CallControls, } from '@stream-io/video-react-sdk'; import '@stream-io/video-react-sdk/dist/css/styles.css'; export default function AgentPage() { const params = useParams(); // Guard for null or array case: const slugParam = params?.slug; const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam ?? ''; const [client, setClient] = useState<StreamVideoClient>(); const [call, setCall] = useState<any>(); const [joined, setJoined] = useState(false); useEffect(() => { async function joinAsExpert() { const userId = `expert-${slug}`; // replace with your auth ID const res = await fetch('/api/create-room', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productSlug: slug, userId }), }); const { callId, token, apiKey } = await res.json(); const user = { id: userId, name: 'Expert' }; const svClient = new StreamVideoClient({ apiKey, user, token }); await svClient.connectUser(user); // authenticate the WS connection const callObj = svClient.call('default', callId); await callObj.join({ create: false}); setClient(svClient); setCall(callObj); setJoined(true); } joinAsExpert(); }, [slug]); if (!joined) { return <p className="p-8 text-xl">Joining call as expert…</p>; } return ( <div className="min-h-screen p-8"> <StreamVideo client={client!}> <StreamCall call={call!}> <StreamTheme> <SpeakerLayout /> <CallControls /> </StreamTheme> </StreamCall> </StreamVideo> </div> ); }

Expert Dashboard

Experts can join the call by navigating to:

localhost:3000/agent/\<product-slug>

You will see this waiting page as you join the call.

The Stream Video API offers other unique features, like screen sharing.

In the image below, both the expert and the client joined the call, and the client attempted to share their screen.


Testing

  1. Navigate to the Product page: http://localhost:3000/product/<product-slug>
  2. Click “Talk to a Product Expert” button → browser asks for camera/mic.
  3. See “Waiting for Expert” on customer side.
  4. Open expert URL: http://localhost:3000/call/<product-slug>?expert=true
  5. Expert joins → both video tiles appear; controls work.
  6. End call → customer redirected with feedback prompt.

Conclusion

You’ve built a full headless, Jamstack video-consultation feature using Stream’s Video API, Contentful and Next.js.

In this app, you have:

  • Static product pages via Contentful & Next.js
  • Secure room/token generation with Stream Node SDK
  • Real-time video UI using Stream React SDK
  • Clean UX: waiting screens, hang-up, feedback

🔗 Try it yourself: Fork the repo, plug in your keys, and deploy on Vercel or Netlify.

🚀 Next steps: Add chat overlays, advanced layouts, or analytics dashboards with Kibana.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->