Replace the metaverse: build a lightweight web collaboration app (server + client) in a weekend
starter-kitcollaborationweb

Replace the metaverse: build a lightweight web collaboration app (server + client) in a weekend

ccodenscripts
2026-02-01 12:00:00
10 min read
Advertisement

Ship a small web collaboration app with rooms, a shared whiteboard, and voice in a weekend — includes server + client code and deploy recipe.

Replace the metaverse: build a lightweight web collaboration app (server + client) in a weekend

Hook: Tired of heavyweight VR platforms and vendor lock‑in? You can ship a small, secure collaboration webapp with rooms, a shared whiteboard, and voice chat in a single weekend — deployable to a $5/month VPS. This article gives a practical starter kit (server + client), runnable code, deployment recipe, and 2026-forward recommendations so you don't reinvent Workrooms.

The promise — and the pain

In early 2026 Meta announced it will discontinue Horizon Workrooms and related commercial VR services. The market reaction is clear: organizations want the collaboration features — not the headset lock-in. Lightweight self-hosted apps win because they are cross-platform, auditable, and cheap to run.

Meta discontinued Workrooms as a standalone app in February 2026, reinforcing the demand for accessible web-first collaboration tools.

This guide focuses on the minimum viable set of features that replicate basic Workrooms functionality for real teams: rooms (join, list, leave), shared whiteboard (real-time vector draws), and voice (real-time audio using WebRTC). It’s designed for developers and ops folks who want a fast starter kit — open, deployable, and extensible.

Why a lightweight web approach in 2026?

  • Cross-platform: Runs in any modern browser or in a PWA on desktop and mobile.
  • Cost-effective: Small CPU/RAM footprint; no cloud-managed headset or per-seat billing.
  • Fast to iterate: Web tech and WebRTC allow rapid prototyping and incremental upgrades.
  • Better security & control: Host on your infra, add SSO, E2EE patterns, and compliance controls.

Architecture overview — the minimal, practical design

Build the app with three cooperating parts:

  1. Static client — HTML/CSS/JS served from the same server (or CDN). Renders UI, whiteboard, and handles local audio capture and peer connections.
  2. Signaling server — Node.js with Express and WebSocket for room state, signaling SDP/ICE, and broadcasting whiteboard events.
  3. Optional TURNcoturn for NAT traversal in production; essential for reliable voice across networks.

This is a mesh WebRTC model: the server handles signaling, while peers send audio directly to each other. Mesh is simple and perfect for small rooms (4–8 active speakers). For larger deployments use an SFU (Janus, mediasoup) — guidance below.

Starter kit code — quick walkthrough

Below are compact, runnable examples for the server and client. They intentionally keep logic minimal to ship fast. You can clone and iterate.

Server: Node.js (Express + ws)

Features: serves static files, keeps a room -> socket list, relays signaling messages and whiteboard events.

/* server.js (Node 18+) */
import express from 'express';
import http from 'http';
import WebSocket, { WebSocketServer } from 'ws';
import path from 'path';

const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });

app.use(express.static(path.join(process.cwd(), 'public')));

// In-memory rooms: { roomId: Set(ws) }
const rooms = new Map();

wss.on('connection', (ws) => {
  ws.on('message', (msg) => {
    let data;
    try { data = JSON.parse(msg.toString()); } catch (e) { return; }
    const { type, room, payload } = data;

    if (type === 'join') {
      if (!rooms.has(room)) rooms.set(room, new Set());
      rooms.get(room).add(ws);
      ws.room = room;
      // notify peers of new user
      broadcast(room, { type: 'peer-joined', id: ws._socket.remotePort });
      // send current peer count
      ws.send(JSON.stringify({ type: 'joined', id: ws._socket.remotePort }));
    }

    if (type === 'signal' || type === 'whiteboard') {
      // Relay to other peers in room
      broadcast(room, { ...data, from: ws._socket.remotePort }, ws);
    }

    if (type === 'list-rooms') {
      ws.send(JSON.stringify({ type: 'rooms', payload: Array.from(rooms.keys()) }));
    }
  });

  ws.on('close', () => {
    const room = ws.room;
    if (room && rooms.has(room)) {
      rooms.get(room).delete(ws);
      broadcast(room, { type: 'peer-left', id: ws._socket.remotePort });
      if (rooms.get(room).size === 0) rooms.delete(room);
    }
  });
});

function broadcast(room, data, except) {
  const set = rooms.get(room);
  if (!set) return;
  const s = JSON.stringify(data);
  for (const peer of set) if (peer !== except && peer.readyState === WebSocket.OPEN) peer.send(s);
}

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log(`Server running on ${PORT}`));

