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:
- A headless CMS (Contentful)
- A static-site framework (Next.js)
- Stream Video SDK (React + Node)
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
- Sign up and create a Contentful Space.

-
Define a “Product” content model with fields:
- Name (Short text)
- Slug (Short text, unique)
- Image (Media)
- Description (Long text)
- Price (Number)
-
Populate entries with a few products, e.g.
blue-dress
,red-shirt
.

Next.js Setup and Dependencies Installation
12345npx 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.
1234567CONTENTFUL_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:
123456import { 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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162'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:
123456789101112131415161718192021222324252627282930313233343536import { 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' }); } }
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:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071'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:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199'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:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465'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
- Navigate to the Product page:
http://localhost:3000/product/<product-slug>
- Click “Talk to a Product Expert” button → browser asks for camera/mic.
- See “Waiting for Expert” on customer side.
- Open expert URL:
http://localhost:3000/call/<product-slug>?expert=true
- Expert joins → both video tiles appear; controls work.
- 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.