This tutorial teaches you how to build Zoom/Whatsapp style video calling experience for your app.
- Calls run on Stream's global edge network for optimal latency & reliability.
- Permissions give you fine grained control over who can do what.
- Video quality and codecs are automatically optimized.
- Powered by Stream's Video Calling API.
This page works for both humans and AI coding agents: every step is a file operation or a shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.
Choose your path
This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step, on Vite. Both paths end with the same working video call, so pick the one you prefer instead of working through both.
Path A - Let your AI agent build it (recommended)
Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the React integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.
1234567# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder). The React pack installs # on demand the first time it's needed, or add it explicitly: getstream skills getstream skills stream-react
Then ask your agent:
1/stream-react Build a React video calling app with a speaker layout and call controls.
Where you come in. The agent handles credentials and code itself. It stops for you three times: when getstream init opens your browser to log in and pick an app (new accounts get an organization created in that flow), when it asks which theme to use, and when the app first runs and asks for camera and microphone access.
Want to get the login out of the way first? Run getstream init before you prompt the agent - it picks up the initialized project from there.
Checkpoint: start the dev server if the agent hasn't, open the app in your browser and allow camera and microphone access. You should join the call and see your own video.
Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.
Path B - Build it manually
Choose this path if you'd rather write the code yourself, or you want to understand what the SDK is doing before you hand it to an agent. Follow the steps below. Code blocks titled src/App.tsx are the complete file at that point in the tutorial; the shorter untitled blocks are excerpts explaining what just changed.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the React Video SDK is athttps://getstream.io/video/docs/react/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Four pieces, one mental model:
StreamVideoClient- the low level client. Holds the API key, the user, the token and the websocket connection.Call- a single call, created withclient.call(type, id).call.join()is what actually sets up audio and video.- Providers -
<StreamVideo client={client} />puts the client in React context,<StreamCall call={call} />puts one call in React context. Everything below reads from them. - Call state hooks -
useCallStateHooks()hands you the hooks (useCallCallingState,useParticipantCount,useLocalParticipant,useRemoteParticipants) that drive the UI.
You'll get a bare call connected first, then render raw video with ParticipantView, then swap in the prebuilt UI components.
Step 0 - Prepare your environment
For this tutorial, we'll need a few tools to be installed on our device. You can skip this step in case you already have them installed.
Checkpoint: both commands print a version at or above the ones listed above.
12node --version yarn --version
Step 1 - Set up your project and credentials
In this step, we will create a new React application using the Vite CLI, and install Stream's React Video SDK. We recommend using Vite because it is fast and easy to use.
123yarn create vite video-call --template react-ts cd video-call yarn add @stream-io/video-react-sdk
The @stream-io/video-react-sdk should be added to your package.json.
Checkpoint: yarn dev serves the Vite starter page, and @stream-io/video-react-sdk appears in package.json. Stop the dev server again before continuing.
The code in Step 2 also needs four values: an API key, a user token, a user id and a call id. There are two ways to get them.
Option 1 - Your own Stream app, via the Stream CLI
The getstream CLI provisions all of it in one flow. Run these from the video-call directory you just created.
1. Install the CLI (skip if you did this in Path A):
1curl -fsSL https://getstream.io/cli.sh | bash
2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The login flow creates your organization. Already have an org or an app? It lets you pick them.
1getstream init
Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first, env and token fail with "stream project is not initialized" until it runs.
3. Write your API key into the project. For a Vite app this writes VITE_STREAM_API_KEY into .env.local and adds that file to .gitignore. The API secret is never written to a client target.
1getstream env --target vite
Read it in code with import.meta.env.VITE_STREAM_API_KEY instead of pasting the key into App.tsx.
4. Mint a user token for a user in your app (never expiring by default; add a TTL for production-like testing):
12getstream token oliver getstream token oliver --ttl 1d
5. Pick a call id. Anything URL safe works, for example my-first-call. Calls are created the first time somebody joins with create: true, so there is nothing to provision up front.
Checkpoint: you have an API key in .env.local, a token printed by the CLI, the user id you minted it for, and a call id you chose. All four belong to the same app.
Option 2 - Pre-filled tutorial credentials, no account
Want to skip account setup entirely? Every code block below marked with the lock icon is filled in for you with working credentials against Stream's shared tutorial environment. Copy the block as-is and it runs.
Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.
Step 2 - Create & Join a call
We open up src/App.tsx and replace it with this code:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758import { CallingState, StreamCall, StreamVideo, StreamVideoClient, useCall, useCallStateHooks, type User, } from "@stream-io/video-react-sdk"; const apiKey = "REPLACE_WITH_API_KEY"; const token = "REPLACE_WITH_TOKEN"; const userId = "REPLACE_WITH_USER_ID"; const callId = "REPLACE_WITH_CALL_ID"; // set up the user object const user: User = { id: userId, name: "Oliver", image: "https://getstream.io/random_svg/?id=oliver&name=Oliver", }; const client = new StreamVideoClient({ apiKey, user, token }); const call = client.call("default", callId); await call.join({ create: true }); // Turn the local camera and microphone on. The SDK persists each user's last // device state, so enabling them explicitly keeps the first run predictable. call.camera.enable(); call.microphone.enable(); export default function App() { return ( <StreamVideo client={client}> <StreamCall call={call}> <MyUILayout /> </StreamCall> </StreamVideo> ); } export const MyUILayout = () => { const call = useCall(); const { useCallCallingState, useParticipantCount } = useCallStateHooks(); const callingState = useCallCallingState(); const participantCount = useParticipantCount(); if (callingState !== CallingState.JOINED) { return <div>Loading...</div>; } return ( <div> Call "{call?.id}" has {participantCount} participants </div> ); };
If you followed Option 1 above, replace the four constants with your own values. Using the API key from .env.local instead of a literal looks like this:
1const apiKey = import.meta.env.VITE_STREAM_API_KEY;
If you followed Option 2, the four values are already filled in for you.
The user token is normally generated by your server side API: when a user logs in to your app, your backend returns the token that gives them access to the call. Never ship the API secret to the browser.
Now we are ready to run our app. In our terminal, we run:
1yarn dev
️ Camera & microphone permissions: the first time you join, your browser will ask for permission to use your camera and microphone; click Allow. Browsers only grant media access on secure origins, which
localhost(and any HTTPS site) satisfies. If you deny access, you'll still join the call, but you won't publish any audio or video.
Human checkpoint: the permission prompt is a browser dialog. Agents: start the dev server, then ask the human to open the page and allow camera and microphone access.
Once we open the browser and run the app, it will connect successfully to our systems.
The text will say Call "..." has 1 participants - just you, and the component doesn't bother pluralizing.

