WatchTogether

Full System Architecture & Tech Stack

A cross-platform real-time watch party app with gamification, guild systems, event-based collectables, a leaderboard, and an ad marketplace. Targets 10k initial users across Web, iOS, Android, Windows, Mac, and Linux — all sharing one unified backend.

Platforms

Web · iOS · Android · Windows · macOS · Linux

Initial Scale

~10k users · single region · horizontally scalable

Core Loop

Watch together → earn XP → level up → compete on leaderboard

Revenue

Free tier with ads · Premium subscription · Ad marketplace

Feature Map

🎬 Watch Rooms

  • YouTube sync
  • Google Drive video
  • Spotify (audio)
  • Any web URL (iframe)
  • Public / Friends / Private
  • Voice chat (WebRTC)
  • Live chat
  • Invite system

⚔️ Guilds

  • Create / join guilds
  • Max 10 members
  • Guild watch hours
  • Guild leaderboard
  • Top 3 guilds → free premium
  • Guild chat

🎮 Gamification

  • XP system
  • Levels 1–10
  • Daily login bonus
  • Watch hour tracking
  • Friend & chat XP
  • Individual leaderboard
  • Top 3 → 3 months premium

🏆 Events

  • Time-limited events
  • Task completion
  • Collectable badges
  • Profile decorations
  • Video-watch events
  • Business promotions

👤 Profiles

  • Avatar / bio / username
  • Watch stats (week/month/year/all)
  • Badge showcase
  • Level & XP display
  • Join date
  • Friend list

💰 Ads

  • Pre/post room video ads
  • Business dashboard
  • Pay-per-view upload
  • XP reward for watching
  • Title / avatar reward
  • Free tier only

Frontend Tech Stack

