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

React Audio Room Tutorial

The following tutorial shows you how to quickly build an Audio Room app leveraging Stream's Video API and the Stream Video React components. The underlying API is very flexible and allows you to build nearly any type of video experience.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your React audio room.

This tutorial will teach you how to build an audio room experience like Twitter Spaces or Clubhouse. The end result will look like the image below and will support the following features:

  • Backstage mode. You can start the call with your co-hosts and chat a bit before going live
  • Calls run on Stream's global edge network for optimal latency and scalability
  • There is no cap to how many listeners you can have in a room
  • Listeners can raise their hand, and be invited to speak by the host
  • Audio tracks are sent multiple times for optimal reliability

Preview of the final result

Time to get started building an audio-room for your app.

This page works for both humans and AI coding agents: every step is a file operation or a shell command, every build 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 audio room, so pick the one you prefer instead of working through both.

Fastest if you already use Claude Code, Cursor, or Codex. Install the Stream CLI once, then add the skills. This gives your agent the React integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.

Terminal (bash)
1
2
3
4
5
# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the React pack (the router, docs and builder skills come with it) getstream skills stream-react

Then ask your agent:

Prompt (markdown)
1
/stream-react Build a React audio room app with backstage mode, a participants list split into speakers and listeners, and requests to speak.

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 asks for 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: open the app in your browser, go live, and join from a second tab. The participant list updates as tabs come and go, and the host can speak once you allow microphone access.

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. Each code block is titled with the file it belongs to, so create the files as you go.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the React Video SDK is at https://getstream.io/video/docs/react/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Five pieces, one mental model:

  • StreamVideoClient - the low level client. Holds the API key, the user, the token and the websocket connection.
  • Call - a single room, created with client.call("audio_room", id). call.join() is what actually sets up audio.
  • Backstage - the audio_room call type starts rooms not live. Hosts can join and talk before call.goLive() lets everyone else in.
  • 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 (useParticipants, useCallCustomData, useIsCallLive, useMicrophoneState) that drive the UI.

Permissions matter here more than in a video call: audio_room only lets admins and moderators speak, so everyone else requests permission and the host grants it.

Step 0 - Prepare your environment

For this tutorial, you'll need a few tools to be installed on your device. You can skip this step in case you already have them installed.

  • Node.js (version 24 or higher)
  • Yarn (version 1.22 or higher)
  • React 19

Checkpoint: both commands print a version at or above the ones listed above.

Terminal (bash)
1
2
node --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 & Audio SDK. We recommend using Vite because it's fast and easy to use.

Terminal (bash)
1
2
3
yarn create vite audio-rooms --template react-ts cd audio-rooms yarn add @stream-io/video-react-sdk

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 audio-rooms directory you just created.

1. Install the CLI (skip if you did this in Path A):

Terminal (bash)
1
curl -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.

Terminal (bash)
1
getstream 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.

Terminal (bash)
1
getstream 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. This user creates the room, so they're the host:

Terminal (bash)
1
getstream token oliver

5. Pick a call id. Anything URL safe works, for example my-first-room. Rooms 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

Open up src/App.tsx and replace it with this code:

src/App.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { StreamCall, StreamVideo, StreamVideoClient, type User, } 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"; // initialize 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("audio_room", callId); call.join({ create: true, data: { members: [ /* { user_id: 'john_smith' }, { user_id: 'jane_doe' } */ ], custom: { title: "React Rooms", description: "Talking about React", }, }, }); export default function App() { return ( <StreamVideo client={client}> <StreamCall call={call}> {/** We will introduce the <MyUILayout /> component later */} {/** <MyUILayout /> */} </StreamCall> </StreamVideo> ); }

Let's review the example above and go over the details.

User setup

First we create a user object. You typically sync your users via a server side integration from your own backend. Alternatively, you can also use guest or anonymous users.

ts
1
2
3
4
5
6
7
import 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.

ts
1
2
3
import { 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:

tsx
1
2
3
4
5
6
7
8
9
10
11
const call = client.call("audio_room", callId); await call.join({ create: true, data: { members: [{ user_id: "john_smith" }, { user_id: "jane_doe" }], custom: { title: "React Rooms", description: "Talking about React", }, }, });
  • This joins and creates a call with the type: audio_room and the specified callId
  • The users with id john_smith and jane_doe are added as members to the call
  • And we set the title and description custom field on the call object

In production grade apps, you'd typically store the call instance in a state variable and take care of correctly disposing it. Read more in our Joining and Creating Calls guide.

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:

ts
1
const 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.

Checkpoint: run yarn dev. The page is intentionally blank at this point - MyUILayout is still commented out - but the browser console should be free of auth errors, which means the room was created and joined. A token is invalid error here means the API key and token belong to different apps.

Step 3 - Adding audio room UI elements

In this next step we'll add:

  • Room title and description
  • Controls to toggle live mode on/off
  • A list of participants with their speaking status

Room Title & Description

Let's create the components we need to render this and add them to the main app

MyDescriptionPanel.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useCallStateHooks } from "@stream-io/video-react-sdk"; export const MyDescriptionPanel = () => { const { useCallCustomData, useParticipants } = useCallStateHooks(); const custom = useCallCustomData(); const participants = useParticipants(); return ( <div className="description-panel"> <h2 className="title">{custom?.title ?? "<Title>"}</h2> <h3 className="description">{custom?.description ?? "<Description>"}</h3> <p className="participant-count">{participants.length} participants</p> </div> ); };
MyParticipantsPanel.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Avatar, useCallStateHooks } from "@stream-io/video-react-sdk"; export const MyParticipantsPanel = () => { const { useParticipants } = useCallStateHooks(); const participants = useParticipants(); return ( <div className="participants-panel"> <h4>Participants</h4> {participants.map((participant) => { return ( <div className="participant" key={participant.sessionId}> <Avatar imageSrc={participant.image} /> <div className="name">{participant.name}</div> </div> ); })} </div> ); };
MyControlsPanel.tsx (tsx)
1
2
3
4
5
6
7
export const MyControlsPanel = () => { return ( <div className="controls-panel"> {/* We'll add the mic and live-mode buttons here in the next section */} </div> ); };

That's it for the basics, here's how the app MyUILayout should look like now:

MyUILayout.tsx (tsx)
1
2
3
4
5
6
7
8
9
export const MyUILayout = () => { return ( <div className="ui-layout"> <MyDescriptionPanel /> <MyParticipantsPanel /> <MyControlsPanel /> </div> ); };

Finally, let's add the UILayout to the main app component:

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
// ... omitted imports import { MyUILayout } from "./UILayout"; export const App = () => { // ... omitted code return ( <StreamVideo client={client}> <StreamCall call={call}> <MyUILayout /> </StreamCall> </StreamVideo> ); };

The approach is the same for all components. We take the states of the call by observing call.state updates through the SDK provided hooks, such as useParticipants() or useCallCustomData() and use it to power our UI. These two hooks and many others are available in the useCallStateHooks utility.

In React, all call.state properties can be accessed via a set of hooks, all exposed by the useCallStateHooks utility hook. This makes it easier to build UI components that react to changes in the call state.

Read more about it at Call & Participant State.

To make this a little more interactive, let's join the audio room from the browser.

For testing you can join the call on our web-app:

Backstage & Live mode control

As you probably noticed by opening the same room from the browser, audio rooms by default are not live. Regular users can only join an audio room when it is in live mode. Let's expand the ControlPanel and add a button that controls the backstage of the room.

