Watch and listen together — sync YouTube playback in real time, build a collaborative queue, vote to skip, and chat with friends. No account required.
Live: together.chtnnhfoundation.org · Source: github.com/chtnnh/together
Most watch-party apps assume desktop, always-on video, and one look for everyone. Together is built for how people actually listen:
| Mobile-first | Video on top, queue and chat in thumb-friendly tabs below. Install as a PWA for a full-screen session on your phone. |
| Audio-only mode | Hide the player and keep listening — perfect for music sessions, background listening, or saving bandwidth. Toggle per browser; doesn’t affect anyone else. |
| Personal themes | Pick your own accent theme (midnight, ocean, sunset, forest, lavender). Saved locally — your room, your colors. |
- Synchronized YouTube playback with drift correction
- Seek bar — scrub to any point when you have playback control
- Play any track from the queue on demand (not just “next”)
- Late-join sync (“tap to sync” when autoplay is blocked)
- Host/co-host play, pause, and skip
- Open controls mode — unlock playback so every member can control play/skip/seek and add directly to the queue
- Loop modes: off, repeat current track, repeat queue
- Unavailable or deleted YouTube videos blocked at import
- Two-lane queue: member requests → host DJ queue
- Drag-and-drop queue reordering (host/co-host)
- Clear all requests or queue in one click
- Queue history with one-click re-add
- YouTube URL paste and search
- Spotify & Apple Music playlist import (optional OAuth / API keys)
- Smart track resolution (ISRC-first, fuzzy title/artist matching, alternate picker)
- Public, unlisted, or password-protected rooms
- Custom room names; room settings persist across host refresh
- Kick, ban, promote and demote co-hosts
- Vote-to-skip with configurable threshold (votes reset when the track changes)
- Democratic request promotion (optional)
- Text chat with emoji picker
- Slow mode and profanity filter (host settings)
- Five theme presets — personal accent colors, saved per browser
- Audio-only mode — hide video, keep the music going
- Stream quality preference (auto / 720p / 480p / 144p)
- Activity toasts (join, leave, kick, ban, promote)
- Sign in to save room settings and cross-provider playlists
┌──────────────────────────────┐ WebSocket ┌────────────────────────────────────┐
│ Next.js (Vercel) │ ◄────────────────► │ Cloudflare Worker │
│ together.chtnnhfoundation… │ │ realtime.together.chtnnhfound… │
└──────────────┬───────────────┘ │ + Durable Objects (rooms) │
│ └────────────────────────────────────┘
│ SQL
▼
┌──────────────────────────────┐
│ Supabase Postgres │
│ (rooms, settings, playlists)│
└──────────────────────────────┘
| Package / app | Role |
|---|---|
apps/web |
Next.js 15 UI, REST API routes, room pages |
services/realtime |
Cloudflare Worker + Durable Object for WebSocket rooms |
packages/shared |
Event protocol, Zod schemas, playback math |
packages/db |
Drizzle ORM schema + migrations |
packages/ui |
Shared React components |
packages/track-resolver |
YouTube / ISRC / fuzzy matching |
- Node.js ≥ 22 (required by Wrangler for the realtime dev server)
- pnpm 9 (
corepack enable && corepack prepare [email protected] --activate) - PostgreSQL (local or Supabase)
- YouTube Data API key (required for search/import)
- Cloudflare account (optional — only if you deploy the realtime worker yourself)
- Optional: Spotify, Apple Music, Supabase auth keys
git clone https://github.com/chtnnh/together.git
cd together
pnpm installCopy the example env and fill in values at the repo root (monorepo-wide):
cp .env.example .envMinimum for local dev:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/together
YOUTUBE_API_KEY=your_youtube_api_key
NEXT_PUBLIC_REALTIME_URL=ws://127.0.0.1:8787
NEXT_PUBLIC_APP_URL=http://localhost:3000
ROOM_TOKEN_SECRET=dev-secret-change-meEnv is loaded from the repo root by
apps/web/next.config.tsandpackages/db/drizzle.config.ts. You do not need to duplicate keys intoapps/web/.env.localunless you want overrides.
pnpm db:migrateThis applies all SQL in packages/db/drizzle/ via Drizzle Kit.
Supabase: use the Session pooler URI (port 5432) for migrations if the direct host fails — db.*.supabase.co is IPv6-only and many networks cannot reach it.
Supabase → Settings → Database → Connection string → Session pooler (not Transaction / 6543).
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgresFor Vercel runtime, use the Transaction pooler (port 6543, ?pgbouncer=true) instead.
Run migrations once from your machine (or CI), not from Vercel serverless.
Terminal 1 — realtime worker:
pnpm --filter @together/realtime dev
# listens on http://127.0.0.1:8787Terminal 2 — web app:
pnpm --filter @together/web dev
# http://localhost:3000- Open http://localhost:3000 and create a room
- Realtime health: http://127.0.0.1:8787/health
- DB health: http://localhost:3000/api/health/db
| Command | Description |
|---|---|
pnpm dev |
Start all apps via Turborepo |
pnpm --filter @together/web dev |
Next.js dev server |
pnpm --filter @together/realtime dev |
Cloudflare Worker (Wrangler dev) |
pnpm db:migrate |
Apply Drizzle migrations |
pnpm db:generate |
Generate migration from schema changes |
pnpm --filter @together/web build |
Production build |
pnpm --filter @together/web test |
Playwright E2E tests |
pnpm typecheck |
Typecheck all packages |
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | Postgres connection (Supabase in prod) |
YOUTUBE_API_KEY |
Yes | YouTube Data API v3 |
NEXT_PUBLIC_REALTIME_URL |
Yes | WebSocket base — prod: wss://realtime.together.chtnnhfoundation.org |
NEXT_PUBLIC_APP_URL |
Yes | Public site URL |
ROOM_TOKEN_SECRET |
Yes | JWT secret for room tokens (long random string) |
NEXT_PUBLIC_SUPABASE_URL |
Optional | Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Optional | Supabase anon key |
SUPABASE_SERVICE_ROLE_KEY |
Optional | Server-side Supabase |
SPOTIFY_CLIENT_ID / SECRET |
Optional | Spotify playlist import |
NEXT_PUBLIC_SPOTIFY_CLIENT_ID |
Optional | Spotify OAuth redirect (client) |
APPLE_MUSIC_* |
Optional | Apple MusicKit import |
See .env.example for the full list.
together/
├── apps/web/ # Next.js app
├── services/realtime/ # Cloudflare Worker + Durable Object
├── packages/
│ ├── shared/ # Protocol & schemas
│ ├── db/ # Drizzle schema + migrations
│ ├── ui/ # Component library
│ └── track-resolver/ # Track matching
├── .env.example
├── turbo.json
└── pnpm-workspace.yaml
pnpm --filter @together/web test:install # first time / CI: install Playwright browsers
pnpm --filter @together/web test # starts web + realtime via Playwright webServerPlaywright specs live in apps/web/e2e/. Requires Node.js 22+ (Wrangler dev server).
| Symptom | Likely cause |
|---|---|
| “Connecting…” forever | Worker unreachable — check realtime health and NEXT_PUBLIC_REALTIME_URL |
| YouTube search empty | Missing/invalid YOUTUBE_API_KEY, or empty override in apps/web/.env.local |
| Room create fails | DATABASE_URL wrong or migrations not applied |
EHOSTUNREACH on migrate |
Direct db.*.supabase.co is IPv6-only — use Session pooler URI (port 5432) in .env |
| WebSocket reconnect loop | Two tabs open for same room; close duplicate tabs |
| “Offline — start realtime server” | Worker unreachable from browser |
Licensed under the Apache License 2.0.
Built by chtnnh. v0.1.0 — together.chtnnhfoundation.org · Source on GitHub