Strategy: One monorepo with shared business logic. Next.js for web, React Native + Expo for mobile, Tauri for desktop. All three share the same API client, state management, and utility layer.
LayerChoiceWhy
MonorepoTurborepoFast incremental builds, shared packages, one repo for all platforms
Web appNext.js 14 (App Router)SSR for SEO on landing/profile pages, RSC for performance, TypeScript native
MobileReact Native + Expo SDK 51Single codebase for iOS + Android, OTA updates, Expo Router for navigation
DesktopTauri 2.0Wraps the web app in a Rust shell. Tiny bundle (~5MB vs Electron's 150MB), fast, cross-platform
Web stylingTailwind CSS v4Utility-first, design tokens, dark mode, consistent spacing
Mobile stylingNativeWind v4Tailwind syntax for React Native — same class names across platforms
Shared UI@app/ui packageComponent library built in React (web uses react-dom, mobile uses react-native)
State (global)ZustandTiny, no boilerplate, works identically on all platforms
Server stateTanStack Query v5Caching, background refetch, optimistic updates — works on web + native
FormsReact Hook Form + ZodValidation shared between client and server via same Zod schemas
Real-time clientSocket.io-clientManaged WebSocket, auto-reconnect, works in all envs
Video (YT)YouTube IFrame APIOfficial, pause/play/seek control, event callbacks
Voice chatAgora RTC SDK or LiveKitWebRTC abstraction — Agora free tier for 10k MAU is enough to start
Push (mobile)Expo NotificationsFCM + APNs via one API, no separate server setup
Push (web)Web Push APINative browser, no third party
AnalyticsPostHogOpen-source product analytics, self-hostable, session recording
Error trackingSentryWorks across all platforms, source maps, alerts

Backend Tech Stack

LayerChoiceWhy
RuntimeNode.js 22 LTSStable, vast ecosystem, same language as frontend = shared types/schemas
HTTP frameworkFastify v5Fastest Node.js framework (2× Express), TypeScript native, schema validation built-in
WebSocketSocket.io v4Rooms, namespaces, fallback to long-poll, Redis adapter for multi-instance
Primary DBPostgreSQL 16 via SupabaseManaged, realtime subscriptions, row-level security, free tier generous
ORMDrizzle ORMTypeScript-first, no magic, fast, schema as code, generates migrations
Cache / Pub-SubRedis 7 via UpstashSession store, Socket.io adapter (cross-server events), leaderboard sorted sets, rate limiting
AuthSupabase AuthOAuth (Google, Discord, Apple), JWT, refresh tokens, email/password, MFA
File StorageSupabase Storage + Cloudflare R2Profile pics / badges on Supabase; large ad videos on R2 (S3-compatible, cheap egress)
Background jobsBullMQ (Redis queue)Leaderboard snapshots, XP rewards, email dispatch, ad delivery scheduling
EmailResendDeveloper-friendly, React Email templates, generous free tier
PaymentsStripeSubscriptions (Premium), one-time (ad purchases), webhooks
API type-safetytRPC v11End-to-end type safety without codegen — Next.js + React Native both consume same router
ValidationZodShared schemas on client and server, TypeScript inference, Drizzle integration
Rate limiting@fastify/rate-limit + RedisPer-user, per-IP, per-endpoint
LoggingPino (built into Fastify)Structured JSON logs, ships to Logtail/Axiom

Infrastructure

ServiceProviderNotes
Web hostingVercelZero-config Next.js, edge CDN, preview deployments per PR
API serverRailwayDocker-based, auto-scales, $5/month starter — upgrade to 2+ replicas when needed
DatabaseSupabaseManaged Postgres, Auth, Storage, Realtime. Free tier: 500MB DB, 1GB storage
RedisUpstashServerless Redis, pay-per-request, global edge — free tier: 10k commands/day
Video/File CDNCloudflare R2 + CDNZero egress fees, serves ad videos fast globally
DNS + DDoSCloudflareFree tier covers 10k users fine, WAF, bot protection
CI/CDGitHub ActionsTest → build → deploy pipeline on merge to main
SecretsRailway Secrets + Vercel envNever in code. Rotation policy per env
MonitoringBetter UptimeStatus page + alerts, free tier for 1 monitor
Mobile OTAExpo EAS UpdatePush JS bundle updates without App Store review
Mobile buildsExpo EAS BuildCloud builds for iOS (.ipa) and Android (.aab)
Cost estimate at 10k users: Supabase free → ~$25/mo Pro · Railway ~$20/mo · Upstash free · Vercel free · Cloudflare free · Agora ~$0 free tier. Total: ~$45–80/month to start. Scales linearly.

Monorepo Structure

watchparty/                     ← Turborepo root
├── apps/
│   ├── web/                   ← Next.js 14 (Web + Tauri shell)
│   ├── mobile/                ← React Native + Expo
│   ├── api/                   ← Fastify + tRPC + Socket.io
│   └── desktop/               ← Tauri 2.0 (wraps web app)
│
├── packages/
│   ├── @app/ui                ← Shared component library
│   ├── @app/schemas           ← Zod schemas (shared client+server)
│   ├── @app/db                ← Drizzle schema + migrations
│   ├── @app/types             ← TypeScript types shared everywhere
│   ├── @app/config            ← ESLint, Prettier, TS configs
│   └── @app/utils             ← Pure utility functions
│
├── turbo.json
├── package.json
└── pnpm-workspace.yaml         ← Use pnpm for workspace efficiency
Key principle: All Zod schemas live in @app/schemas. The API validates with them server-side via Fastify. The frontend uses the same schemas for form validation. tRPC generates client types from the router — zero manual type maintenance.

System Architecture

CLIENTS Next.js Web Vercel React Native iOS · Android Tauri Desktop Win · Mac · Linux Cloudflare CDN · WAF · DDoS API GATEWAY Fastify API Server REST + tRPC · Railway Auth middleware · Rate limiting Socket.io Server WebSocket · Railway Rooms · Chat · Video sync Supabase Auth OAuth · JWT · Sessions Google · Discord · Apple Agora RTC WebRTC Voice Chat Per-room channels DATA LAYER PostgreSQL Supabase · Primary DB All persistent data Redis Upstash · Cache + Pub-Sub Sessions · Leaderboard · WS BullMQ Jobs Background workers XP · Snapshots · Emails Object Storage Supabase + Cloudflare R2 Avatars · Badges · Ad videos EXTERNAL SERVICES YouTube API IFrame · Data v3 Google Drive OAuth + Drive API Spotify SDK Web Playback API Stripe Payments · Webhooks Resend Transactional email Expo EAS Mobile builds + OTA LEGEND REST / tRPC (HTTP) WebSocket (real-time) HTTPS / CDN routing Background job queue WebRTC (audio) All servers run in Docker containers on Railway. Redis adapter syncs Socket.io across multiple API instances. Single account → single active session enforced via Redis.

Auth & Session System

One account, one active session at a time. Login from a new device automatically invalidates the previous session. This is enforced via Redis, not the DB, for speed.

OAuth Flow

Client
Supabase Auth (Google/Discord/Apple)
Callback URL
Our API receives tokens
Store in Redis + issue session

Session Architecture

// Redis key structure
"session:{userId}"  → { sessionId, deviceInfo, createdAt, expiresAt }
"session:{userId}:socket" → socketId  // current WS connection

// On new login:
async function createSession(userId, deviceInfo) {
  const existing = await redis.get(`session:${userId}`);
  if (existing) {
    // Emit 'force_logout' to their active socket, THEN delete
    await io.to(existing.socketId).emit('force_logout', { reason: 'New login' });
    await redis.del(`session:${userId}`);
  }
  const sessionId = generateSecureId();
  await redis.setex(`session:${userId}`, 86400 * 30, { sessionId, deviceInfo });
  return sessionId;
}

JWT Strategy

Real-time Architecture

Socket.io Room Structure

// Namespaces
io.of('/rooms')    → video sync, room chat, roster
io.of('/guilds')   → guild chat, guild events
io.of('/global')   → friend online status, notifications, leaderboard updates

// Socket.io room = watch room
socket.join(`room:${roomId}`)

// Events emitted in a room:
'video:play'     → { timestamp, serverTime }
'video:pause'    → { timestamp }
'video:seek'     → { timestamp }
'video:buffer'   → { userId }         // show buffer spinner for that user
'chat:message'   → { userId, content, createdAt }
'room:join'      → { user }
'room:leave'     → { userId }
'room:kick'      → { userId }
'room:mic_toggle' → { enabled }

Redis Adapter — Multi-Instance Sync

import { createAdapter } from '@socket.io/redis-adapter';
const pubClient = new Redis(process.env.REDIS_URL);
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));
// Now io.to('room:xyz').emit() works across ALL server instances

