Collabo is a browser-based, invite-only meeting tool built around one core interaction: one person shares their screen, everyone else draws on top of it in real time, and everyone talks over audio. Presenting rights ("host") can be handed to anyone in the room.
This is not a Zoom/Meet clone. There is no face video and no text chat — the product is deliberately narrow: shared screen + live multi-user annotation + voice.
Architecture note: media (audio + screen video) is routed through a server-side SFU (Selective Forwarding Unit), not a peer-to-peer mesh. Each client has exactly one WebRTC connection — to the server — which fans media out to everyone else. The server forwards RTP packets without decoding or transcoding them, so it's cheap to run, but it does mean media is no longer literally peer-to-peer at the network level. Keep that distinction in mind when naming things internally — the product still feels like a direct call between people, the transport is now client↔server↔client.
Do not build these, even if they seem like natural additions:
- Webcam / face video
- Text chat
- Meeting recording or transcription
- User accounts / persistent login
- Persistent drawing history across sessions
- Host a meeting — any user can start a meeting. This generates:
- a unique meeting link (
/join/[meetingId]) - a short auth code (e.g. 6-char alphanumeric)
- a unique meeting link (
- Join a meeting — user opens the link, enters name + auth code. No accounts.
- Returning-user convenience — if the browser has previously stored a
display name in
sessionStorage, pre-fill it on the join form. - Screen sharing — the current host shares their screen; everyone else sees it live, delivered via the SFU.
- Presenter handoff — host role (and therefore who is screen-sharing) can be transferred to any other participant at any time.
- Live drawing overlay — every participant can draw directly on top of the shared screen, and those drawings appear live for everyone in the call, including the current host — the same way Slack huddle screen annotation works. Each participant is assigned a distinct color for the lifetime of the call; their strokes always render in that color.
- Audio conferencing — all participants can talk to each other simultaneously (mic on/off toggle, no video), routed through the SFU.
- Capacity — support up to 10 concurrent participants per meeting.
- Mobile-friendly UI — fully usable (viewing, drawing, talking) on a phone-sized viewport. Screen-sharing is a secondary concern on mobile (see §13).
- Next.js (App Router) — UI, routing, meeting-creation API route
- A custom Node server hosting both the WebSocket signaling layer and the mediasoup SFU — see §13, this cannot run on Vercel-style serverless functions
- mediasoup — the SFU itself. Recommended over LiveKit/Janus for this project specifically because it's a Node.js library, not a separate service in another language, so it lives in the same codebase/process family as your signaling server. (If you'd rather not hand-write the producer/consumer signaling flow yourself, LiveKit is the easier turnkey alternative — self-hosted or cloud, with a very clean client SDK — at the cost of running a separate Go-based service.)
- WebRTC browser APIs (
getDisplayMedia,getUserMedia,RTCPeerConnectionunder the hood via mediasoup-client) — capture only; actual routing is server-side - Canvas API (
<canvas>+ Pointer Events) for the draw overlay - Tailwind CSS for styling
- lucide-react for all icons — no other icon set, no emoji
sessionStoragefor remembering the user's display name between visits
- Next.js serves pages and a small REST/API surface (create meeting, validate a meeting exists).
- WebSocket signaling is the source of truth for room state and also
carries the mediasoup handshake:
- who's in the room, their assigned color, who is host
- relays the mediasoup transport/producer/consumer handshake (see §5.2)
- relays drawing strokes to the room
- enforces the 10-participant cap and auth-code check
- mediasoup does the actual media work: one
Routerper meeting room,WebRtcTransports per client,Producers for outgoing tracks,Consumers for incoming ones.
Room state (and its mediasoup Router) lives in memory (a
Map<meetingId, RoomState>) — no database needed for v1. A meeting and its
router are torn down when the last participant leaves.
Every client has one connection to the server (in mediasoup terms, a send transport + a receive transport), not a connection per peer:
- Everyone produces one audio track (their mic) to the server.
- The current host additionally produces one video track (their screen). Non-host participants never produce video.
- Every client consumes: all other participants' audio tracks, plus the host's video track (skipped for the host itself — see §5.4, the host uses a local self-preview instead of consuming its own video back).
So for N=10: 10 audio producers + 1 video producer flowing into the server; the server fans those back out as consumers to whoever needs them. Nobody's browser manages more than one underlying transport pair, regardless of room size — this is the main win over mesh, both for client CPU and for the host's upload, which is now a single stream instead of nine.
Stroke points are small, infrequent, and need a single authoritative order and an easy way to replay current state to a late joiner — the signaling server already has room membership, so it's the natural relay point. This choice is independent of the media architecture: keep drawing coordination on the plain WebSocket layer rather than routing it through mediasoup data producers/consumers — it's simpler and there's no latency-sensitive reason to do otherwise.
Annotations are not viewer-only — the current host must see everyone's
strokes appear on their own display in real time, same as everyone else.
This is a rendering detail, not a new sync mechanism: every client (host
included) subscribes to the same draw-stroke broadcast and renders it on
the same canvas overlay component.
The only thing that differs for the host is what sits underneath the
canvas: the host plays their own local getDisplayMedia MediaStream in a
muted <video> element (a self-preview), rather than consuming their own
video back from the SFU — don't round-trip the host's video through the
server to itself, it's wasteful and adds latency for no benefit. Everyone
else's canvas sits on top of the remote video track they consume from the
server. Build one <ScreenView> component that takes a video source (local
stream or a mediasoup Consumer track) and always renders the shared
<DrawCanvas> on top of it, used identically for host and viewers.
- Host: the current presenter. Captures and shares their screen. Can hand
off the host role to any other participant (that person's browser then
prompts them for
getDisplayMedia— there is no way to grab someone's screen without their explicit action, so the handoff UI must clearly ask the new host to click "Share screen"). On handoff, the old host closes its video producer and the new host opens one — the server-side router doesn't care who's producing, just that there's at most one active video producer per room. - Everyone else: sees the host's screen, can draw on it, can talk. Cannot draw-erase other people's strokes (only their own, or a host-only "clear all").
- Landing page → "Host a meeting" button.
- Server generates
meetingId(short slug, e.g. nanoid) +authCode(6-char alphanumeric), creates the room, and spins up its mediasoupRouter. - Host is prompted for a display name (pre-filled from
sessionStorageif present), joins automatically as host, and sees the shareable link + auth code to send to others. - Invitee opens the link → form asks for Name + Auth Code (name
field pre-filled from
sessionStorageif set). - Client sends
{ meetingId, name, authCode }to the WS server:- wrong code → inline error, no join
- room already at 10 → inline "meeting full" error
- success → server assigns a color, returns current room state (who's in
the call, who's host, current strokes) plus the router's RTP
capabilities (needed to initialize the mediasoup client device), client
saves name to
sessionStorage.
- Client then completes the mediasoup handshake: create send/receive transports, produce its audio (and video, if host), consume everyone else's already-active producers.
Do not persist the auth code itself in sessionStorage — only the
display name.
- A transparent
<canvas>is layered exactly over the<video>element showing the host's screen — for every participant, host included (see §5.4 for why the host's underlying video source is a local self-preview, not a consumed SFU track). - Store and transmit stroke coordinates normalized to 0–1 relative to the video's intrinsic width/height, not raw pixels — this keeps drawings aligned across viewers with different window sizes / mobile vs desktop.
- Use Pointer Events (not just mouse events) so touch drawing works on mobile.
- Color assignment is server-side, from a small fixed palette (10 colorblind-considerate, visually distinct colors), assigned on join and released on leave — never two active users with the same color.
- Provide a visible color legend (name + swatch) somewhere in the UI so people know whose annotation is whose.
- Give each user a "clear my strokes" control; give the host a "clear all" control. Strokes otherwise persist until manually cleared (they're meant to support an ongoing discussion, not disappear on their own).
- Mic on/off toggle (lucide
Mic/MicOff) — pauses/resumes the client's mediasoup audio producer rather than tearing it down, so re-enabling is instant. - Simple per-participant speaking indicator is a nice-to-have (Web Audio
AnalyserNodeon each consumed audio track) — not required for v1.
- Visual language: professional and restrained. Neutral palette (grays/one accent color), clear typography, real whitespace, subtle borders/shadows. No gradients-as-decoration, no glow effects, no bouncing/pulsing micro-animations, no illustration-heavy empty states — avoid anything that reads as generic "AI app" template design.
- Icons:
lucide-reactexclusively. - Screens:
- Landing — "Host a meeting" / "Join a meeting"
- Create-meeting confirmation — link + auth code, copy buttons
- Join form — name + auth code
- Meeting room — shared screen + draw canvas (dominant), a slim control bar (mute, leave, "make me host" / "hand off host", color legend), a compact participant strip
- Mobile: control bar collapses to icon-only / a bottom sheet on small viewports; the shared screen + canvas remain the dominant element; touch drawing must work.
- Component set stays minimal — build a small reusable kit (Button, IconButton, Input, Modal, Toast, Avatar-with-initial-and-color) rather than pulling in a heavy component library.
app/
page.tsx landing
join/[meetingId]/page.tsx name + auth code form
room/[meetingId]/page.tsx main call UI
api/meetings/route.ts create + validate meetings
server/
ws-server.ts custom node server (Next.js + ws)
rooms.ts in-memory room state, color assignment, auth checks
sfu.ts mediasoup worker/router/transport lifecycle management
lib/
mediasoup-client.ts client-side Device setup, transport/producer/consumer helpers
draw-sync.ts stroke send/receive + canvas rendering helpers
session.ts sessionStorage read/write helpers
components/
ui/ Button, IconButton, Input, Modal, Toast, Avatar
room/ ScreenView, DrawCanvas, ControlBar, ParticipantStrip, ColorLegend
The mediasoup-specific messages below map directly onto mediasoup's
documented client/server flow (createWebRtcTransport, connect,
produce, consume) — follow mediasoup's own docs for the exact
parameter shapes rather than inventing them; the room/draw messages are
this project's own.
- The server needs a long-running process with a real public IP — it
cannot run as a Vercel-style serverless function, and this matters more
than it did for a plain WebSocket relay: mediasoup's
WebRtcTransportneeds alistenIp/announcedIpit can hand out in ICE candidates, so if it's behind Docker/NAT you must explicitly set the announced public IP in its config, or clients won't be able to connect to it. Deploy this whole server (Next.js + WS + mediasoup) on a platform with a static public IP and control over exposed ports (a plain VPS is the simplest mental model; Fly.io/Render work too but read their docs on UDP/public-IP support first). - Open a UDP port range for mediasoup's RTP traffic (commonly something like 40000–49999, configurable) in addition to your normal HTTPS/WS ports.
- TURN is still worth having as a fallback, but it's a much smaller concern than it was under mesh — there's now only one media hop per client (client↔server) instead of up to nine, so restrictive networks have far fewer chances to block something. A public STUN server plus an optional TURN fallback for the worst-case restrictive network is reasonable for v1.
- HTTPS is required in production for
getDisplayMedia/getUserMediato work at all. - Bandwidth is now a server capacity question, not a client one — this
is the main benefit of moving off mesh. The host uploads their screen
exactly once; the server absorbs the fan-out cost of forwarding it (and
everyone's audio) to the other participants. For a single 10-person room
with screen capped around 1280×720 @ 8–12fps, total server egress lands
in the low tens of Mbps — trivial for any real server, and it's a single
place to monitor/scale rather than depending on each host's home upload
speed. One
mediasoup Worker/Routerper active room is enough at this scale; you don't need multi-worker routing (piped transports across workers) unless you're running many simultaneous rooms on one box, which is a scaling concern beyond this v1's scope.
- Scaffold Next.js + Tailwind + lucide-react; landing, create, join, room pages/routes (no real functionality yet).
- WebSocket server: room state, auth-code check, 10-person cap, color assignment.
- Join flow wired end-to-end, including
sessionStoragename persistence. - Stand up mediasoup: one
Worker+Routerper room, transport creation, and get audio-only conferencing working through the SFU first (every client produces + consumes audio). - Add the host's screen-share video producer + consumption by everyone else.
- Draw canvas overlay + WebSocket stroke sync + color legend.
- Presenter handoff flow (close old video producer, open new one).
- Mobile responsive pass, TURN fallback, reconnect/error handling, "meeting full" / "invalid code" edge cases.
- Hard cap is exactly 10 participants, enforced server-side.
- Auth code is valid only while the meeting/room is alive; nothing is persisted once everyone leaves.
- No recording, no chat, no face video — confirmed non-goals per §2.
- One mediasoup
Routerper room is sufficient at the 10-participant cap; no need for multi-worker/piped-router complexity in v1.