Notes:

  • This in-memory store is fine for a weekend prototype. For production use Redis or another pub/sub to scale multi‑instance.
  • We identify peers by the socket port in this sample. Replace with UUID/DB-backed identities for real apps.

Client: HTML + JavaScript (public/index.html)

Capabilities: create/join room, canvas whiteboard synced over WebSocket, and simple mesh-based WebRTC voice.

<!doctype html>
<meta charset="utf-8">
<style>body{font-family:system-ui,Segoe UI,Roboto}#canvas{border:1px solid #ccc}</style>
<div>
  <input id="room" placeholder="room-id"/>
  <button id="join">Join</button>
  <button id="list">List rooms</button>
</div>
<div>
  <canvas id="canvas" width="800" height="400"></canvas>
</div>
<script>
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;
const ws = new WebSocket(wsUrl);
let roomId, localStream;
const peers = new Map(); // id -> RTCPeerConnection

ws.addEventListener('open', ()=>console.log('ws open'));
ws.addEventListener('message', async e => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'joined') console.log('joined', msg.id);
  if (msg.type === 'peer-joined') console.log('peer joined', msg.id);
  if (msg.type === 'signal') handleSignal(msg);
  if (msg.type === 'whiteboard') applyRemoteDraw(msg.payload);
});

async function startLocalAudio(){
  localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
}

async function createPeer(id, isInitiator){
  if (peers.has(id)) return peers.get(id);
  const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
  localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
  pc.onicecandidate = e => { if (e.candidate) ws.send(JSON.stringify({ type: 'signal', room: roomId, payload: { to: id, candidate: e.candidate } })); };
  pc.ontrack = e => { // create audio element
    const audio = document.createElement('audio'); audio.autoplay = true; audio.srcObject = e.streams[0]; document.body.appendChild(audio);
  };
  peers.set(id, pc);
  if (isInitiator) {
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    ws.send(JSON.stringify({ type: 'signal', room: roomId, payload: { to: id, sdp: pc.localDescription } }));
  }
  return pc;
}

async function handleSignal(msg){
  const { payload } = msg;
  const id = payload.from || payload.to || 'unknown';
  if (payload.sdp) {
    const pc = await createPeer(id, false);
    await pc.setRemoteDescription(payload.sdp);
    if (payload.sdp.type === 'offer') {
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      ws.send(JSON.stringify({ type: 'signal', room: roomId, payload: { to: payload.from, sdp: pc.localDescription } }));
    }
  }
  if (payload.candidate) {
    const pc = peers.get(id);
    if (pc) pc.addIceCandidate(payload.candidate).catch(console.error);
  }
}

// UI: Join
document.getElementById('join').addEventListener('click', async ()=>{
  roomId = document.getElementById('room').value || 'default';
  await startLocalAudio();
  ws.send(JSON.stringify({ type: 'join', room: roomId }));
  // for demo: create a short delay and then try to establish mesh to others via server info
});

document.getElementById('list').addEventListener('click', ()=> ws.send(JSON.stringify({ type: 'list-rooms' })));

// Whiteboard: capture strokes and broadcast
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let drawing = false;
canvas.addEventListener('pointerdown', e=> (drawing=true, draw(e)));
canvas.addEventListener('pointerup', e=> (drawing=false, ctx.beginPath()));
canvas.addEventListener('pointermove', e=> drawing && draw(e));

function draw(e){
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left, y = e.clientY - rect.top;
  ctx.lineWidth = 2; ctx.lineCap='round'; ctx.strokeStyle='#111';
  ctx.lineTo(x,y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x,y);
  // send vector event
  if (roomId) ws.send(JSON.stringify({ type:'whiteboard', room: roomId, payload: { x,y } }));
}

function applyRemoteDraw(pt){
  ctx.lineWidth=2; ctx.lineCap='round'; ctx.strokeStyle='#0074D9'; ctx.lineTo(pt.x, pt.y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pt.x, pt.y);
}
</script>

