Loading…
Loading…
Build a peer-to-peer video call application using WebRTC for media streaming and Firebase Realtime Database for signaling, with room creation, screen sharing, and call controls.
Tags
Firebase Realtime Database serves as a lightweight signaling server for WebRTC, letting you build a fully functional peer-to-peer video call app with room codes, screen sharing, and call controls without deploying your own signaling infrastructure.
Create a new project and install Firebase.
npm create vite@latest webrtc-video -- --template react-ts
cd webrtc-video
npm install firebaseConfigure Firebase in your project:
// src/lib/firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);Create a .env file with your Firebase credentials (never commit this file):
VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
Before writing code, understand the handshake process that two peers go through to establish a direct connection.
RTCPeerConnection and generates an SDP offerFirebase acts only as a relay for the initial handshake. Once the connection is established, media flows directly between the browsers.
STUN servers help peers discover their public IP addresses for NAT traversal. TURN servers relay traffic when direct connections fail.
// src/lib/rtcConfig.ts
export const rtcConfiguration: RTCConfiguration = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
// Add a TURN server for production reliability
// {
// urls: "turn:your-turn-server.com:3478",
// username: "user",
// credential: "pass",
// },
],
};Google provides free STUN servers suitable for development. For production, you should add a TURN server since roughly 10-15% of connections require relaying through restrictive NATs.
Build a utility to handle camera and microphone access.
// src/lib/mediaStream.ts
export async function getLocalStream(
video: boolean = true,
audio: boolean = true
): Promise<MediaStream> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: video
? {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: "user",
}
: false,
audio: audio
? {
echoCancellation: true,
noiseSuppression: true,
}
: false,
});
return stream;
} catch (error) {
if (error instanceof DOMException) {
if (error.name === "NotAllowedError") {
throw new Error("Camera/microphone access was denied. Please grant permission.");
}
if (error.name === "NotFoundError") {
throw new Error("No camera or microphone found on this device.");
}
}
throw error;
}
}
export async function getScreenShareStream(): Promise<MediaStream> {
return navigator.mediaDevices.getDisplayMedia({
video: { cursor: "always" } as MediaTrackConstraints,
audio: false,
});
}
export function stopStream(stream: MediaStream) {
stream.getTracks().forEach((track) => track.stop());
}This is the core of the application. Firebase Firestore stores offers, answers, and ICE candidates in a room document structure.
// src/lib/signaling.ts
import {
doc,
collection,
setDoc,
getDoc,
onSnapshot,
addDoc,
updateDoc,
deleteDoc,
} from "firebase/firestore";
import { db } from "./firebase";
import { rtcConfiguration } from "./rtcConfig";
export async function createRoom(
localStream: MediaStream,
onRemoteStream: (stream: MediaStream) => void
): Promise<{ roomId: string; peerConnection: RTCPeerConnection }> {
const pc = new RTCPeerConnection(rtcConfiguration);
// Add local tracks to the connection
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
// Listen for remote tracks
const remoteStream = new MediaStream();
pc.ontrack = (event) => {
event.streams[0].getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
onRemoteStream(remoteStream);
};
// Create the room document
const roomRef = doc(collection(db, "rooms"));
const callerCandidatesRef = collection(roomRef, "callerCandidates");
const calleeCandidatesRef = collection(roomRef, "calleeCandidates");
// Collect ICE candidates and write them to Firebase
pc.onicecandidate = (event) => {
if (event.candidate) {
addDoc(callerCandidatesRef, event.candidate.toJSON());
}
};
// Create and set the offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await setDoc(roomRef, {
offer: {
type: offer.type,
sdp: offer.sdp,
},
createdAt: new Date().toISOString(),
});
// Listen for the answer from the callee
onSnapshot(roomRef, (snapshot) => {
const data = snapshot.data();
if (data?.answer && !pc.currentRemoteDescription) {
const answer = new RTCSessionDescription(data.answer);
pc.setRemoteDescription(answer);
}
});
// Listen for callee ICE candidates
onSnapshot(calleeCandidatesRef, (snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
const candidate = new RTCIceCandidate(change.doc.data());
pc.addIceCandidate(candidate);
}
});
});
return { roomId: roomRef.id, peerConnection: pc };
}
export async function joinRoom(
roomId: string,
localStream: MediaStream,
onRemoteStream: (stream: MediaStream) => void
): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection(rtcConfiguration);
// Add local tracks
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
// Listen for remote tracks
const remoteStream = new MediaStream();
pc.ontrack = (event) => {
event.streams[0].getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
onRemoteStream(remoteStream);
};
const roomRef = doc(db, "rooms", roomId);
const callerCandidatesRef = collection(roomRef, "callerCandidates");
const calleeCandidatesRef = collection(roomRef, "calleeCandidates");
// Collect ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
addDoc(calleeCandidatesRef, event.candidate.toJSON());
}
};
// Get the offer and create an answer
const roomSnapshot = await getDoc(roomRef);
if (!roomSnapshot.exists()) {
throw new Error("Room not found. Check the room code and try again.");
}
const offer = roomSnapshot.data().offer;
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await updateDoc(roomRef, {
answer: {
type: answer.type,
sdp: answer.sdp,
},
});
// Listen for caller ICE candidates
onSnapshot(callerCandidatesRef, (snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
const candidate = new RTCIceCandidate(change.doc.data());
pc.addIceCandidate(candidate);
}
});
});
return pc;
}Create the main component that ties everything together.
// src/components/VideoCall.tsx
import { useState, useRef, useEffect, useCallback } from "react";
import { getLocalStream, getScreenShareStream, stopStream } from "../lib/mediaStream";
import { createRoom, joinRoom } from "../lib/signaling";
type CallState = "idle" | "connecting" | "connected" | "ended";
export function VideoCall() {
const [callState, setCallState] = useState<CallState>("idle");
const [roomId, setRoomId] = useState("");
const [joinRoomId, setJoinRoomId] = useState("");
const [isMuted, setIsMuted] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const setupLocalVideo = useCallback(async () => {
const stream = await getLocalStream();
localStreamRef.current = stream;
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream;
}
return stream;
}, []);
const handleRemoteStream = useCallback((stream: MediaStream) => {
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = stream;
}
setCallState("connected");
}, []);
const handleCreateRoom = async () => {
setCallState("connecting");
const stream = await setupLocalVideo();
const { roomId: newRoomId, peerConnection } = await createRoom(stream, handleRemoteStream);
pcRef.current = peerConnection;
setRoomId(newRoomId);
peerConnection.onconnectionstatechange = () => {
if (peerConnection.connectionState === "disconnected") {
handleEndCall();
}
};
};
const handleJoinRoom = async () => {
if (!joinRoomId.trim()) return;
setCallState("connecting");
const stream = await setupLocalVideo();
const peerConnection = await joinRoom(joinRoomId, stream, handleRemoteStream);
pcRef.current = peerConnection;
setRoomId(joinRoomId);
peerConnection.onconnectionstatechange = () => {
if (peerConnection.connectionState === "disconnected") {
handleEndCall();
}
};
};
const handleEndCall = () => {
pcRef.current?.close();
if (localStreamRef.current) stopStream(localStreamRef.current);
setCallState("ended");
setRoomId("");
};
const toggleMute = () => {
const audioTrack = localStreamRef.current?.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
setIsMuted(!audioTrack.enabled);
}
};
const toggleVideo = () => {
const videoTrack = localStreamRef.current?.getVideoTracks()[0];
if (videoTrack) {
videoTrack.enabled = !videoTrack.enabled;
setIsVideoOff(!videoTrack.enabled);
}
};
const toggleScreenShare = async () => {
if (!pcRef.current) return;
const sender = pcRef.current.getSenders().find((s) => s.track?.kind === "video");
if (!sender) return;
if (isScreenSharing) {
// Switch back to camera
const cameraTrack = localStreamRef.current?.getVideoTracks()[0];
if (cameraTrack) await sender.replaceTrack(cameraTrack);
} else {
// Switch to screen share
const screenStream = await getScreenShareStream();
const screenTrack = screenStream.getVideoTracks()[0];
await sender.replaceTrack(screenTrack);
// Revert to camera when screen share ends
screenTrack.onended = async () => {
const cameraTrack = localStreamRef.current?.getVideoTracks()[0];
if (cameraTrack) await sender.replaceTrack(cameraTrack);
setIsScreenSharing(false);
};
}
setIsScreenSharing(!isScreenSharing);
};
return (
<div className="video-call-container">
<div className="video-grid">
<div className="video-wrapper">
<video ref={localVideoRef} autoPlay muted playsInline />
<span className="video-label">You</span>
</div>
<div className="video-wrapper">
<video ref={remoteVideoRef} autoPlay playsInline />
<span className="video-label">Remote</span>
</div>
</div>
{callState === "idle" && (
<div className="call-actions">
<button onClick={handleCreateRoom}>Create Room</button>
<div className="join-section">
<input
value={joinRoomId}
onChange={(e) => setJoinRoomId(e.target.value)}
placeholder="Enter room code"
/>
<button onClick={handleJoinRoom}>Join Room</button>
</div>
</div>
)}
{roomId && callState === "connecting" && (
<div className="room-info">
<p>Share this room code: <strong>{roomId}</strong></p>
<p>Waiting for the other person to join...</p>
</div>
)}
{callState === "connected" && (
<div className="call-controls">
<button onClick={toggleMute}>{isMuted ? "Unmute" : "Mute"}</button>
<button onClick={toggleVideo}>{isVideoOff ? "Camera On" : "Camera Off"}</button>
<button onClick={toggleScreenShare}>
{isScreenSharing ? "Stop Share" : "Share Screen"}
</button>
<button onClick={handleEndCall} className="end-call">End Call</button>
</div>
)}
</div>
);
}Screen sharing uses RTCRtpSender.replaceTrack() to swap the camera video track for the screen capture track. This is seamless because it does not renegotiate the entire peer connection -- it simply replaces the media being sent through the existing connection. When the user stops screen sharing (either via the button or the browser's built-in "Stop sharing" UI), the onended event fires and we revert to the camera track.
Secure your signaling data so only call participants can read and write.
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /rooms/{roomId} {
allow read, write: if true; // For development only
// For production, require authentication:
// allow read, write: if request.auth != null;
match /callerCandidates/{candidateId} {
allow read, write: if true;
}
match /calleeCandidates/{candidateId} {
allow read, write: if true;
}
}
}
}
The complete video call application has four layers working together:
getUserMedia captures the local camera and microphone streamsRTCPeerConnection manages the peer-to-peer connection and media exchangeThe flow is: create a room (writes an offer to Firebase), share the room code, the other person joins (reads the offer, writes an answer), ICE candidates are exchanged through Firebase, and once negotiation completes, video and audio stream directly between browsers without Firebase involvement.
MediaRecorder API to record the remote stream locallyRTCPeerConnection.getStats() to display bandwidth, packet loss, and latency metricsSignaling is the process of exchanging connection metadata (SDP offers, answers, and ICE candidates) between peers before a direct connection is established. WebRTC does not define a signaling protocol, so you need a channel like Firebase to relay this initial handshake data.
WebRTC can work peer-to-peer using STUN servers for NAT traversal in many cases. However, about 10-15% of connections fail without a TURN relay server, especially behind symmetric NATs or corporate firewalls. For production apps, always configure a TURN server as fallback.
Use navigator.mediaDevices.getDisplayMedia() to capture the screen, then replace the video track in your existing peer connection using RTCRtpSender.replaceTrack(). This swaps the camera feed for the screen share without renegotiating the entire connection.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.