MyLiveButton.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
import { useCall, useCallStateHooks } from "@stream-io/video-react-sdk"; export const MyLiveButton = () => { // this utility hook returns the call object from the <StreamCall /> context const call = useCall(); // will emit a new value whenever the call goes live or stops being live. // we can use it to update the button text or adjust any other UI elements const { useIsCallLive } = useCallStateHooks(); const isLive = useIsCallLive(); return ( <button className={`live-button ${isLive ? "live" : ""}`} onClick={async () => { if (isLive) { await call?.stopLive(); } else { await call?.goLive(); } }} > {isLive ? "Stop Live" : "Go Live"} </button> ); };

While we're at it, let's also add a button that allows to mute/unmute the local audio track:

MyMicButton.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useCallStateHooks } from "@stream-io/video-react-sdk"; export const MyMicButton = () => { const { useMicrophoneState } = useCallStateHooks(); const { microphone, isMute } = useMicrophoneState(); return ( <button className="mic-button" onClick={async () => { if (isMute) { await microphone.enable(); } else { await microphone.disable(); } }} > {isMute ? "Unmute" : "Mute"} </button> ); };

Now let's wire both buttons into the MyControlsPanel we stubbed out earlier:

MyControlsPanel.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
import { MyMicButton } from "./MyMicButton"; import { MyLiveButton } from "./MyLiveButton"; export const MyControlsPanel = () => { return ( <div className="controls-panel"> <MyMicButton /> <MyLiveButton /> </div> ); };

By default the audio_room call type joins you with your microphone disabled (mic_default_on is false), so the button starts as Unmute. Click it (and allow microphone access when the browser prompts) to start sending audio.

Now the app exposes a mic control button and a button that allows to toggle live mode on/off. If you try the web demo of the audio room you should be able to join as a regular user.

List Participants

As a next step, let's render the actual list of participants and show an indicator when they are speaking. To do this we are going to create a Participant component and render it from the ParticipantsPanel