What this does:

  • Canvas events are broadcast via the server to other clients in the room. The server relays; no history or CRDT is maintained (we'll discuss persistence/CRDT below).
  • Audio uses WebRTC peer connections. When production-ready, switch to an SFU for better bandwidth.

Run locally — the weekend checklist

  1. Node 18+ and npm/yarn installed.
  2. Create project folder, add server.js and public/index.html from above.
  3. npm init -y && npm i express ws
  4. node server.js and open http://localhost:3000 in two browsers on the same machine or different devices on the LAN.
  5. To test NAT behavior, deploy to a cloud host and add a TURN server (below).

Deployment recipe (deploy in a weekend)

Goal: secure, simple deployable stack that you can replicate across environments.

Production components

  • App server: Docker image for Node app (Express + ws).
  • TURN server: coturn running on a small VM/container with proper credentials.
  • HTTPS: Use Cloudflare/TLS or Caddy/Let's Encrypt to terminate TLS.
  • Optional Redis: for pub/sub and room state across instances.

Dockerfile (simple)

FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node","server.js"]

coturn (TURN) quickstart

  1. Spin-up a small VM (DigitalOcean, Linode, etc.) with port 3478 and 49152–65535 UDP open.
  2. Install coturn and configure with static credentials or long-term credentials (recommended).
  3. In your RTCPeerConnection config, use the TURN entry and credentials: { urls: 'turn:turn.example.com:3478', username, credential }.

Hosting choices in 2026

  • Small teams: deploy the Docker image to Fly.io or Railway for quick SSL and global edge placement (cheap and fast).
  • Enterprises: run on Kubernetes with Redis-backed pub/sub and media server (mediasoup) if you expect 20+ peers per room.

Security, privacy, and licensing

Security matters. For a weekend prototype you can skip authentication, but for any real team:

  • Authentication: Integrate OIDC/SAML to map users to rooms. Provide JWT tokens for the signaling channel.
  • TURN credentials: Use time-limited credentials per session (coturn supports long-term creds).
  • Encryption: WebRTC already uses SRTP for media. For true E2EE, consider Insertable Streams and client-side key exchange. This is now feasible and gaining adoption in 2026.
  • License: Ship the starter kit under an OSI‑compatible license like MIT and document security caveats in README.

By 2026, trends to consider:

  • SFUs are the norm for rooms bigger than ~6 participants — mediasoup and LiveKit (open-source) have matured and are easier to deploy than five years ago.
  • AI-driven audio processing (noise suppression and voice activity detection) is standard — modern browsers expose improved noise suppression by default, and server-side enhancement pipelines (WASM/Neural codecs) can be added.
  • WebTransport & WebCodecs enable lower-latency data channels and high quality streaming pipelines for collaborative features. Consider these for advanced sync or video workflows.
  • Privacy-first alternatives to big vendor platforms are demanded. Lightweight self-hosted apps give teams control over data retention and audit logs.

Production extensions (next-week tasks)

  • Persist whiteboard state using a CRDT (Automerge, Yjs) so new joiners see history and edits merge safely.
  • Add presence and typed cursors (synchronise pointer with presence messages).
  • Replace mesh with an SFU (LiveKit/mediasoup) for better bandwidth and mixed audio control.
  • Implement recording, transcripts, and moderation tools (safe for enterprise compliance).

Limitations and trade-offs

Mesh WebRTC is simple but doesn't scale beyond small rooms. The sample server relays signaling and whiteboard events but doesn't store history or enforce auth. Use Redis for horizontal scaling and an SFU for larger rooms. Always deploy a TURN server for robust media connectivity.

Actionable takeaways (what to do this weekend)

  1. Clone the minimal repo (create your project using the server.js and public/index.html above).
  2. Run locally and verify whiteboard sync and audio between two browsers.
  3. Spin up a cheap Fly.io or DigitalOcean droplet, deploy the Docker image, and test from separate networks.
  4. Add a coturn server (or use a managed TURN) to handle NAT traversal reliably.
  5. Iterate: add CRDT for whiteboard persistence and swap mesh to an SFU when you have more than 6 users per room.

Real-world example: a 2026 case study

A mid-size design team replaced an ill-fitting VR collaboration bet with a web-first approach in Q4 2025. They started with this pattern: mesh for daily standups (4–6 people), migrated to an SFU for weekly all-hands (50+), and integrated Yjs to enable persistent collaborative whiteboards. Total cost: under $200/month for infra and TURN. The team valued control and faster iteration more than immersive VR features.

Final notes: why this matters now

With major vendors retreating from VR-first workplaces in 2026, the opportunity is to ship focused collaboration features that teams use daily, without hardware lock-in. A lightweight starter kit like this reduces the barrier to get working features into the hands of users rapidly.

Call to action

Ready to build and deploy? Get the starter kit, run it locally, and push to a cheap cloud host this weekend. Start small: iterate on reliability (TURN), persistence (CRDT), and scale (SFU) when you hit limits. If you'd like, clone a ready-made repository we maintain and adapt it for your org — or ask for a walkthrough on integrating mediasoup or LiveKit next.

Next step: Pick a VPS, add coturn, and deploy the Docker image. Open a pull request with your chosen improvements and share your deployment notes — get recognized in the community and ship collaboration that people actually use.

Advertisement

Related Topics

#starter-kit#collaboration#web
c

codenscripts

Contributor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

Advertisement
2026-01-24T04:18:17.105Z