WebRTC enables real-time peer-to-peer video and audio communication directly in the browser. Here is how to build it.
WebRTC Connection Manager
// lib/webrtc.ts
type SignalHandler = (data: { type: string; payload: unknown; to: string }) => void;
interface RTCConfig {
iceServers: RTCIceServer[];
}
const defaultConfig: RTCConfig = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
};
export class WebRTCConnection {
private pc: RTCPeerConnection;
private localStream: MediaStream | null = null;
private remoteStream = new MediaStream();
private sendSignal: SignalHandler;
private peerId: string;
onRemoteStream: ((stream: MediaStream) => void) | null = null;
onConnectionState: ((state: RTCPeerConnectionState) => void) | null = null;
constructor(peerId: string, sendSignal: SignalHandler, config = defaultConfig) {
this.peerId = peerId;
this.sendSignal = sendSignal;
this.pc = new RTCPeerConnection(config);
this.pc.ontrack = (event) => {
event.streams[0].getTracks().forEach((track) => {
this.remoteStream.addTrack(track);
});
this.onRemoteStream?.(this.remoteStream);
};
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.sendSignal({
type: "ice-candidate",
payload: event.candidate.toJSON(),
to: this.peerId,
});
}
};
this.pc.onconnectionstatechange = () => {
this.onConnectionState?.(this.pc.connectionState);
};
}
async startCall(stream: MediaStream) {
this.localStream = stream;
stream.getTracks().forEach((track) => {
this.pc.addTrack(track, stream);
});
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.sendSignal({
type: "offer",
payload: offer,
to: this.peerId,
});
}
async handleOffer(offer: RTCSessionDescriptionInit, stream: MediaStream) {
this.localStream = stream;
stream.getTracks().forEach((track) => {
this.pc.addTrack(track, stream);
});
await this.pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.sendSignal({
type: "answer",
payload: answer,
to: this.peerId,
});
}
async handleAnswer(answer: RTCSessionDescriptionInit) {
await this.pc.setRemoteDescription(new RTCSessionDescription(answer));
}
async handleIceCandidate(candidate: RTCIceCandidateInit) {
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
}
toggleAudio(enabled: boolean) {
this.localStream?.getAudioTracks().forEach((track) => {
track.enabled = enabled;
});
}
toggleVideo(enabled: boolean) {
this.localStream?.getVideoTracks().forEach((track) => {
track.enabled = enabled;
});
}
async shareScreen() {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
});
const screenTrack = screenStream.getVideoTracks()[0];
const sender = this.pc
.getSenders()
.find((s) => s.track?.kind === "video");
if (sender) {
await sender.replaceTrack(screenTrack);
}
// Restore camera when screen sharing stops
screenTrack.onended = async () => {
const cameraTrack = this.localStream?.getVideoTracks()[0];
if (sender && cameraTrack) {
await sender.replaceTrack(cameraTrack);
}
};
return screenStream;
}
close() {
this.localStream?.getTracks().forEach((t) => t.stop());
this.pc.close();
}
}
React Hook
// hooks/useVideoCall.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { WebRTCConnection } from "@/lib/webrtc";
interface UseVideoCallOptions {
roomId: string;
userId: string;
signalingUrl: string;
}
export function useVideoCall({ roomId, userId, signalingUrl }: UseVideoCallOptions) {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
const [audioEnabled, setAudioEnabled] = useState(true);
const [videoEnabled, setVideoEnabled] = useState(true);
const connectionRef = useRef<WebRTCConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const sendSignal = useCallback(
(data: { type: string; payload: unknown; to: string }) => {
wsRef.current?.send(JSON.stringify({ ...data, from: userId, room: roomId }));
},
[userId, roomId]
);
const startCall = useCallback(async () => {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720 },
audio: { echoCancellation: true, noiseSuppression: true },
});
setLocalStream(stream);
return stream;
}, []);
useEffect(() => {
const ws = new WebSocket(signalingUrl);
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join", room: roomId, userId }));
};
ws.onmessage = async (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "user-joined": {
const stream = await startCall();
const conn = new WebRTCConnection(data.userId, sendSignal);
conn.onRemoteStream = setRemoteStream;
conn.onConnectionState = setConnectionState;
connectionRef.current = conn;
await conn.startCall(stream);
break;
}
case "offer": {
const stream = await startCall();
const conn = new WebRTCConnection(data.from, sendSignal);
conn.onRemoteStream = setRemoteStream;
conn.onConnectionState = setConnectionState;
connectionRef.current = conn;
await conn.handleOffer(data.payload, stream);
break;
}
case "answer": {
await connectionRef.current?.handleAnswer(data.payload);
break;
}
case "ice-candidate": {
await connectionRef.current?.handleIceCandidate(data.payload);
break;
}
}
};
return () => {
connectionRef.current?.close();
ws.close();
};
}, [roomId, userId, signalingUrl, sendSignal, startCall]);
const toggleAudio = useCallback(() => {
const next = !audioEnabled;
connectionRef.current?.toggleAudio(next);
setAudioEnabled(next);
}, [audioEnabled]);
const toggleVideo = useCallback(() => {
const next = !videoEnabled;
connectionRef.current?.toggleVideo(next);
setVideoEnabled(next);
}, [videoEnabled]);
const shareScreen = useCallback(async () => {
await connectionRef.current?.shareScreen();
}, []);
const endCall = useCallback(() => {
connectionRef.current?.close();
localStream?.getTracks().forEach((t) => t.stop());
setLocalStream(null);
setRemoteStream(null);
setConnectionState("closed");
}, [localStream]);
return {
localStream,
remoteStream,
connectionState,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
shareScreen,
endCall,
};
}
Video Call UI
// components/VideoCall.tsx
"use client";
import { useEffect, useRef } from "react";
import { useVideoCall } from "@/hooks/useVideoCall";
interface VideoCallProps {
roomId: string;
userId: string;
}
export function VideoCall({ roomId, userId }: VideoCallProps) {
const {
localStream,
remoteStream,
connectionState,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
shareScreen,
endCall,
} = useVideoCall({
roomId,
userId,
signalingUrl: process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080",
});
const localRef = useRef<HTMLVideoElement>(null);
const remoteRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (localRef.current && localStream) {
localRef.current.srcObject = localStream;
}
}, [localStream]);
useEffect(() => {
if (remoteRef.current && remoteStream) {
remoteRef.current.srcObject = remoteStream;
}
}, [remoteStream]);
return (
<div className="relative w-full max-w-4xl mx-auto">
{/* Remote video (main) */}
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
{remoteStream ? (
<video ref={remoteRef} autoPlay playsInline className="w-full h-full object-cover" />
) : (
<div className="flex items-center justify-center h-full text-white/60">
{connectionState === "connecting" ? "Connecting..." : "Waiting for participant..."}
</div>
)}
</div>
{/* Local video (picture-in-picture) */}
<div className="absolute top-4 right-4 w-48 aspect-video bg-gray-800 rounded-lg overflow-hidden border-2 border-white/20">
{localStream ? (
<video ref={localRef} autoPlay muted playsInline className="w-full h-full object-cover" />
) : (
<div className="flex items-center justify-center h-full text-white/40 text-xs">
Camera off
</div>
)}
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-3 mt-4">
<button
onClick={toggleAudio}
className={`p-3 rounded-full ${audioEnabled ? "bg-gray-700 text-white" : "bg-red-600 text-white"}`}
aria-label={audioEnabled ? "Mute microphone" : "Unmute microphone"}
>
{audioEnabled ? "Mic On" : "Mic Off"}
</button>
<button
onClick={toggleVideo}
className={`p-3 rounded-full ${videoEnabled ? "bg-gray-700 text-white" : "bg-red-600 text-white"}`}
aria-label={videoEnabled ? "Turn off camera" : "Turn on camera"}
>
{videoEnabled ? "Cam On" : "Cam Off"}
</button>
<button
onClick={shareScreen}
className="p-3 rounded-full bg-gray-700 text-white"
aria-label="Share screen"
>
Screen
</button>
<button
onClick={endCall}
className="p-3 rounded-full bg-red-600 text-white"
aria-label="End call"
>
End
</button>
</div>
</div>
);
}
Need Real-Time Communication Features?
We build video calling, live chat, and real-time collaboration tools. Reach out to discuss your project.