Video Sync Engine

The core innovation of the app. The server is the single source of truth for video state. All clients correct to the server clock, not to each other. This eliminates drift without peer-to-peer complexity.

How it works

// Server maintains room state in Redis
interface RoomVideoState {
  url: string;
  platform: 'youtube' | 'drive' | 'spotify' | 'web';
  isPlaying: boolean;
  position: number;         // seconds at last event
  lastEventAt: number;      // Date.now() when position was set
  leaderId: string;         // only leader controls playback
}

// When a client seeks/plays/pauses:
socket.on('video:play', ({ position }) => {
  const state = { isPlaying: true, position, lastEventAt: Date.now() };
  redis.set(`room:${roomId}:video`, JSON.stringify(state));
  io.to(`room:${roomId}`).emit('video:play', { ...state });
});

// On client: calculate actual current position accounting for latency
function getServerPosition(state: RoomVideoState): number {
  if (!state.isPlaying) return state.position;
  const elapsed = (Date.now() - state.lastEventAt) / 1000;
  return state.position + elapsed;
}

// If client drift > 1.5s → hard seek. If 0.5–1.5s → speed adjust (1.05x or 0.95x)
const HARD_SEEK_THRESHOLD = 1.5;
const SOFT_CORRECT_THRESHOLD = 0.5;

Platform Integration

PlatformMethodControl APINotes
YouTubeIFrame EmbedYT.PlayerseekTo(), playVideo()Most reliable. Needs user interaction to autoplay.
Google DriveDrive embed iframePostMessage API (limited)Can sync play/pause. Seek is limited — use server position on join.
SpotifyWeb Playback SDKSpotify Connect APIRequires Spotify Premium on all listeners. Syncs via player state.
Web URLSandboxed iframeNone (display only)Show URL, all users load independently. Chat-only sync.
Netflix / Prime / etc.: These cannot be legally embedded or controlled via APIs — their DRM blocks iframing. Rave works around this with a browser extension that injects scripts into their pages. This is technically complex and legally grey. For v1, focus on YouTube + Drive + Spotify + Web. Add an extension later as a separate project.

Database Schema

Full PostgreSQL schema using Drizzle ORM. All tables include created_at and updated_at timestamps automatically.

Core Tables Overview