MyParticipant.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Avatar, type StreamVideoParticipant, } from "@stream-io/video-react-sdk"; export const MyParticipant = ({ participant, }: { participant: StreamVideoParticipant; }) => { // `isSpeaking` information is available on the participant object, // and it is automatically detected by our system and updated by our SDK. const { isSpeaking } = participant; return ( <div className={`participant ${isSpeaking ? "speaking" : ""}`}> <Avatar imageSrc={participant.image} /> <div className="name">{participant.name}</div> </div> ); };
MyParticipantsPanel.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useCallStateHooks } from "@stream-io/video-react-sdk"; import { MyParticipant } from "./MyParticipant"; export const MyParticipantsPanel = () => { const { useParticipants } = useCallStateHooks(); // whenever a participant receives an update, this hook will re-render // this component with the updated list of participants, ensuring that // the UI is always in sync with the call state. const participants = useParticipants(); return ( <div className="participants-panel"> <h4>Participants</h4> {participants.map((p) => ( <MyParticipant participant={p} key={p.sessionId} /> ))} </div> ); };

With these changes things get more interesting, the app is now showing a list of all participants connected to the call and displays a green frame around the ones that are speaking.

However, you might have noticed that you can't hear the audio from the browser. To enable this, you need to render the audio track of every participant. Our SDK provides a special component for this purpose, called <ParticipantsAudio />. Let's add it to MyParticipantsPanel:

MyParticipantsPanel.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { ParticipantsAudio, useCallStateHooks, } from "@stream-io/video-react-sdk"; import { MyParticipant } from "./MyParticipant"; export const MyParticipantsPanel = () => { const { useParticipants } = useCallStateHooks(); // whenever a participant receives an update, this hook will re-render // this component with the updated list of participants, ensuring that // the UI is always in sync with the call state. const participants = useParticipants(); return ( <div className="participants-panel"> <h4>Participants</h4> <ParticipantsAudio participants={participants} /> {participants.map((p) => ( <MyParticipant participant={p} key={p.sessionId} /> ))} </div> ); };

Checkpoint: the page shows the room title, description, a participant count and the participants list. The live button reflects whether the room is live, and toggling it updates the label.

Step 4 - Go live and join from the browser

If you now join the call from the browser, you will see that the participant list updates as you open/close the browser tab.

Note how the web interface won't allow you to share your audio/video. The reason for this is that by default the audio_room call type only allows moderators or admins to speak. Regular participants can request permission. And if different defaults make sense for your app you can edit the call type in the dashboard or create your own.

Human checkpoint: the first time you unmute, the browser asks for microphone access - click Allow. Browsers only grant media access on secure origins, which localhost (and any HTTPS site) satisfies. Agents: start the dev server, then ask the human to open the page and allow microphone access.

Checkpoint: with the room live, a second browser tab joins as a listener and appears in the participant list. The listener can't publish audio yet - that's Step 6.

Step 5 - Enable Noise Cancellation

Background noise in an audio session is never a pleasant experience for the listeners and the speaker.

Our SDK provides a plugin that helps to greatly reduce the unwanted noise caught by your microphone. Read more on how to enable it here.

Step 6 - Requesting permission to speak

Requesting permission to speak is easy. Let's first have a quick look at how the SDK call object exposes this:

Requesting permission to speak

tsx
1
2
3
4
5
import { OwnCapability } from "@stream-io/video-react-sdk"; await call.requestPermissions({ permissions: [OwnCapability.SEND_AUDIO], });

Handling permission requests

Permission requests are delivered to the call object in a form of an event one can subscribe to:

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// `call.on` returns an unsubscribe function. Call it when you no longer want to // listen, for example in the cleanup of a React effect (as in the panel below). const unsubscribe = call.on("call.permission_request", async (request) => { const { user, permissions } = request; // grant the requested permissions... await call.grantPermissions(user.id, permissions); // ...or reject them instead: // await call.revokePermissions(user.id, permissions); }); // later, when you're done listening: unsubscribe();

Let's add another view that shows the last incoming request as well as the buttons to grant / reject it

MyPermissionRequestsPanel.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
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
import { useCall, type PermissionRequestEvent, } from "@stream-io/video-react-sdk"; import { useCallback, useEffect, useState } from "react"; export const MyPermissionRequestsPanel = () => { // this hook will take the call instance from the <StreamCall /> context. const call = useCall(); const [permissionRequests, setPermissionRequests] = useState< PermissionRequestEvent[] >([]); useEffect(() => { return call?.on("call.permission_request", (request) => { setPermissionRequests((reqs) => [...reqs, request]); }); }, [call]); const handlePermissionRequest = useCallback( async (request: PermissionRequestEvent, accept: boolean) => { const { user, permissions } = request; try { if (accept) { await call?.grantPermissions(user.id, permissions); } else { await call?.revokePermissions(user.id, permissions); } setPermissionRequests((reqs) => reqs.filter((req) => req !== request)); } catch (err) { console.error(`Error granting or revoking permissions`, err); } }, [call], ); if (!permissionRequests.length) return null; return ( <div className="permission-requests"> <h4>Permission Requests</h4> {permissionRequests.map((request) => ( <div className="permission-request" key={request.user.id}> <span> {request.user.name} requested to {request.permissions.join(", ")} </span> <button onClick={() => handlePermissionRequest(request, true)}> Approve </button> <button onClick={() => handlePermissionRequest(request, false)}> Deny </button> </div> ))} </div> ); };

And here is the updated UILayout code that includes it:

MyUILayout.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
export const MyUILayout = () => { return ( <div className="ui-layout"> <MyDescriptionPanel /> <MyParticipantsPanel /> <MyPermissionRequestsPanel /> <MyControlsPanel /> </div> ); };

Checkpoint: a listener in the second tab requests to speak, the request appears in the host's panel, and approving it lets that tab publish audio - they move into the speakers group.

Step 7 - Group participants

It is common for audio rooms and similar interactive audio/video experiences to show users in separate groups. Let's see how we can update this application to render participants in two separate sections: speakers and listeners.

Building custom layouts is very simple, all we need to do is to apply some filtering to the result of useParticipants() hook.

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { SfuModels, type StreamVideoParticipant, useCallStateHooks, } from "@stream-io/video-react-sdk"; // grab a reference to the useParticipants hook const { useParticipants } = useCallStateHooks(); // a list of participants, by default this is list is ordered by the ID of the user const participants = useParticipants(); const hasAudio = (p: StreamVideoParticipant) => p.publishedTracks.includes(SfuModels.TrackType.AUDIO); // Speakers: participants that have an audio track // (i.e., are allowed to speak and have a mic configured) const speakers = participants.filter((p) => hasAudio(p)); // Listeners: participants that do not have an audio track const listeners = participants.filter((p) => !hasAudio(p));

We already have a view to display participants so all we need to do is to update it to use the new speakers and listeners arrays.

MyParticipantsPanel.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
26
27
28
29
30
31
32
33
34
35
import { ParticipantsAudio, SfuModels, type StreamVideoParticipant, useCallStateHooks, } from "@stream-io/video-react-sdk"; import { MyParticipant } from "./MyParticipant"; export const MyParticipantsPanel = () => { const hasAudio = (p: StreamVideoParticipant) => p.publishedTracks.includes(SfuModels.TrackType.AUDIO); const { useParticipants } = useCallStateHooks(); const participants = useParticipants(); const speakers = participants.filter((p) => hasAudio(p)); const listeners = participants.filter((p) => !hasAudio(p)); return ( <> <h4>Speakers</h4> <ParticipantsAudio participants={speakers} /> <div className="participants-panel"> {speakers.map((p) => ( <MyParticipant participant={p} key={p.sessionId} /> ))} </div> <h4>Listeners</h4> <div className="participants-panel"> {listeners.map((p) => ( <MyParticipant participant={p} key={p.sessionId} /> ))} </div> </> ); };

Note that here a speaker is anyone with a published audio track, so a speaker who mutes their microphone drops back into the listeners group. If you'd rather keep granted speakers listed while they're muted, filter on the participant's role/granted capabilities instead of on publishedTracks.

Checkpoint: the panel renders two groups. Granted speakers appear under Speakers, everyone else under Listeners, and a speaker who mutes drops back down to the listeners group.

For 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.

Other built-in features

There are a few more exciting features that you can use to build audio rooms

  • Query Calls: You can query calls to easily show upcoming calls, calls that recently finished as well as call previews.
  • Reactions & Custom events: Reactions and custom events are supported.
  • Recording & Broadcasting: You can record and broadcast your calls.
  • Chat: Stream's Chat SDKs are fully featured and you can integrate them in the call
  • Moderation: Moderation capabilities are built-in to the product
  • Transcriptions: You can enable transcriptions for your calls

Recap

It was fun to see just how quickly you can build an audio-room for your app. Please do let us know if you ran into any issues. Our team is also happy to review your UI designs and offer recommendations on how to achieve it with Stream.

To recap what we've learned:

  • You set up a call with const call = client.call('audio_room', '123')
  • The call type audio_room controls which features are enabled and how permissions are set up
  • The audio_room by default enables backstage mode, and only allows admins and the creator of the call to join before the call goes live
  • When you join a call, realtime communication is set up for audio: await call.join()
  • Call state call.state and utility state access hooks exposed through useCallStateHooks() make it easy to build your own UI
  • For audio rooms, we use Opus RED and Opus DTX for optimal audio quality.

We've used Stream's Audio Rooms API, which means calls run on a global edge network of video servers. By being closer to your users the latency and reliability of calls are better. The React SDK enables you to build in-app video calling, audio rooms and livestreaming in days.

We hope you've enjoyed this tutorial and please do feel free to reach out if you have any suggestions or questions. You can find the code and the stylesheet for this tutorial in this CodeSandbox.

The source code for the companion audio room app, together with all of its features, is available on GitHub.

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, then getstream skills stream-react for the web React pack (the router, docs and builder skills install with it). (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) -> getstream env --target vite (writes VITE_STREAM_API_KEY to .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-react in your agent for React and Next.js setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/video/docs/react/llms.txt
  • Markdown endpoints: append .md to any docs URL for a clean, token-efficient version
  • Call type: audio_room - starts in backstage, only admins and moderators can publish audio; everyone else requests permission via OwnCapability.SEND_AUDIO
  • Source of truth for APIs: the SDK repository - check the installed version's source rather than assuming APIs from training data

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.