Skip to content

Repository files navigation

MeetLab

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.

Demo License TypeScript

Live demo https://meetlab.vercel.app
Codelab the apps/docs site — deploy it, or run pnpm dev:docs
Repository https://github.com/jacksonfdam/MeetLab

Screenshots

Landing Lobby
Landing page Lobby
Meeting (two participants) Chat
Meeting Chat
Participants Reactions
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.


Overview

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.

Features

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 (m mute, v camera, c chat)
  • Accessible: labelled toggles, live regions, full keyboard navigation, reduced-motion support

Architecture

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
Loading

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.

The shared protocol

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
Loading

The dependency arrow points one way. shared imports nothing from apps/.


Technologies

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-eslint does 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 lesson frontend-00 for why "latest stable" is a property of the whole toolchain rather than of one package.


Repository structure

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

Installation

Requirements: Node ≥ 22.12, pnpm ≥ 10.

git clone https://github.com/jacksonfdam/MeetLab.git
cd MeetLab
pnpm install

Running locally

# 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    # codelab

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

Testing a real call

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.

Verification

pnpm verify        # format check + lint + typecheck + tests
pnpm build         # build every package in dependency order

Deploying

graph 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
Loading

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.

Frontend + docs → Vercel (one project)

Both are static Vite bundles, so hosting them together is just a matter of building both and copying them into one tree:

pnpm build:site

That 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_URL is build-time. Vite inlines it into the bundle, so setting it is not enough — you must redeploy. Without it the demo calls http://localhost:3001 and every "Start a meeting" fails with a network error.

Why the demo needs a base path

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 links

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

Backend → Railway or Render

# Build
pnpm install --frozen-lockfile && pnpm --filter @meetlab/shared build && pnpm --filter @meetlab/backend build

# Start
node apps/backend/dist/index.js
NODE_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=info

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

TURN

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.

Verify a deploy

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 rewrite

Open both in a fresh tab, not by clicking through — client-side navigation passes even when a rewrite is missing.


Learning path

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
Loading

Frontend track

# 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

Backend track

# 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

Integration track

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


Branch strategy

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 WebRTC
main                 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

Roadmap

Reserved for future lessons and branches:

  • Raise hand (the best first contribution — one message end to end)
  • Waiting room / knock to enter
  • Recording meetings (MediaRecorder on 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.


Contributing

Contributions are welcome, particularly lesson corrections and clarifications.

  1. Fork and create a branch from main
  2. Make your change
  3. Run pnpm verify — format, lint, typecheck and tests must all pass
  4. Commit using Conventional Commits; commitlint enforces this
  5. 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 build and pnpm verify must 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.


License

MIT © Jackson Mafra

Built for learning. If it helps you understand WebRTC, it has done its job.

About

Build a Google Meet clone from scratch an interactive, branch-per-lesson codelab covering React, WebRTC, WebSockets and Express.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages