# How to Add Screen Sharing to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Screen sharing requires three pieces: the browser Screen Capture API (getDisplayMedia) to capture the display, WebRTC (RTCPeerConnection) to stream it peer-to-peer, and a signaling channel (Supabase Realtime) to coordinate the handshake. With Lovable or V0 you can get a working prototype in 1-3 days, but the WebRTC peer logic always needs manual review. Running costs range from $0 at small scale to $200-600/mo at 10,000 users when a managed WebRTC service becomes mandatory.

## What Screen Sharing Actually Requires

Screen sharing in a web app is not a single API — it is a chain of four cooperating systems. The browser's Screen Capture API (getDisplayMedia) captures a MediaStream of the user's display. WebRTC's RTCPeerConnection negotiates and streams that video to one or more viewers via STUN/TURN-mediated peer connections. A signaling channel (typically Supabase Realtime Broadcast) shuttles the SDP offer/answer and ICE candidates between peers during the handshake. And a session management layer tracks who is sharing, who is watching, and when the session ends. AI tools generate the UI shell and Supabase signaling reliably; the RTCPeerConnection wiring is where every first build breaks.

## Anatomy of the Feature

Six components in total. The signaling channel and session UI generate reliably with AI tools. The WebRTC peer connection and screen capture layer are where builds consistently break on first attempt.

- **Screen capture layer** (ui): Calls the browser-native MediaDevices.getDisplayMedia() API with video constraints ({ video: { cursor: 'always' }, audio: false }) to produce a MediaStream of the selected display surface. Must be triggered inside a direct user gesture (onClick), not in a useEffect or timer.
- **WebRTC peer connection** (backend): RTCPeerConnection manages the peer-to-peer video channel. The sharer creates an RTCPeerConnection, adds the MediaStream tracks, and creates an SDP offer. The viewer creates its own RTCPeerConnection, receives the offer, creates an answer, and both sides exchange ICE candidates. Uses public STUN servers (stun.l.google.com:19302) for NAT traversal and Twilio TURN for users behind corporate firewalls.
- **Signaling channel** (backend): Supabase Realtime Broadcast channel (named by room_id) exchanges SDP offers/answers and ICE candidates between sharer and viewer during the WebRTC handshake. Broadcast is used (not INSERT) because signaling messages are ephemeral and do not need persistence.
- **Session management UI** (ui): React component built with shadcn/ui Dialog (for pre-share confirmation) and Badge (sharing status indicator). Manages the UI state machine: idle → requesting-permission → sharing → stopped. Shows active viewer count sourced from Supabase Realtime presence or the viewers table.
- **Recording layer (optional)** (service): The MediaRecorder API can capture the MediaStream locally to a Blob, then upload to Supabase Storage for cloud persistence. For managed cloud recording, Daily.co and Livekit Cloud both offer built-in record features at approximately $0.004/min recorded.
- **Permissions guard** (ui): Checks MediaDevices.getDisplayMedia support before showing the share button. On permission denial, catches the NotAllowedError and surfaces a friendly explainer with a retry button rather than letting the error propagate to a blank state.

## Data model

Screen sharing needs two tables: sessions tracks active and past sharing sessions, and viewers records who joined each session. Run this SQL in the Supabase SQL editor (Dashboard → SQL Editor):

```sql
create table public.screen_sessions (
  id uuid primary key default gen_random_uuid(),
  host_user_id uuid references auth.users(id) on delete cascade not null,
  room_id text not null unique default gen_random_uuid()::text,
  started_at timestamptz not null default now(),
  ended_at timestamptz,
  viewer_count integer not null default 0,
  status text not null default 'active' check (status in ('active', 'ended'))
);

create table public.screen_session_viewers (
  id uuid primary key default gen_random_uuid(),
  session_id uuid references public.screen_sessions(id) on delete cascade not null,
  user_id uuid references auth.users(id) on delete cascade not null,
  joined_at timestamptz not null default now(),
  left_at timestamptz,
  unique (session_id, user_id)
);

alter table public.screen_sessions enable row level security;
alter table public.screen_session_viewers enable row level security;

create policy "Host can manage own sessions"
  on public.screen_sessions for all
  using (auth.uid() = host_user_id)
  with check (auth.uid() = host_user_id);

create policy "Any authenticated user can view active sessions"
  on public.screen_sessions for select
  using (auth.role() = 'authenticated');

create policy "Viewers can insert own join record"
  on public.screen_session_viewers for insert
  with check (auth.uid() = user_id);

create policy "Viewers can see participants in a session"
  on public.screen_session_viewers for select
  using (auth.role() = 'authenticated');

create policy "Viewers can update own leave record"
  on public.screen_session_viewers for update
  using (auth.uid() = user_id);

create index screen_sessions_room_id_idx
  on public.screen_sessions (room_id);

create index screen_session_viewers_session_idx
  on public.screen_session_viewers (session_id, joined_at desc);
```

The room_id column is what both peers subscribe to on the Supabase Realtime Broadcast channel. Generate it server-side when the host starts a session and share it with viewers via an invite link or in-app notification.

## Build paths

### Lovable — fit 2/10, 1-2 days + manual wiring

Lovable handles the UI shell, Supabase session tables, and the Realtime signaling channel setup. The RTCPeerConnection logic must be written manually in a client-side component because AI frequently generates async/await patterns that violate the trusted-gesture requirement for getDisplayMedia.

1. Create a new Lovable project with Lovable Cloud enabled so Supabase auth and the sessions table are provisioned automatically
2. Paste the prompt below; Lovable will generate the session UI, the Supabase schema, and the Realtime Broadcast channel subscription
3. After generation, manually review the getDisplayMedia call — move it into an explicit onClick handler if Lovable placed it in a useEffect
4. Publish the project (do not test in preview — getDisplayMedia is blocked in the preview iframe)
5. Open the published HTTPS URL in two browser tabs and verify the signaling handshake completes

Starter prompt:

```
Build a screen sharing feature for a web app. Schema: create a screen_sessions table (id, host_user_id, room_id, started_at, ended_at, viewer_count, status) and a screen_session_viewers table (session_id, user_id, joined_at, left_at) in Supabase with RLS — host manages own sessions, authenticated users can read active sessions and insert their own viewer row. UI: A 'Share Screen' button that calls navigator.mediaDevices.getDisplayMedia({ video: { cursor: 'always' }, audio: false }) inside an async onClick handler (never in useEffect). Show a state machine: idle state shows share button, requesting-permission state shows spinner, sharing state shows a red 'Sharing active' banner with viewer count and a 'Stop sharing' button, stopped state returns to idle. Stop sharing by calling stream.getTracks().forEach(t => t.stop()) and updating session status to 'ended'. Signaling: use a Supabase Realtime Broadcast channel named by room_id to exchange JSON messages of type 'sdp-offer', 'sdp-answer', and 'ice-candidate'. RTCPeerConnection iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]. Viewer page: subscribes to the room's Broadcast channel, receives the SDP offer, creates an answer, attaches the remote stream to a <video> element. Error states: permission denied (NotAllowedError) shows an explainer card with retry button; browser not supported shows an unsupported message. Audio capture toggle checkbox (off by default). Cleanup: remove viewer row from DB and close peer connection on page unload or 'Stop watching' button click.
```

Limitations:

- getDisplayMedia is blocked in Lovable's preview iframe — test only on the published HTTPS URL
- AI-generated RTCPeerConnection ICE candidate handling often has race conditions; expect one manual debugging session after initial generation
- Multi-viewer broadcasting (more than 3 viewers) is not achievable with Lovable's P2P approach — requires switching to a managed SFU service

### V0 — fit 3/10, 1-2 days + manual wiring

V0 generates clean Next.js client components for the signaling and UI layers. The WebRTC RTCPeerConnection still needs manual validation after generation because ICE/STUN configuration is frequently incomplete, but V0's component output is easier to edit than Lovable's.

1. Prompt V0 with the spec below; it will generate the ScreenShare and ScreenViewer client components with the Broadcast channel hooks
2. Open the Vars panel in V0 and add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL schema from the af_data_model section above in your Supabase SQL editor
4. Publish to Vercel and test on the live HTTPS URL — the V0 sandbox cannot access getDisplayMedia
5. Review the generated RTCPeerConnection iceServers config; add a TURN server if users are behind strict NAT

Starter prompt:

```
Build a screen sharing feature as Next.js 'use client' components. Component 1: ScreenShareHost — calls navigator.mediaDevices.getDisplayMedia({ video: { cursor: 'always' }, audio: false }) inside an onClick handler; creates an RTCPeerConnection with iceServers [{ urls: 'stun:stun.l.google.com:19302' }]; adds the stream tracks; creates an SDP offer; sends it via Supabase Realtime Broadcast on channel 'screen-{roomId}' as message type 'sdp-offer'; listens for 'sdp-answer' to set remote description; listens for 'ice-candidate' to add ICE candidates. UI state machine: idle (Share button), requesting-permission (spinner), sharing (red active banner + viewer count + Stop button), stopped (back to idle). Stop: stream.getTracks().forEach(t => t.stop()), close peer connection, broadcast 'session-ended'. Component 2: ScreenShareViewer — subscribes to 'screen-{roomId}' Broadcast; on 'sdp-offer' creates RTCPeerConnection, sets remote description, creates answer, broadcasts 'sdp-answer'; handles 'ice-candidate' events; attaches remote stream to a <video autoPlay playsInline> element. Error boundaries: NotAllowedError shows retry card; 'session-ended' shows 'Session has ended' screen. Audio toggle: checkbox that re-calls getDisplayMedia with audio: true when checked. Supabase env vars from process.env. RLS for sessions and viewers tables as described.
```

Limitations:

- V0 scaffolds the RTCPeerConnection shape but STUN/TURN ICE config requires manual validation after first deploy
- The V0 sandbox cannot test getDisplayMedia — always publish to Vercel first
- SSR: all WebRTC and getDisplayMedia code must be in 'use client' components with typeof window checks to avoid build errors

### Custom — fit 5/10, 2-4 weeks

The right path when screen sharing is a core product feature rather than a supplement. Full control over STUN/TURN configuration, codec selection (VP8 vs VP9 vs H.264), multi-viewer SFU architecture, cloud recording, and bandwidth optimization.

1. Integrate the Daily.co SDK or Livekit JS SDK — both abstract away RTCPeerConnection and SFU routing, giving production-grade reliability on day one
2. Configure a Livekit self-hosted deployment or Daily.co app with custom TURN server credentials for enterprise NAT traversal
3. Implement multi-viewer fan-out via SFU: sharer sends one upstream; the SFU distributes to N viewers without sharer CPU/bandwidth scaling linearly
4. Add cloud recording via the platform's built-in record API; store recordings in Supabase Storage or S3 with signed URL access
5. Instrument bandwidth and connection quality metrics to detect degraded sessions and prompt users to lower resolution

Limitations:

- Custom WebRTC development has a steep learning curve; budget at least a week purely on ICE/NAT edge cases before writing app logic

## Gotchas

- **Screen is blank or black in preview — works nowhere except the published URL** — getDisplayMedia requires both a user gesture AND a secure (HTTPS) browsing context. Lovable's preview panel and V0's sandbox both run inside sandboxed iframes that block the Screen Capture API entirely — calling getDisplayMedia in these contexts returns a blank black stream or throws NotAllowedError with no visible error message. Fix: Test screen sharing only on the published URL. In Lovable, click Publish first; in V0, deploy to Vercel. Never report a bug to the AI tool until you've verified on the live HTTPS URL.
- **ICE connection fails for some users — especially on corporate networks** — Public STUN servers help peers discover their external IP addresses, but symmetric NAT (common in enterprise VPNs and firewalls) blocks direct peer connections even with STUN. When STUN fails, the RTCPeerConnection silently stays in 'connecting' state and never streams video. Fix: Add a TURN relay server to the iceServers config. Twilio's Network Traversal Service generates short-lived TURN credentials via a Supabase Edge Function that the client fetches before creating the peer connection. Without TURN, roughly 10-15% of users on strict networks will never connect.
- **Supabase Realtime drops signaling messages and negotiation fails silently** — Supabase Realtime Broadcast has no message persistence or delivery guarantee. If a peer's Broadcast subscription briefly disconnects (tab backgrounded, mobile browser, slow connection) and reconnects after an ICE candidate was sent, those candidates are lost and the WebRTC negotiation fails — often with no error visible to the user. Fix: Implement offer/answer retry with exponential backoff: if the peer connection stays in 'connecting' for more than 8 seconds, trigger a full renegotiation. As a belt-and-suspenders backup, write ICE candidates to a temporary Supabase table with a 60-second TTL and have the joining peer read them on connect.
- **AI puts getDisplayMedia in a useEffect and it throws NotAllowedError** — The browser enforces that getDisplayMedia can only be called from a direct user-interaction handler (click, keypress). AI tools frequently scaffold it inside a useEffect, a setTimeout, or an async function called outside an event handler — all of which the browser treats as non-trusted-gesture contexts and blocks with NotAllowedError. Fix: Ensure the getDisplayMedia call is inside the async onClick handler with no intermediary async steps between the click event and the API call. Move the call up the call stack if needed; the fewer async hops between user gesture and getDisplayMedia, the better.
- **CPU and bandwidth spike at 4+ simultaneous viewers** — P2P WebRTC creates a separate upstream peer connection per viewer. At 4 viewers the sharer's device sends 4 full video streams simultaneously. On a typical laptop with a 10 Mbps upload, a 720p screen share at 4 viewers saturates the connection entirely and frame rate drops to near zero. Fix: Switch to an SFU (Selective Forwarding Unit) architecture for more than 3 simultaneous viewers. Daily.co and Livekit Cloud handle SFU routing transparently — the sharer sends one stream to the SFU which distributes to all viewers. P2P is fine for 1-on-1 and small group sessions.

## Best practices

- Always wrap getDisplayMedia in a try/catch and handle NotAllowedError, NotFoundError, and AbortError separately with user-friendly messages for each
- Call stream.getTracks().forEach(t => t.stop()) on every exit path — tab close, route change, and the stop button — or the OS screen-capture indicator remains active after the session ends
- Show the viewer count in real time using Supabase Realtime Presence rather than polling the DB to keep the UI responsive without generating unnecessary database load
- Set a session timeout: automatically end sharing after 4 hours and notify the host, preventing orphaned sessions that accumulate in the database
- Include the audio capture toggle as an explicit off-by-default option — many users are surprised that getDisplayMedia can capture system audio and tab audio on Chrome
- Store session metadata (started_at, ended_at, peak_viewer_count) even for short sessions; this data is essential for support triage when users report connectivity issues
- Document the browser support matrix clearly in your app's UI: screen sharing works in Chrome, Edge, and Firefox on desktop; Safari 13+; it does not work on iOS browsers or Firefox for Android

## Frequently asked questions

### Can I build screen sharing without WebRTC?

There are workarounds, but none are practical for production. You could capture screenshots with getDisplayMedia and send them as images over WebSockets — but this gives 1-2 FPS at best and burns Supabase bandwidth. For real-time video, WebRTC is the only browser-native solution. Third-party SDKs like Daily.co and Livekit abstract it, but they use WebRTC underneath.

### Is screen sharing possible in a mobile browser?

On Android Chrome, getDisplayMedia is supported from Chrome 94+. On iOS, screen sharing from a browser is not supported — Apple restricts getDisplayMedia on iOS Safari and all iOS browsers. For iOS screen sharing you need a native app using ReplayKit.

### How do I stop AI from generating broken WebRTC code?

Be extremely specific in your prompt: name the exact callback where getDisplayMedia should be called (onClick handler), name the iceServers config, specify that ICE candidates are sent via Broadcast not INSERT, and list the exact error types to catch. Vague prompts produce working-looking code that fails at the ICE negotiation step. After generation, always verify the getDisplayMedia call is inside a trusted user-gesture handler.

### What is the cheapest way to add screen sharing to a Lovable app?

For up to ~50 simultaneous sessions with small viewer counts (1-3 per session), the cheapest path is Supabase free tier for signaling plus public Google STUN servers (stun.l.google.com:19302) — total cost is $0/mo. Add Twilio TURN only if you have enterprise customers on strict firewalls. Avoid managed services like Daily.co unless you need SFU multi-viewer or cloud recording.

### Does screen sharing work on Safari?

Yes, getDisplayMedia is supported on Safari 13+ on macOS. However, Safari's implementation has limitations: it does not support sharing individual application windows (only the entire screen or a tab), and audio capture via getDisplayMedia is not available in Safari. Test specifically in Safari before telling users it works.

### Can viewers interact with the shared screen (remote control)?

No — browsers do not expose a remote-control API. WebRTC streams display pixels, not input events. True remote control (like TeamViewer) requires a native OS-level agent running outside the browser. For browser-only collaboration, the closest alternative is a shared whiteboard or a real-time cursor position overlay using Supabase Realtime Presence.

### How do I record a screen share session?

Two options: local recording with the MediaRecorder API (free, records on the sharer's device, upload to Supabase Storage after) or cloud recording via Daily.co or Livekit Cloud (approximately $0.004/min, stores directly in their cloud). Local recording is lost if the tab crashes; cloud recording is reliable but adds cost. For most apps, local MediaRecorder + upload covers the use case at zero additional service cost.

### What is the difference between getDisplayMedia and getUserMedia for screen sharing?

getUserMedia captures the user's camera and microphone. getDisplayMedia captures a selected display surface: a browser tab, an application window, or the entire screen. They produce the same type (MediaStream) so you can mix tracks from both — for example, combining a webcam feed with a screen share — by adding tracks from both calls to the same RTCPeerConnection.

---

Source: https://www.rapidevelopers.com/app-features/screen-sharing
© RapidDev — https://www.rapidevelopers.com/app-features/screen-sharing
