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

Build a Live Virtual Classroom with React and Stream Video

New
15 min read
Raymond F
Raymond F
Published August 6, 2026
Build a Live Virtual Classroom with React and Stream Video

Live classes are now table stakes for education products.

Whether you're running a tutoring marketplace, a corporate training platform, or a complete online school, sooner or later a stakeholder asks for "a Zoom inside our app," but with the rules of a classroom. The teacher controls the room, students ask to speak instead of talking over each other, and everything works on the phone a student actually has in their hand.

Building that from raw WebRTC means months of work on signaling, SFUs, device handling, and reconnection logic before you write a single classroom feature.

In this tutorial, you'll build it in an afternoon with Stream's React Video SDK: a virtual classroom where a teacher runs the class from a laptop, and students join from their phones, with hand-raising, a moderated microphone, and a live roster.

What You'll Build

Here's what the finished product looks like, with the teacher on the left and the students on the right.

This includes:

  • Two roles with real permissions. The teacher can mute any student or the whole room, and Stream's backend enforces it. Students can't grant themselves the same powers by poking at the client.
  • Raise hand. Students tap a button; the teacher sees a queue ordered by who asked first, and can lower any hand from the roster.
  • A classroom mic policy. Students join muted by default and unmute when called on.
  • A live roster. Showing everyone's mic and camera state in real time.
  • Mobile-ready student view. One responsive UI that renders full-screen on a phone.

You get video layouts, screen sharing, device selection, and dominant-speaker detection from the SDK's built-in components, so the code you write is almost entirely classroom logic.

Note if you're handling student data: Stream is SOC 2 Type II, ISO 27001, HIPAA, DPF, and GDPR compliant, so the privacy paperwork a school district asks for is already sorted.

Prerequisites

  • Node 20+
  • A free Stream account - create an app in the dashboard and grab your API key and API secret
  • Working knowledge of React

Project Setup

Scaffold a Vite React app and install the two Stream SDKs plus a minimal backend:

shell
1
2
3
4
npm create vite@latest classroom -- --template react cd classroom npm install @stream-io/video-react-sdk @stream-io/node-sdk express cors dotenv concurrently npm install -D @vitejs/plugin-basic-ssl

You need two SDKs because there are two sides to authentication:

  1. @stream-io/video-react-sdk runs in the browser.
  2. @stream-io/node-sdk runs on your server, where your API secret lives. The secret must never be shipped to the client, as anyone who holds it can impersonate any user in your app.

The basic-ssl plugin enables HTTPS in dev. You won't need it on localhost, but you will when you test on a real phone. Browsers only expose the camera and microphone to secure origins.

Put your credentials in .env at the project root:

shell
1
2
3
STREAM_API_KEY=your_api_key STREAM_API_SECRET=your_api_secret PORT=3001

Step 1: The Token Server

Every user connects to Stream with a token your backend mints. This is also the natural place to decide who gets to run the classroom, because call permissions in Stream are attached to server-assigned roles, not to anything the client claims about itself.

Create server/index.js:

javascript
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
import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import { StreamClient } from '@stream-io/node-sdk'; const apiKey = process.env.STREAM_API_KEY; const apiSecret = process.env.STREAM_API_SECRET; const client = new StreamClient(apiKey, apiSecret); const app = express(); app.use(cors(), express.json()); const CALL_TYPE = 'default'; const TOKEN_TTL_SECONDS = 8 * 60 * 60; app.post('/api/join', async (req, res) => { try { const { name, role, callId } = req.body; if (!name || !callId || !['teacher', 'student'].includes(role)) { return res.status(400).json({ error: 'name, callId and role (teacher|student) are required' }); } const userId = `${role}-${name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; await client.upsertUsers([{ id: userId, name: name.trim() }]); const call = client.video.call(CALL_TYPE, callId); await call.getOrCreate({ data: { created_by_id: userId, custom: { classroom: true } }, }); await call.updateCallMembers({ update_members: [{ user_id: userId, role: role === 'teacher' ? 'host' : 'user' }], }); const token = client.generateUserToken({ user_id: userId, validity_in_seconds: TOKEN_TTL_SECONDS, }); res.json({ apiKey, token, userId, callId }); } catch (err) { console.error('join failed:', err); res.status(500).json({ error: 'Could not join the classroom. Check the server logs.' }); } }); const port = process.env.PORT || 3001; app.listen(port, () => console.log(`Token server running on http://localhost:${port}`));

The line that makes the whole classroom work is updateCallMembers. Stream's built-in default call type includes a host role that carries capabilities such as mute-users and update-call-permissions.

By adding the teacher as a member with role: 'host', and students as plain users, you've established the classroom's power structure server-side. Later, when the teacher's client calls muteUser(), Stream's backend checks this membership before acting. A student sending the same request gets rejected, no matter what UI they've hacked together.

In production, you'd derive role from your own auth system (the session of the logged-in teacher) rather than trusting the request body (here, we're taking it from the join form to keep the focus on the video features).

Wire up the dev workflow in package.json, so one command runs both processes:

json
1
2
3
4
5
6
"scripts": { "dev": "concurrently -n server,web \"npm run server\" \"vite\"", "dev:lan": "LAN=1 concurrently -n server,web \"npm run server\" \"vite\"", "server": "node server/index.js", "web": "vite" }

In vite.config.js, proxy /api to the token server, and when the LAN flag is set, serve over HTTPS on all network interfaces so a phone on your Wi-Fi can join:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// vite.config.js import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import basicSsl from '@vitejs/plugin-basic-ssl' // LAN=1 serves over HTTPS on all interfaces so phones on the same network // can join — browsers only allow camera/mic access on secure origins. const lan = !!process.env.LAN export default defineConfig({ plugins: [react(), ...(lan ? [basicSsl()] : [])], server: { host: lan ? true : 'localhost', proxy: { '/api': 'http://localhost:3001', }, }, })

Step 2: Connecting to the Call

The client flow is:

  1. Exchange a name and role for a token
  2. Create a StreamVideoClient
  3. Join the call
  4. Hand everything to the SDK's provider components:
javascript
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
// src/App.jsx import { useEffect, useState } from 'react'; import { StreamVideo, StreamVideoClient, StreamCall, StreamTheme, } from '@stream-io/video-react-sdk'; import { JoinScreen } from './components/JoinScreen'; import { Classroom } from './components/Classroom'; export default function App() { const [session, setSession] = useState(null); // { apiKey, token, userId, name, role, callId } const [client, setClient] = useState(null); const [call, setCall] = useState(null); useEffect(() => { if (!session) return; const videoClient = new StreamVideoClient({ apiKey: session.apiKey, user: { id: session.userId, name: session.name }, token: session.token, }); const classroomCall = videoClient.call('default', session.callId); // Students join muted so a full class doesn't turn into crosstalk; // they ask to speak with the raise-hand button instead. if (session.role === 'student') { classroomCall.microphone.disable(); } classroomCall.join({ create: true }).then(() => { setClient(videoClient); setCall(classroomCall); }); return () => { classroomCall.leave().catch(() => {}); videoClient.disconnectUser(); setClient(null); setCall(null); }; }, [session]); if (!session) return <JoinScreen onJoin={setSession} />; if (!client || !call) return <div className="loading-screen"><p>Joining {session.callId}…</p></div>; return ( <StreamVideo client={client}> <StreamTheme> <StreamCall call={call}> <Classroom role={session.role} callId={session.callId} onLeave={() => setSession(null)} /> </StreamCall> </StreamTheme> </StreamVideo> ); }

Two details worth calling out:

  • classroomCall.microphone.disable() before join() is the entire "students join muted" feature. The SDK's device manager applies the setting before any audio is published, so students never broadcast that first half-second of background noise.
  • The cleanup function leaves the call and disconnects the user, so navigating away doesn't strand a ghost participant in the room.

JoinScreen is an ordinary form with name, class code, and a teacher/student radio that POSTs to /api/join and passes the response up.

Don't forget the Stream SDK stylesheet in src/main.jsx:

javascript
1
2
3
// src/main.jsx import '@stream-io/video-react-sdk/dist/css/styles.css'; import './index.css';
Building your own app? Get access to our Livestream or Video Calling API and launch in days!

Step 3: The Teacher's View

The teacher gets a laptop-oriented layout with the video stage, a side panel with the roster and raised-hand queue, and a control bar.

Almost all of the heavy lifting is SDK components:

javascript
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
// src/components/TeacherView.jsx import { SpeakerLayout, ToggleAudioPublishingButton, ToggleVideoPublishingButton, ScreenShareButton, useCall, useCallStateHooks, } from '@stream-io/video-react-sdk'; import { useRaisedHands } from '../hooks/useRaisedHands'; import { ParticipantsPanel } from './ParticipantsPanel'; export function TeacherView({ callId, onLeave }) { const call = useCall(); const { useParticipantCount } = useCallStateHooks(); const participantCount = useParticipantCount(); const raisedHands = useRaisedHands(); return ( <div className="teacher-view"> <header className="class-header"> <div> <span className="live-dot" aria-hidden="true" /> <h1>{callId}</h1> </div> <span className="participant-count">{participantCount} in class</span> </header> <div className="teacher-main"> <div className="stage"> <SpeakerLayout participantsBarPosition="bottom" /> </div> <ParticipantsPanel raisedHands={raisedHands} /> </div> <footer className="control-bar"> <ToggleAudioPublishingButton /> <ToggleVideoPublishingButton /> <ScreenShareButton /> <button className="bar-button" onClick={() => call?.muteAllUsers('audio')} title="Mute every student's microphone" > Mute all </button> <button className="bar-button leave" onClick={onLeave}> End class </button> </footer> </div> ); }

SpeakerLayout gives you dominant-speaker switching, a scrollable filmstrip of everyone else, and automatic screen-share handling. When the teacher clicks the ScreenShareButton to present slides, the layout promotes the shared screen without any code from you.

Teacher's view with the roster panel and mute-all control

call.muteAllUsers('audio') is the "settle down, everyone" button. It's a server-side operation. Stream stops the audio at the backend, and every muted client's UI updates to reflect it.

Step 4: The Roster, With Per-Student Mute

The roster is where Stream's reactive state is ideal. useParticipants() re-renders whenever anyone joins, leaves, or changes what they're publishing, so mic/cam indicators are always live:

javascript
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
// src/components/ParticipantsPanel.jsx import { SfuModels, useCall, useCallStateHooks } from '@stream-io/video-react-sdk'; export function ParticipantsPanel({ raisedHands }) { const call = useCall(); const { useParticipants } = useCallStateHooks(); const participants = useParticipants(); const { hands, lowerHand } = raisedHands; return ( <aside className="participants-panel"> <section className="hand-queue"> <h2>Raised hands</h2> {hands.length === 0 ? ( <p className="empty-note">No hands up right now.</p> ) : ( <ol> {hands.map((hand) => ( <li key={hand.userId}> <span className="hand-badge" aria-hidden="true">āœ‹</span> <span className="hand-name">{hand.name}</span> <button className="chip-button" onClick={() => lowerHand(hand.userId)}> Lower hand </button> </li> ))} </ol> )} </section> <section className="roster"> <h2>Everyone</h2> <ul> {participants.map((p) => { const micOn = p.publishedTracks.includes(SfuModels.TrackType.AUDIO); const camOn = p.publishedTracks.includes(SfuModels.TrackType.VIDEO); const handUp = hands.some((h) => h.userId === p.userId); return ( <li key={p.sessionId}> <span className="roster-name"> {p.name || p.userId} {p.isLocalParticipant && ' (you)'} {handUp && <span aria-label="hand raised"> āœ‹</span>} </span> <span className="roster-state"> <span className={micOn ? 'on' : 'off'}>{micOn ? 'Mic on' : 'Muted'}</span> <span className={camOn ? 'on' : 'off'}>{camOn ? 'Cam on' : 'Cam off'}</span> </span> {!p.isLocalParticipant && micOn && ( <button className="chip-button" onClick={() => call?.muteUser(p.userId, 'audio')}> Mute </button> )} </li> ); })} </ul> </section> </aside> ); }

A participant's publishedTracks tells you exactly what media they're sending right now, which is more trustworthy than tracking mute state yourself. And the per-student Mute button is one line: call.muteUser(p.userId, 'audio'). Because the teacher holds the host role from Step 1, Stream authorizes it; the student's device stops publishing audio, and their own UI flips to muted, instantly.

Roster panel showing raised hands and each student's mic and camera state

Step 5: Hand Raising with Custom Events

Stream doesn't ship a "raise hand" button, but it ships something better: sendCustomEvent(), a real-time channel for everyone on the call to send any JSON payload you like.

That means hand raising is your feature, with your rules. Here, that means a queue ordered by who asked first, which either side can clear.

The whole feature is one hook:

javascript
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
// src/hooks/useRaisedHands.js import { useCallback, useEffect, useState } from 'react'; import { useCall, useCallStateHooks } from '@stream-io/video-react-sdk'; export function useRaisedHands() { const call = useCall(); const { useLocalParticipant } = useCallStateHooks(); const localParticipant = useLocalParticipant(); const [hands, setHands] = useState([]); // [{ userId, name }] in raise order const localUserId = localParticipant?.userId; const handIsRaised = hands.some((h) => h.userId === localUserId); useEffect(() => { if (!call) return; const unsubscribe = call.on('custom', (event) => { const { type, userId } = event.custom ?? {}; if (type === 'hand-raised') { const raiser = { userId: event.user.id, name: event.user.name || event.user.id }; setHands((prev) => (prev.some((h) => h.userId === raiser.userId) ? prev : [...prev, raiser])); } if (type === 'hand-lowered') { const target = userId ?? event.user.id; setHands((prev) => prev.filter((h) => h.userId !== target)); } }); return unsubscribe; }, [call]); // Re-broadcast for late joiners so they see hands already in the air. useEffect(() => { if (!call || !handIsRaised) return; const unsubscribe = call.on('call.session_participant_joined', () => { call.sendCustomEvent({ type: 'hand-raised' }); }); return unsubscribe; }, [call, handIsRaised]); const raiseHand = useCallback(() => { if (!call || !localParticipant) return; setHands((prev) => prev.some((h) => h.userId === localUserId) ? prev : [...prev, { userId: localUserId, name: localParticipant.name || localUserId }], ); call.sendCustomEvent({ type: 'hand-raised' }); }, [call, localParticipant, localUserId]); const lowerHand = useCallback( (userId = localUserId) => { if (!call) return; setHands((prev) => prev.filter((h) => h.userId !== userId)); call.sendCustomEvent({ type: 'hand-lowered', userId }); }, [call, localUserId], ); return { hands, handIsRaised, raiseHand, lowerHand }; }

You should notice three design decisions here:

  1. The queue preserves order. New hands append to the array, and the teacher's panel numbers them so "who was first?" is never a judgment call.
  2. hand-lowered carries a userId. When a student lowers their own hand, the target defaults to the sender. When the teacher lowers a student's hand, the event names the student, and because the student's client processes the same event, the student's " Lower hand" button automatically resets to "Raise hand." One event type, both directions.
  3. Late joiners get synced. Custom events are fire-and-forget, so someone who joins after a hand has been raised would miss it. The second effect handles this. Whenever a new participant joins, anyone who has raised a hand quietly rebroadcasts. The prev.some() dedupe check makes replays harmless.

Step 6: The Student's View Designed for a Phone

Students in a live class are overwhelmingly on phones, so the student UI is built phone-first.

The teacher's video is big, and there's a thumb-reachable control bar with one prominent amber raise-hand button:

javascript
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
// src/components/StudentView.jsx import { SpeakerLayout, ToggleAudioPublishingButton, ToggleVideoPublishingButton, useCallStateHooks, } from '@stream-io/video-react-sdk'; import { useRaisedHands } from '../hooks/useRaisedHands'; export function StudentView({ callId, onLeave }) { const { useParticipantCount, useMicrophoneState } = useCallStateHooks(); const participantCount = useParticipantCount(); const { isMute } = useMicrophoneState(); const { handIsRaised, raiseHand, lowerHand } = useRaisedHands(); return ( <div className="student-view"> <header className="class-header compact"> <div> <span className="live-dot" aria-hidden="true" /> <h1>{callId}</h1> </div> <span className="participant-count">{participantCount}</span> </header> {isMute && <p className="muted-note">You're muted — raise your hand to ask a question.</p>} <div className="stage"> <SpeakerLayout participantsBarPosition="bottom" /> </div> <footer className="control-bar"> <ToggleAudioPublishingButton /> <ToggleVideoPublishingButton /> <button className={`hand-button${handIsRaised ? 'raised' : ''}`} onClick={() => (handIsRaised ? lowerHand() : raiseHand())} > āœ‹ {handIsRaised ? 'Lower hand' : 'Raise hand'} </button> <button className="bar-button leave" onClick={onLeave}> Leave </button> </footer> </div> ); }

useMicrophoneState().isMute drives the "You're muted" banner, and it remains accurate regardless of the reason the student is muted. They joined that way, they muted themselves, or the teacher muted them from the roster.

Student's mobile view showing the muted banner and raise-hand control

Because it's the same React SDK across desktop and mobile browsers, "mobile support" is a CSS problem (not an architectural one).

The stylesheet does two things. Below 700px, the student view goes edge-to-edge full-screen (with env(safe-area-inset-bottom) padding so the controls clear the iPhone home indicator):

css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.phone-frame { width: 390px; height: min(800px, 94vh); border: 10px solid #000; border-radius: 44px; overflow: hidden; } @media (max-width: 700px) { .phone-frame { width: 100%; height: 100dvh; border: none; border-radius: 0; } }

If you're shipping to app stores rather than the mobile web, the same call, roles, and custom events apply across Stream's React Native, iOS, and Android SDKs. A student on the native app and one on the mobile web join the same classroom.

Try It

For a quick single-machine test:

shell
1
npm run dev

Open http://localhost:5173, join as Teacher, then open a second tab and join as a Student (the student renders inside a phone frame so you can see the mobile layout without leaving your desk).

For the real thing, with the teacher on your laptop and students on actual phones, start in LAN mode:

shell
1
npm run dev:lan

This is where the basic-ssl plugin earns its keep. If you serve plain HTTP to another device, students can join the call, but getUserMedia fails silently on their phones: no camera, no mic, and no obvious error. Over HTTPS, it just works.

On each phone, open https://<your-computer's-LAN-IP>:5173 (find the IP with ipconfig getifaddr en0 on macOS or hostname -I on Linux), accept the self-signed certificate warning, join as a Student, and allow camera and mic access.

Then run the class:

  1. Student taps ** Raise hand** -> the teacher's queue shows them, numbered.
  2. Teacher calls on them; student unmutes and asks their question.
  3. Teacher hits Lower hand, then Mute on the roster. The student's phone will flip back to muted on its own.
  4. Mute all when the whole back row forgets they're live.

Where To Take It Next

This is a solid starting point. Natural next steps for a production classroom:

  • Stricter mic policy. Instead of trusting students to stay muted, revoke the send-audio capability with call.updateUserPermissions() and grant it only when the teacher calls on a raised hand, turning the hand queue into a true request-to-speak flow.
  • Class chat. Stream's Chat SDK shares the same user tokens, so a channel per classroom is a small addition.
  • Recording and attendance. call.startRecording() captures the lesson; call session events to give you join/leave timestamps for attendance.
  • Breakout groups. Each group is just another call; move students between call IDs.

You built a working virtual classroom in a few hundred lines of React code: server-enforced teacher moderation, an ordered raise-hand queue via custom events, and a student experience that's at home on a phone.

None of it required touching WebRTC internals. The Stream SDK handled the media layer, so every line you wrote was classroom logic.

Ready to add live classes to your own product? Create a free Stream account, grab your API keys, and you can have this running against your own app before your next standup. The React Video SDK docs cover everything this tutorial touched on, including call types, permissions, custom events, and where to go deeper.

Scaling WebRTC Video to 100,000 Participants
View Stream's latest Video API benchmark and the architecture that powers performance at scale.