Checkpoint: the page shows Call "<call-id>" has 1 participants and the browser console is free of auth errors. Stuck on Loading...? The call never finished joining - check the console for an auth error, which usually means the API key and token belong to different apps.
Let's review what we did in the above code.
User setup
First, we create a user object. Typically, these users are synced via a server side integration from our own backend. Alternatively, we can also use guest or anonymous users.
1234567import { type User } from "@stream-io/video-react-sdk"; const user: User = { id: userId, name: "Oliver", image: "https://getstream.io/random_svg/?id=oliver&name=Oliver", };
Client setup
Next, we initialize the client by passing the API Key, user and user token.
123import { StreamVideoClient } from "@stream-io/video-react-sdk"; const client = new StreamVideoClient({ apiKey, user, token });
Create and join call
After the user and client are created, we create a call like this:
123456const call = client.call("default", callId); await call.join({ create: true }); // Turn the local camera and microphone on. call.camera.enable(); call.microphone.enable();
As soon as we use call.join() the connection for video & audio is set up.
We also enable the local camera and microphone explicitly. The SDK remembers each user's
last device state, so turning them on in code guarantees a predictable first run.
In production grade apps, we'd typically store the
callinstance in a state variable and take care of correctly disposing it. Read more in our Joining and Creating Calls guide.
Rendering the UI
Lastly, we render the UI by observing the call state through the call state hooks:
12345678import { useCallStateHooks } from "@stream-io/video-react-sdk"; // all call state hooks are available in the useCallStateHooks() object // your IDE should help you to explore all of them const { useCallCallingState, useParticipantCount } = useCallStateHooks(); const callingState = useCallCallingState(); const participantCount = useParticipantCount();
We'll find all relevant state for the call in call.state - also exposed through a set of SDK provided hooks.
The documentation on Call and Participant state explains this in further detail.
To render the UI, we can either use SDK provided components or build our own on top of the Core Components and Call State Hooks.
Step 3 - Joining from the web
To make this a little more interactive, let's join the call from your browser.
In your browser, you'll see the text update to: Call <call-id> has 2 participants.
Let's keep the browser tab open as you go through the tutorial.
Using your own credentials from Option 1? The Join Call button above joins the shared tutorial call, not yours. Open a second browser profile (or an incognito window) pointed at your own app with a different user id and the same call id. Two tabs signed in as the same user count as one participant.
Checkpoint: the participant count reads 2.
Step 4 - Rendering Video
In this next step, we're going to render our local & remote participants video.
Before we start, we will delete the default contents of the index.css file to ensure it doesn't interfere with our setup.
Let's update our MyUILayout component to load the predefined SDK stylesheet,
apply the default theme and render the participant's video and play their audio.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162import { CallingState, StreamCall, StreamVideo, StreamVideoClient, useCallStateHooks, type User, StreamTheme, } from "@stream-io/video-react-sdk"; import "@stream-io/video-react-sdk/dist/css/styles.css"; const apiKey = "REPLACE_WITH_API_KEY"; const token = "REPLACE_WITH_TOKEN"; const userId = "REPLACE_WITH_USER_ID"; const callId = "REPLACE_WITH_CALL_ID"; // set up the user object const user: User = { id: userId, name: "Oliver", image: "https://getstream.io/random_svg/?id=oliver&name=Oliver", }; const client = new StreamVideoClient({ apiKey, user, token }); const call = client.call("default", callId); await call.join({ create: true }); // Turn the local camera and microphone on. The SDK persists each user's last // device state, so enabling them explicitly keeps the first run predictable. call.camera.enable(); call.microphone.enable(); export default function App() { return ( <StreamVideo client={client}> <StreamCall call={call}> <MyUILayout /> </StreamCall> </StreamVideo> ); } export const MyUILayout = () => { const { useCallCallingState, useLocalParticipant, useRemoteParticipants } = useCallStateHooks(); const callingState = useCallCallingState(); const localParticipant = useLocalParticipant(); const remoteParticipants = useRemoteParticipants(); if (callingState !== CallingState.JOINED) { return <div>Loading...</div>; } return ( <StreamTheme> <MyParticipantList participants={remoteParticipants} /> <MyFloatingLocalParticipant participant={localParticipant} /> </StreamTheme> ); };
We will now create a MyParticipantList component that will render the remote participants' video and play the participants' audio.
We are going to use the SDK-provided ParticipantView component. We'll learn more about it later in this tutorial.
12345678910111213141516171819202122import { ParticipantView, type StreamVideoParticipant, } from "@stream-io/video-react-sdk"; // ... rest of the App.tsx code export const MyParticipantList = (props: { participants: StreamVideoParticipant[]; }) => { const { participants } = props; return ( <div style={{ display: "flex", flexDirection: "row", gap: "8px" }}> {participants.map((participant) => ( <ParticipantView participant={participant} key={participant.sessionId} /> ))} </div> ); };
With this, we have a simple grid layout for the remote participants.
Let's add a floating video (MyFloatingLocalParticipant) for the local participant:
1234567891011121314151617181920212223242526// ... rest of the App.tsx code export const MyFloatingLocalParticipant = (props: { participant?: StreamVideoParticipant; }) => { const { participant } = props; if (!participant) { return <p>Error: No local participant</p>; } return ( <div style={{ position: "absolute", top: "15px", left: "15px", width: "240px", height: "135px", boxShadow: "rgba(0, 0, 0, 0.1) 0px 0px 10px 3px", borderRadius: "12px", }} > <ParticipantView participant={participant} /> </div> ); };
Now when we run the app (yarn dev), we'll see our local video in a floating video element and the video from our other browser tab.
The final result should look somewhat like this (the person on screen might differ):