-- USERS
users
  id            uuid PK
  username      text UNIQUE NOT NULL
  display_name  text
  email         text UNIQUE NOT NULL
  avatar_url    text
  bio           text (max 160)
  level         int DEFAULT 1
  xp            int DEFAULT 0
  is_premium    boolean DEFAULT false
  premium_until timestamptz
  is_verified   boolean DEFAULT false
  created_at    timestamptz

-- FRIENDS
friend_requests
  from_user_id  uuid FK → users
  to_user_id    uuid FK → users
  status        enum('pending','accepted','rejected')
  PRIMARY KEY (from_user_id, to_user_id)

-- GUILDS
guilds
  id            uuid PK
  name          text UNIQUE NOT NULL
  description   text
  avatar_url    text
  owner_id      uuid FK → users
  max_members   int DEFAULT 10
  is_public     boolean DEFAULT true
  total_watch_hours float DEFAULT 0

guild_members
  guild_id      uuid FK → guilds
  user_id       uuid FK → users
  role          enum('leader','officer','member')
  joined_at     timestamptz
  PRIMARY KEY (guild_id, user_id)

-- WATCH ROOMS
rooms
  id            uuid PK
  name          text
  leader_id     uuid FK → users
  platform      enum('youtube','drive','spotify','web')
  content_url   text
  visibility    enum('public','friends','private')
  mic_enabled   boolean DEFAULT false
  max_members   int DEFAULT 50
  is_active     boolean DEFAULT true
  guild_id      uuid FK → guilds (nullable)

room_members
  room_id       uuid FK → rooms
  user_id       uuid FK → users
  is_muted      boolean DEFAULT false
  joined_at     timestamptz
  PRIMARY KEY (room_id, user_id)

room_messages
  id            uuid PK
  room_id       uuid FK → rooms
  user_id       uuid FK → users
  content       text NOT NULL (max 500)
  created_at    timestamptz

-- WATCH HOURS (aggregated per user)
watch_sessions
  id            uuid PK
  user_id       uuid FK → users
  room_id       uuid FK → rooms
  started_at    timestamptz
  ended_at      timestamptz
  duration_secs int            ← computed on session end

-- GAMIFICATION
xp_log
  id            uuid PK
  user_id       uuid FK → users
  amount        int
  reason        enum('watch','login','chat','friend','event','ad_watch')
  created_at    timestamptz

-- EVENTS
events
  id            uuid PK
  title         text
  description   text
  starts_at     timestamptz
  ends_at       timestamptz
  is_sponsored  boolean DEFAULT false
  sponsor_name  text

event_tasks
  id            uuid PK
  event_id      uuid FK → events
  title         text
  description   text
  xp_reward     int
  badge_id      uuid FK → badges (nullable)

user_event_progress
  user_id       uuid FK → users
  task_id       uuid FK → event_tasks
  completed_at  timestamptz
  PRIMARY KEY (user_id, task_id)

-- BADGES / COLLECTABLES
badges
  id            uuid PK
  name          text
  description   text
  image_url     text
  rarity        enum('common','rare','epic','legendary')
  event_id      uuid FK → events (nullable)
  is_active     boolean

user_badges
  user_id       uuid FK → users
  badge_id      uuid FK → badges
  earned_at     timestamptz
  is_displayed  boolean DEFAULT false   ← on profile showcase
  PRIMARY KEY (user_id, badge_id)

-- LEADERBOARD CACHE (rebuilt weekly by BullMQ job)
leaderboard_snapshots
  id            uuid PK
  type          enum('individual','guild')
  period        enum('weekly','monthly','alltime')
  rank          int
  entity_id     uuid              ← user_id or guild_id
  watch_hours   float
  snapshot_at   timestamptz

-- ADS
ads
  id            uuid PK
  advertiser_id uuid FK → users
  title         text
  video_url     text
  xp_reward     int DEFAULT 0
  reward_title  text              ← cosmetic title unlocked on watch
  status        enum('pending','active','paused','done')
  impressions   int DEFAULT 0
  paid_amount   numeric(10,2)

ad_impressions
  ad_id         uuid FK → ads
  user_id       uuid FK → users
  watched_pct   int               ← 0–100, must be 80%+ to earn XP
  rewarded      boolean
  created_at    timestamptz
  PRIMARY KEY (ad_id, user_id)   ← one per user per ad

Entity Relationship Diagram

