Build a Google Meet clone from scratch — and understand every line of it.
MeetLab is an interactive codelab. It is a working peer-to-peer video meeting application, plus 48 lessons that explain how it was built and, more importantly, why each decision was made. There is no video SDK anywhere in it: you write the offer, the answer and the ICE handling yourself.
| Live demo | https://meetlab.vercel.app |
| Codelab | the apps/docs site — deploy it, or run pnpm dev:docs |
| Repository | https://github.com/jacksonfdam/MeetLab |
| Landing | Lobby |
|---|---|
![]() |
![]() |
| Meeting (two participants) | Chat |
|---|---|
![]() |
![]() |
| Participants | Reactions |
|---|---|
![]() |
![]() |
The green shapes are Chrome's synthetic test camera — these were captured by an automated two-browser end-to-end run, so what you see is real WebRTC media between two real peers.
A meeting in MeetLab works like this: two browsers exchange a small amount of metadata through a signaling server, then connect directly to each other. Once connected, the server is idle for the rest of the call and cannot see a single frame of video.
That property shapes everything else in the codebase, and understanding it is the point of the project.
Meetings
- Create a room, get a shareable link with a human-speakable code (
abc-defg-hij) - A lobby to check your camera and microphone before anyone is watching
- Up to 8 participants in a full mesh
- Device selection for camera and microphone, remembered across sessions
In the call
- Mute / unmute, camera on / off, with real indicators for other participants
- Screen sharing, with automatic presenter promotion
- Chat with typing indicators
- Emoji reactions
- Participant list with per-person media state
- Per-peer connection quality, and automatic ICE restart on failure
- Leave and rejoin
Interface
- Google Meet inspired, responsive from phone to desktop
- Light and dark themes, switchable and remembered
- Keyboard shortcuts (
mmute,vcamera,cchat) - Accessible: labelled toggles, live regions, full keyboard navigation, reduced-motion support
graph TB
subgraph Browser A
FA["React app<br/>useLocalMedia · useSignaling · useWebRtcMesh"]
end
subgraph Browser B
FB["React app"]
end
S["Signaling server<br/>Express 5 + ws<br/>rooms · JWT · relay"]
FA -->|"HTTPS: create / join"| S
FB -->|"HTTPS: create / join"| S
FA <-->|"WSS: offer · answer · ICE · chat"| S
FB <-->|"WSS: offer · answer · ICE · chat"| S
FA <===>|"encrypted media — direct, peer to peer"| FB
T["TURN relay<br/>~10-15% of calls"]
FA -.-> T
T -.-> FB
style S fill:#4c8dff,color:#fff
style T fill:#f59e0b,color:#000
The signaling server is a trusted introducer and nothing more. It authenticates participants, tracks who is in which room, and relays SDP and ICE candidates. It never touches media.
packages/shared holds the contract: Zod schemas for every message, from which the TypeScript types are derived. Both apps compile against it, so a protocol change breaks the build on both sides immediately rather than becoming a runtime mystery.
graph LR
S["packages/shared<br/>protocol · domain · Zod schemas"]
F["apps/frontend"] --> S
B["apps/backend"] --> S
style S fill:#4c8dff,color:#fff
The dependency arrow points one way. shared imports nothing from apps/.
Every version below was verified as the latest stable release compatible with the rest of the toolchain at the time of writing.
Frontend — React 19 · TypeScript 6 · Vite 8 · Tailwind CSS 4 · React Router 8 · Zustand 5 · Motion 12 · Radix UI · shadcn/ui patterns · WebRTC · WebSocket · Vercel Analytics
Backend — Node 22+ · Express 5 · ws 8 · Zod 4 · jsonwebtoken 9 · TypeScript 6
Docs — Vite 8 · React 19 · react-markdown · Shiki 4 · Mermaid 11
Tooling — pnpm 11 workspaces with a version catalog · ESLint 10 flat config with type-aware rules · Prettier 3 · Husky · lint-staged · commitlint (Conventional Commits) · Vitest 4
A note on TypeScript. TypeScript 7.0 is available and is the faster Go-based compiler, but
typescript-eslintdoes not yet support it (its peer range stops at<6.1.0). This project uses 6.0.3 — the newest version that keeps type-aware linting working. See lessonfrontend-00for why "latest stable" is a property of the whole toolchain rather than of one package.
MeetLab/
├── apps/
│ ├── frontend/ React client — the meeting UI
│ │ ├── src/
│ │ │ ├── components/ UI primitives (button, tooltip, avatar…)
│ │ │ ├── features/ video · chat · controls · participants · reactions
│ │ │ ├── hooks/ useLocalMedia · useSignaling · useWebRtcMesh
│ │ │ ├── lib/ API client · config · utils
│ │ │ ├── routes/ home · lobby · room
│ │ │ └── stores/ meeting · session · settings (Zustand)
│ │ └── vite.config.ts
│ │
│ ├── backend/ Signaling server
│ │ ├── src/
│ │ │ ├── auth/ JWT sign / verify
│ │ │ ├── config/ environment validation
│ │ │ ├── domain/ Room · RoomStore
│ │ │ ├── http/ app · middleware · routes
│ │ │ ├── lib/ logger · errors
│ │ │ ├── realtime/ SignalingServer · PeerConnection
│ │ │ ├── services/ RoomService
│ │ │ ├── server.ts buildServer() — testable, not started
│ │ │ └── index.ts process entry point
│ │ └── .env.example
│ │
│ └── docs/ The codelab site
│ ├── content/lessons/ 48 markdown lessons
│ └── src/
│
├── packages/
│ └── shared/ The protocol both apps compile against
│ └── src/
│ ├── protocol.ts client / server message unions
│ ├── domain.ts Room · Participant · MediaState
│ ├── http.ts REST request / response schemas
│ ├── constants.ts timings · limits · close codes
│ └── ids.ts room code generation
│
├── assets/screenshots/
├── eslint.config.ts
├── pnpm-workspace.yaml workspace + version catalog
└── tsconfig.base.json
Requirements: Node ≥ 22.12, pnpm ≥ 10.
git clone https://github.com/jacksonfdam/MeetLab.git
cd MeetLab
pnpm install# Build the shared package first — both apps import its compiled output
pnpm --filter @meetlab/shared build
# Then run everything
pnpm dev| App | URL |
|---|---|
| Frontend | http://localhost:5173 |
| Backend | http://localhost:3001 |
| Codelab | http://localhost:5175 |
Or individually:
pnpm dev:web # frontend
pnpm dev:api # backend
pnpm dev:docs # codelabNo configuration is needed for local development. The backend mints an ephemeral JWT secret and defaults CORS to http://localhost:5173; the frontend defaults to http://localhost:3001.
Open the meeting link in two different browsers or a private window — two tabs of the same profile compete for the camera.
To exercise NAT traversal properly you need two devices on different networks. That is the only way to discover whether you need TURN, and roughly 10–15% of real users do.
pnpm verify # format check + lint + typecheck + tests
pnpm build # build every package in dependency ordergraph LR
U[Browser]
U -->|"/ and /demo"| V["Vercel — one project<br/>docs at / · demo at /demo"]
U -->|"HTTPS + WSS"| R["Railway / Render<br/>signaling server"]
U <-->|"encrypted media"| U2[Other browser]
style V fill:#000,color:#fff
style R fill:#8b5cf6,color:#fff
One Vercel project serves both static apps on a single domain:
| Path | Serves |
|---|---|
/ |
the codelab |
/lesson/backend-14 |
a lesson |
/demo/ |
the demo app |
/demo/abc-defg-hij |
a meeting |
The backend cannot run on a serverless platform. It needs long-lived WebSocket connections and in-memory room state that outlives a single request. Neither survives a function invocation.
Both are static Vite bundles, so hosting them together is just a matter of building both and copying them into one tree:
pnpm build:siteThat builds @meetlab/shared, then the docs, then the demo, then runs scripts/assemble-site.mjs to produce:
dist/ the codelab
dist/demo/ the demo app
Vercel project settings — only one field is not in vercel.json:
| Field | Value |
|---|---|
| Root Directory | empty (the repository root) |
| everything else | from vercel.json |
The committed vercel.json pins installCommand, buildCommand, outputDirectory and the rewrites, so the deploy is deterministic rather than dependent on dashboard fields or Vercel's framework auto-detection.
Environment variables:
VITE_API_URL=https://your-service.up.railway.app # required, see the warning below
VITE_REPO_URL=https://github.com/jacksonfdam/MeetLab
VITE_API_URLis build-time. Vite inlines it into the bundle, so setting it is not enough — you must redeploy. Without it the demo callshttp://localhost:3001and every "Start a meeting" fails with a network error.
Being served from a subpath is not free. Three things must agree, and all three derive from one value:
// apps/frontend/vite.config.ts
base: '/demo/'; // asset URLs -> /demo/assets/...// apps/frontend/src/main.tsx
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
<BrowserRouter basename={basename}> // routes resolve under /demo// apps/frontend/src/routes/room-page.tsx
`${window.location.origin}${import.meta.env.BASE_URL}${roomId}`; // invite linksMiss the first and the page is blank with 404ing assets. Miss the second and every route matches the wrong path. Miss the third and the invitation link you ask people to share lands on the docs site. scripts/assemble-site.mjs fails the build if the demo was built without the correct base, so that failure cannot reach production silently.
# Build
pnpm install --frozen-lockfile && pnpm --filter @meetlab/shared build && pnpm --filter @meetlab/backend build
# Start
node apps/backend/dist/index.jsNODE_ENV=production
JWT_SECRET=<openssl rand -base64 48>
CORS_ORIGINS=https://meetlab.vercel.app
PUBLIC_WS_URL=wss://your-service.up.railway.app/ws
LOG_LEVEL=infoDo not set PORT — the platform injects it. Use wss://, not ws://: an HTTPS page cannot open an insecure WebSocket.
Note CORS_ORIGINS is the docs domain, because the demo is served from it.
A render.yaml is included for Render's blueprint deploys.
Without a TURN server, roughly 10–15% of users cannot connect and nothing in your logs will say so. Add one before you have real users:
TURN_URL=turn:turn.example.com:3478
TURN_USERNAME=<time-limited username>
TURN_CREDENTIAL=<HMAC credential>All three must be set together. Use time-limited credentials — static ones in a client bundle are a public relay for anyone who views source. See lesson integration-03.
curl -sI https://meetlab.vercel.app/lesson/backend-14 | head -1 # 200 -> docs rewrite
curl -sI https://meetlab.vercel.app/demo/abc-defg-hij | head -1 # 200 -> demo rewriteOpen both in a fresh tab, not by clicking through — client-side navigation passes even when a rewrite is missing.
48 lessons across three tracks. The frontend and backend tracks are fully independent — they share only the typed protocol package, so you can take them in either order, or two people can take one each.
graph LR
F["Frontend<br/>21 lessons"] --> I["Integration<br/>6 lessons"]
B["Backend<br/>21 lessons"] --> I
style I fill:#4c8dff,color:#fff
| # | Lesson | Level |
|---|---|---|
| 00 | Setting up the workspace | beginner |
| 01 | TypeScript, ESLint and Prettier that actually help | beginner |
| 02 | Vite, and what a dev server actually does | beginner |
| 03 | Tailwind CSS 4 and a two-layer theme | beginner |
| 04 | Component primitives with cva and Radix Slot | intermediate |
| 05 | Routing, and why the lobby is a URL | beginner |
| 06 | State with Zustand, and the selector that breaks everything | intermediate |
| 07 | A REST client that does not trust the server | intermediate |
| 08 | Enumerating cameras and microphones | intermediate |
| 09 | getUserMedia — permissions, constraints and failure | intermediate |
| 10 | The lobby — a green room before the meeting | beginner |
| 11 | Rendering video — srcObject, autoplay and mirroring | intermediate |
| 12 | The signaling socket — reconnection, backoff and heartbeat | advanced |
| 13 | RTCPeerConnection — your first offer and answer | advanced |
| 14 | ICE candidates, trickle and NAT traversal | advanced |
| 15 | The mesh — more than two people | advanced |
| 16 | Perfect negotiation and glare | advanced |
| 17 | Screen sharing with replaceTrack | intermediate |
| 18 | Chat, reactions and typing indicators | intermediate |
| 19 | Responsive layout, motion and theming | intermediate |
| 20 | Accessibility, error boundaries and performance | advanced |
| # | Lesson | Level |
|---|---|---|
| 00 | What a signaling server is for | beginner |
| 01 | Express 5 and middleware order as behaviour | beginner |
| 02 | Configuration that cannot be wrong | beginner |
| 03 | Structured logging and correlation ids | beginner |
| 04 | Errors as values, errors as responses | intermediate |
| 05 | Validating input with Zod | intermediate |
| 06 | Modelling rooms and participants | intermediate |
| 07 | The room registry and the reaper | intermediate |
| 08 | Use cases and the service layer | intermediate |
| 09 | The room REST endpoints | beginner |
| 10 | Health checks that are worth having | beginner |
| 11 | Security headers, CORS and rate limiting | intermediate |
| 12 | JWT access tokens as capabilities | intermediate |
| 13 | The WebSocket upgrade and authentication | advanced |
| 14 | The signaling protocol as a typed contract | intermediate |
| 15 | Relaying offers, answers and candidates | advanced |
| 16 | Heartbeats and the sockets that never close | advanced |
| 17 | Broadcast — chat, reactions and media state | intermediate |
| 18 | Graceful shutdown and process lifecycle | intermediate |
| 19 | Testing a real server, end to end | intermediate |
| 20 | Scaling — where the mesh stops and the SFU begins | advanced |
| # | Lesson | Level |
|---|---|---|
| 00 | Connecting the two tracks | intermediate |
| 01 | The complete signal flow | intermediate |
| 02 | Debugging WebRTC when nothing appears | advanced |
| 03 | Deploying the signaling server | intermediate |
| 04 | Deploying the frontend and the docs | beginner |
| — | Production concerns and where to go next | advanced |
Every lesson includes: estimated duration, difficulty, objectives, theory with the reasoning behind each decision, implementation, Mermaid diagrams, common mistakes, troubleshooting, a hands-on challenge, an AI prompt for going deeper, and a summary.
main holds the complete project. Each of the 48 lessons has a branch so you can see the code that lesson teaches:
git switch frontend-13 # first offer and answer
git switch backend-15 # the signaling relay
git switch integration-02 # debugging WebRTCmain complete project, docs, demo links
frontend-00 … 20 the frontend track
backend-00 … 20 the backend track
integration-00 … 04, integration-final
Branch names match lesson ids exactly, and each lesson page shows the git switch command for its branch. Every branch points at a commit verified to typecheck cleanly across the workspace.
Two things worth knowing before you use them: the sequence is not strictly monotonic (the repo was built bottom-up, the lessons teach top-down), and some lessons share a commit where they describe the same file. BRANCHES.md explains both precisely — read it before relying on the branches, and use main when you want the finished, runnable app.
Development uses micro commits with Conventional Commits, enforced by commitlint. The log is part of the teaching material — git log --oneline reads as the story of how a feature was assembled:
feat(backend): relay webrtc signaling between peers
feat(backend): wrap a participant socket
feat(backend): assemble the express application
feat(backend): expose a cheap health endpoint
Reserved for future lessons and branches:
- Raise hand (the best first contribution — one message end to end)
- Waiting room / knock to enter
- Recording meetings (
MediaRecorderon a canvas composite) - Background blur and virtual backgrounds
- Noise suppression beyond the browser's built-in
- File sharing over
RTCDataChannel - Live captions
- Polls
- Moderation and an admin dashboard
- Authentication providers
- Persistence and horizontal scale (Redis)
- SFU migration (LiveKit or mediasoup)
- AI meeting summaries
Known gaps in the current implementation are documented honestly in lesson integration-final — persistence, horizontal scale, moderation and observability are the real ones.
Contributions are welcome, particularly lesson corrections and clarifications.
- Fork and create a branch from
main - Make your change
- Run
pnpm verify— format, lint, typecheck and tests must all pass - Commit using Conventional Commits; commitlint enforces this
- Open a pull request describing what changed and why
Guidelines:
- Explain the why. A code change without reasoning is hard to review; a lesson change without reasoning defeats the purpose of the project.
- Keep commits small. One logical change each.
- Every branch must build.
pnpm buildandpnpm verifymust pass. - No new dependencies without justification in the pull request description.
The pre-commit hook runs ESLint and Prettier on staged files; the commit-msg hook runs commitlint. If a hook fails, it found something real.
MIT © Jackson Mafra
Built for learning. If it helps you understand WebRTC, it has done its job.