Let's review the changes we made. ParticipantView is one of our primary low-level components.
123import { ParticipantView } from "@stream-io/video-react-sdk"; <ParticipantView participant={participant} key={participant.sessionId} />;
It displays the video stream and comes with some default UI elements, such as participant's name. The video is lazily loaded, and only requested from the video infrastructure if we're actually displaying it. So if we have a video call with 200 participants, and we show only 10 of them, we'll only receive video for 10 participants. This is how software like Zoom and Google Meet make large calls work.
MyFloatingLocalParticipant renders a display of our own video.
1234const { useLocalParticipant } = useCallStateHooks(); const localParticipant = useLocalParticipant(); <MyFloatingLocalParticipant participant={localParticipant} />;
MyParticipantList renders a list of remote participants.
1234const { useRemoteParticipants } = useCallStateHooks(); const remoteParticipants = useRemoteParticipants(); <MyParticipantList participants={remoteParticipants} />;
The complete code App.tsx file should be like this:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105import { CallingState, StreamCall, StreamVideo, StreamVideoClient, useCallStateHooks, type User, StreamTheme, ParticipantView, type StreamVideoParticipant, } from "@stream-io/video-react-sdk"; import "@stream-io/video-react-sdk/dist/css/styles.css"; const apiKey = "REPLACE_WITH_API_KEY"; const token = "REPLACE_WITH_TOKEN"; const userId = "REPLACE_WITH_USER_ID"; const callId = "REPLACE_WITH_CALL_ID"; // set up the user object const user: User = { id: userId, name: "Oliver", image: "https://getstream.io/random_svg/?id=oliver&name=Oliver", }; const client = new StreamVideoClient({ apiKey, user, token }); const call = client.call("default", callId); await call.join({ create: true }); // Turn the local camera and microphone on. The SDK persists each user's last // device state, so enabling them explicitly keeps the first run predictable. call.camera.enable(); call.microphone.enable(); export default function App() { return ( <StreamVideo client={client}> <StreamCall call={call}> <MyUILayout /> </StreamCall> </StreamVideo> ); } export const MyUILayout = () => { const { useCallCallingState, useLocalParticipant, useRemoteParticipants } = useCallStateHooks(); const callingState = useCallCallingState(); const localParticipant = useLocalParticipant(); const remoteParticipants = useRemoteParticipants(); if (callingState !== CallingState.JOINED) { return <div>Loading...</div>; } return ( <StreamTheme> <MyParticipantList participants={remoteParticipants} /> <MyFloatingLocalParticipant participant={localParticipant} /> </StreamTheme> ); }; export const MyParticipantList = (props: { participants: StreamVideoParticipant[]; }) => { const { participants } = props; return ( <div style={{ display: "flex", flexDirection: "row", gap: "8px" }}> {participants.map((participant) => ( <ParticipantView participant={participant} key={participant.sessionId} /> ))} </div> ); }; export const MyFloatingLocalParticipant = (props: { participant?: StreamVideoParticipant; }) => { const { participant } = props; if (!participant) { return <p>Error: No local participant</p>; } return ( <div style={{ position: "absolute", top: "15px", left: "15px", width: "240px", height: "135px", boxShadow: "rgba(0, 0, 0, 0.1) 0px 0px 10px 3px", borderRadius: "12px", }} > <ParticipantView participant={participant} /> </div> ); };
Checkpoint: your own video plays in the floating tile at the top left, and the participant you joined with in Step 3 renders next to it. No video at all? Confirm you allowed camera access and that you deleted the default index.css contents.
Step 5 - A Full Video Calling UI
The above example showed how to use the call state and React to build a basic video UI. For a production version app, we'd want a few more UI elements:
- Indicators of when someone is speaking
- Quality of their network connection
- Layout support for more than two participants
- Labels for the participant names
- Call controls
Stream's React Video SDK ships with several React components to make this easy. We can customize the components with theming, arguments and swapping parts of them. This is convenient if we want to quickly build a production ready calling experience for our app (and if you need more flexibility, many customers use the above low level approach to build a UI from scratch).
To render a full calling UI, we'll leverage the SpeakerLayout component for arranging the video elements,
and the CallControls component for rendering the call controls.
Also, we are going to introduce a minimalistic CSS file to make the UI look a bit nicer.
123456789101112131415161718body, html { height: 100%; width: 100%; margin: 0; font-family: sans-serif; } .str-video { background-color: #272a30; color: #ffffff; height: 100dvh; width: 100%; display: flex; flex-direction: column; min-width: 0; max-width: 100%; }
Then we're switching out the entire content of our App.tsx file with this:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859import { CallControls, CallingState, SpeakerLayout, StreamCall, StreamTheme, StreamVideo, StreamVideoClient, useCallStateHooks, type User, } from "@stream-io/video-react-sdk"; import "@stream-io/video-react-sdk/dist/css/styles.css"; import "./index.css"; const apiKey = "REPLACE_WITH_API_KEY"; const token = "REPLACE_WITH_TOKEN"; const userId = "REPLACE_WITH_USER_ID"; const callId = "REPLACE_WITH_CALL_ID"; const user: User = { id: userId, name: "Oliver", image: "https://getstream.io/random_svg/?id=oliver&name=Oliver", }; const client = new StreamVideoClient({ apiKey, user, token }); const call = client.call("default", callId); await call.join({ create: true }); // Turn the local camera and microphone on. call.camera.enable(); call.microphone.enable(); export default function App() { return ( <StreamVideo client={client}> <StreamCall call={call}> <MyUILayout /> </StreamCall> </StreamVideo> ); } export const MyUILayout = () => { const { useCallCallingState } = useCallStateHooks(); const callingState = useCallCallingState(); if (callingState !== CallingState.JOINED) { return <div>Loading...</div>; } return ( <StreamTheme> <SpeakerLayout participantsBarPosition="bottom" /> <CallControls /> </StreamTheme> ); };
The final UI should look like this:

When we now run our app, we'll see a more polished video UI. It supports reactions, screensharing, active speaker detection, network quality indicators etc. The most commonly used UI components are:
- ParticipantView: For rendering video and automatically requesting video tracks when needed. Most of the Video components are built on top of this.
- DefaultParticipantViewUI: The participant's video + some UI elements for network quality, reactions, speaking etc.
ParticipantViewuses this UI by default. - PaginatedGridLayout: A grid of participants. Support pagination out of the box.
- SpeakerLayout: A layout that shows the active speaker in a large video, and the rest of the participants a scrollable bar.
- CallControls: A set of buttons for controlling your call, such as changing audio and video mute state, switching mic or a camera.
The full list of UI components is available in the docs.
Checkpoint: the active speaker fills the screen with the other participants in a bar along the bottom, and the control bar toggles your mic and camera. Toggle your mic in one tab and watch the indicator update in the other.
Because of simplicity, in this tutorial, we are skipping some of the best practices for building a production ready app. Take a look at our sample app linked at the end of this tutorial for a more complete example.
Step 6 - Customizing the UI
Three ways to customize, in increasing order of effort: theming for colors and fonts, mixing your own components in alongside Stream's, or building the tiles and controls from scratch. The UI components reference lists everything the SDK ships with.
The cookbooks below are short, self-contained recipes - each one swaps out a single piece of the call UI.
Participants and video
- Participant view customizations - the pattern for changing what renders inside each participant tile
- Video placeholder - what shows when someone's camera is off
- Custom participant label - the name, mute state and connection quality overlay
- Picture-in-picture - keep the call visible when the user switches tabs
Layout and controls
- Replacing call controls - swap
CallControlsfor your own buttons - Runtime layout switching - let users toggle between the speaker and grid layouts
- Call preview and thumbnail - a lobby screen before joining
- Fullscreen mode
Handling the awkward cases
- Permission requests - prompting for camera and microphone access
- Speaking while muted - the "you're on mute" nudge
- Unstable connection and low bandwidth - degrade gracefully instead of freezing
- Broken microphone setup
- Closed captions and call quality rating
Step 7 - Enable Noise Cancellation
Background noise during a call session is never pleasant for the call participants.
Our SDK provides a plugin that helps to greatly reduce the unwanted noise caught by users' microphones. Read more on how to enable it here.
Recap
Please do let us know if you ran into any issues while building a video calling app with React Video SDK. Our team is also happy to review your UI designs and offer recommendations on how to achieve it with Stream.
Let's recap what we've learned:
- To set up a call:
const call = client.call('default', '123');. - The call type (
'default'in the above case) controls which features are enabled and how permissions are setup. - When we join a call, realtime communication is setup for audio & video calling:
call.join(). - Call state
call.stateand helper state access hooks exposed throughuseCallStateHooks()make it easy to build our own UI ParticipantViewis the low level component that renders video, plays audio and by default, it utilizesDefaultParticipantViewUIthat adds UI elements as participant name, network quality, etc...
We've used Stream's Video Calling API, which means calls run on a global edge network of video servers. By being closer to our users the latency and reliability of calls are better. The React SDK enables us to build in-app video calling, audio rooms and livestreaming in days.
The source code for the final app can be found in our GitHub repository.
We hope you've enjoyed this tutorial and please do feel free to reach out if you have any suggestions or questions.
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
In this video app tutorial we built a fully functioning React video app with our React SDK component library. We also showed how easy it is to customize the behavior and the style of the React video app components with minimal code changes.
Both the video SDK for React and the API have plenty more features available to support more advanced use-cases.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skillsandgetstream skills stream-reactfor the web React pack. (Alternative:npx skills add GetStream/agent-skills -s stream.) - Provisioning:
getstream init(auth + create/select org & app) ->getstream env --target vite(writesVITE_STREAM_API_KEYto.env.local) ->getstream token <user-id>(mint a token) - Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, calls, and call members - React integration skill: invoke
/stream-reactin your agent for React and Next.js setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/video/docs/react/llms.txt - Markdown endpoints: append
.mdto any docs URL for a clean, token-efficient version - Source of truth for APIs: the SDK repository - check the installed version's source rather than assuming APIs from training data