erDiagram users ||--o{ friend_requests : sends users ||--o{ guild_members : joins users ||--o{ room_members : enters users ||--o{ watch_sessions : accumulates users ||--o{ user_badges : earns users ||--o{ user_event_progress : completes users ||--o{ xp_log : gains users ||--o{ ads : creates guilds ||--o{ guild_members : has guilds ||--o{ rooms : hosts rooms ||--o{ room_members : contains rooms ||--o{ room_messages : has rooms ||--o{ watch_sessions : tracks events ||--o{ event_tasks : includes event_tasks ||--o{ user_event_progress : tracked_by event_tasks }o--|| badges : rewards badges ||--o{ user_badges : given_to ads ||--o{ ad_impressions : generates users { uuid id PK text username int level int xp bool is_premium } guilds { uuid id PK text name float total_watch_hours int max_members } rooms { uuid id PK enum platform enum visibility bool mic_enabled } events { uuid id PK text title timestamptz starts_at timestamptz ends_at } badges { uuid id PK text name enum rarity }

Room System

Room Creation Settings

SettingOptionsDefault
VisibilityPublic (anyone) · Friends only · Private (invite)Friends only
Max members2 – 5010
MicrophoneEnabled / Disabled (leader can toggle live)Disabled
Playback controlLeader only · Any memberLeader only
Guild-linkedYes (visible to guild) / NoNo
ChatAll · Mods only · OffAll

In-Room Leader Powers

Invite System

// Invite link: time-limited, single-use or multi-use
GET /api/rooms/:id/invite  → { code: "abc123", expires_in: 3600 }
// Friend invite: via push notification + in-app
POST /api/rooms/:id/invite-user  → { userId }

Guild System

Guild Roles

RolePermissions
LeaderAll permissions, disband guild, transfer ownership, manage all members
OfficerInvite/kick members, manage guild rooms, create guild events
MemberParticipate in guild rooms, view guild leaderboard, chat

Guild Watch Hours Calculation

// BullMQ job runs every hour (and on session end)
async function updateGuildWatchHours(guildId: string) {
  const result = await db.query(`
    SELECT SUM(ws.duration_secs) / 3600.0 as total_hours
    FROM watch_sessions ws
    JOIN guild_members gm ON ws.user_id = gm.user_id
    WHERE gm.guild_id = $1
  `, [guildId]);
  await db.update(guilds).set({ total_watch_hours: result.total_hours })
    .where(eq(guilds.id, guildId));
}

Leaderboard Reset & Rewards

// Weekly cron job (BullMQ scheduled) — runs every Monday 00:00 UTC
1. Snapshot current rankings → leaderboard_snapshots table
2. Top 3 guilds → set is_premium = true, premium_until = now + 30 days for all members
3. Top 3 individuals → set is_premium = true, premium_until = now + 90 days
4. Send push notifications to winners
5. Reset weekly counters (do NOT reset all-time)

Gamification System

XP Sources

ActionXPCooldown
Daily login+50 XPOnce per calendar day
Watch 1 hour+100 XPPer hour, max 5 hours/day
Watch party (with ≥2 friends)+20 XP bonus/hrPer hour in party
Send 10 chat messages+15 XPMax 3× per day
Add a friend (both users get XP)+25 XPPer new friend
Complete event taskVaries (+50–500)Once per task
Watch ad (80%+ completion)+30 XPOnce per ad
First watch session of day+25 XPDaily

Level Thresholds (Level 1–10)

Level  1:       0 XP  ← Starting
Level  2:     500 XP
Level  3:   1,500 XP
Level  4:   3,500 XP
Level  5:   7,500 XP
Level  6:  15,000 XP
Level  7:  27,500 XP
Level  8:  45,000 XP
Level  9:  70,000 XP
Level 10: 100,000 XP  ← Max (for v1)
Level is stored on the users table and updated by a BullMQ worker after every XP gain. This prevents race conditions from multiple simultaneous XP events.

Event System

Event Types

Community Event

Time-limited challenge (e.g. "Watch 10 hours this weekend"). Complete tasks → earn exclusive badge. Created by admins.

Sponsored Event

Business pays to host an event. Users watch a branded video (hosted on R2) and earn XP + cosmetic reward. Tracked per-impression.

Seasonal Event

Holiday-themed (Christmas, Halloween). Special badges, profile frames. Auto-scheduled by admin.

Badge / Collectable System

Badges are SVG/PNG assets stored in Cloudflare R2, served via CDN. Users can display up to 5 badges on their profile. Rarity tiers:

Common Rare Epic Legendary

Sponsored Video Event Flow

Business pays via StripeAd approved by admin
Event created with video taskUsers see event in Events tab
User watches ≥80% of videoimpression recordedXP + cosmetic awarded
Business gets impression report

Ad System

When Ads Show

Ad Delivery Architecture

// Client requests an ad before joining a room
GET /api/ads/next?placement=pre_room

// Server selects ad:
1. Filter active ads (status = 'active')
2. Filter ads user hasn't seen yet (JOIN ad_impressions)
3. Sort by priority / remaining budget
4. Return video_url (signed Cloudflare R2 URL, expires 10min)

// Client plays video, tracks % watched
// At 80% completion, client calls:
POST /api/ads/:id/impression  → { watchedPct: 82, placement: 'pre_room' }

// Server:
1. Verify 80%+ watched
2. Award XP via xp_log
3. Unlock reward_title if set
4. Increment ad.impressions
5. Queue BullMQ job to notify advertiser dashboard

Advertiser Pricing (example)

PackagePriceImpressions
Starter$50~500 impressions
Growth$200~2,500 impressions
Scale$500~8,000 impressions

Profile System

Editable Fields

Public Profile Data

GET /api/users/:username/profile

Response:
{
  username, displayName, avatarUrl, bio,
  level, xp,
  badges: Badge[5],                    // displayed badges
  stats: {
    watchHours: {
      thisWeek: float,
      thisMonth: float,
      thisYear: float,
      allTime: float
    }
  },
  guild: { id, name, avatarUrl } | null,
  joinedAt: ISO8601,
  friendCount: int
}

Settings Tab

Deployment Plan

Local Dev

Docker Compose for Postgres + Redis locally. pnpm dev starts all apps via Turborepo.

Staging

Auto-deployed on PR merge to develop. Vercel preview + Railway staging env.

Production

Deploy on merge to main. Zero-downtime via Railway rolling deploy + Vercel atomic deploys.

Mobile

EAS Build for TestFlight + Play internal track. EAS Update for JS-only OTA changes.

Environment Variables (key ones)

# API server
DATABASE_URL=postgresql://...
REDIS_URL=rediss://...
JWT_SECRET=...
SUPABASE_URL=...
SUPABASE_SERVICE_KEY=...
STRIPE_SECRET_KEY=...
STRIPE_WEBHOOK_SECRET=...
RESEND_API_KEY=...
R2_BUCKET_URL=...
R2_ACCESS_KEY=...

# Web / Next.js (NEXT_PUBLIC_ prefix = client-safe)
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
NEXT_PUBLIC_AGORA_APP_ID=...

# Mobile (via Expo EAS secrets)
EXPO_PUBLIC_API_URL=https://api.yourdomain.com
EXPO_PUBLIC_SUPABASE_URL=...

Development Roadmap

1

Phase 1 — Core (Weeks 1–6)

Monorepo setup, auth (OAuth), user profiles, basic room system (YouTube sync), real-time chat, friend system. Web + API only. Get the watch sync working perfectly.

Monorepo Auth Profiles YouTube Rooms Chat
2

Phase 2 — Gamification (Weeks 7–10)

XP system, levels, watch hour tracking, basic leaderboard. Google Drive + Spotify integration. Guild system. BullMQ jobs for background work.

XP + Levels Guilds Leaderboard Drive + Spotify
3

Phase 3 — Events + Ads (Weeks 11–14)

Event system, badge collectables, sponsored events. Ad system with video delivery. Stripe integration for premium + ad purchases. Admin dashboard.

Events Badges Ads Premium
4

Phase 4 — Mobile + Desktop (Weeks 15–20)

React Native app (Expo), Tauri desktop build. Push notifications. Voice chat via Agora. Full feature parity with web.

iOS App Android App Desktop Voice Chat
5

Phase 5 — Polish + Launch (Weeks 21–24)

Performance optimization, security audit, analytics, onboarding flow, beta testing with 100 users, App Store + Play Store submission.

Beta App Store Launch
Learning path: If you're new to this stack, start in order — Next.js → PostgreSQL + Drizzle → Socket.io → React Native. TypeScript throughout from day one. The tRPC + Zod combo eliminates an entire class of bugs and is worth learning early.
WatchTogether System Architecture · Generated design document · All rights reserved