RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-04-11Advanced

Building a Live Streaming App with Rork and Agora SDK — Real-Time Video, Gifting, and Monetization

Build a production-ready live streaming app using Rork and Agora SDK. Covers iOS/Android setup, host and audience roles, real-time chat, gift monetization, and scaling strategies with full code examples.

Rork515Agoralive streamingreal-time videoWebRTC4monetization47indie dev29iOS109Android43

Why Live Streaming Is a Powerful Edge for Indie Developers

Live streaming is no longer the exclusive domain of tech giants. In 2026, niche live streaming apps built by indie developers — fitness coaching, language tutoring, investing commentary, game streams — are generating real recurring revenue with modest audience sizes.

The traditional barrier was steep: WebRTC configuration, native SDK bindings for Agora, and maintaining separate iOS and Android codebases was a full-time engineering job. Rork significantly lowers that barrier.

In this guide, you'll build a complete live streaming app using Rork + Agora SDK: from go-live to viewer join flows, from real-time chat to gift monetization, and from basic two-person streams all the way to CDN-based scaling for thousands of concurrent viewers.

What You'll Learn

  • Integrating Agora SDK into a Rork project with secure token-based authentication
  • Host and Audience role architecture with role-specific Agora hooks
  • Real-time chat and animated gift features powered by Supabase Realtime
  • Gift coin purchase flow with RevenueCat and atomic Supabase RPCs
  • Adaptive video quality and CDN push for large-scale broadcasts

Who This Is For

This guide assumes you have experience building Rork apps and are comfortable connecting to external APIs like Supabase or Firebase. No prior Agora experience is required.


Chapter 1: Architecture Design

Before writing any code, let's map out the full architecture of a live streaming app.

Four Layers

Client Layer (Rork App)

Your Rork-generated React Native app handles the host camera screen, viewer stream screen, chat UI, and gift animations.

Real-Time Transport Layer (Agora)

Agora RTC SDK handles all real-time audio and video delivery. It abstracts the complexity of WebRTC so you build without touching SDP negotiation or ICE candidate exchanges.

Backend Layer (Supabase)

Supabase (PostgreSQL + Realtime) manages rooms, chat messages, user profiles, and gift transaction history.

Payments Layer (RevenueCat / Stripe)

RevenueCat handles coin pack in-app purchases through App Store and Google Play. Stripe Connect handles creator payouts.

Data Flow

[Host] ─── Agora SDK ──→ [Agora Cloud] ──→ [Viewer 1, 2, 3...]
              ↕                                      ↕
        Supabase Realtime ── chat / gifts ──→ Supabase DB
              ↕
      RevenueCat ── coin purchase ── App Store / Google Play

Chapter 2: Agora Project Setup

2-1. Creating an Agora Project

  1. Sign in at Agora Console and create a new project
  2. Set Authentication Mode to Secure mode (Token) — required for production
  3. Copy the App ID and App Certificate — you'll need both

2-2. Token Server with Supabase Edge Functions

// supabase/functions/agora-token/index.ts
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { RtcTokenBuilder, RtcRole } from "npm:agora-token@2.0.2";
 
const AGORA_APP_ID = Deno.env.get("AGORA_APP_ID")\!;
const AGORA_APP_CERTIFICATE = Deno.env.get("AGORA_APP_CERTIFICATE")\!;
 
serve(async (req) => {
  if (req.method \!== "POST") return new Response("Method Not Allowed", { status: 405 });
  const { channelName, uid, role } = await req.json();
 
  const rtcRole = role === "host" ? RtcRole.PUBLISHER : RtcRole.SUBSCRIBER;
  const privilegeExpiredTs = Math.floor(Date.now() / 1000) + 3600;
 
  const token = RtcTokenBuilder.buildTokenWithUid(
    AGORA_APP_ID, AGORA_APP_CERTIFICATE,
    channelName, uid, rtcRole, privilegeExpiredTs
  );
 
  return new Response(JSON.stringify({ token, channelName, uid }), {
    headers: { "Content-Type": "application/json" },
  });
});

Deploy this function and set AGORA_APP_ID and AGORA_APP_CERTIFICATE in your Supabase project's environment secrets.

2-3. Supabase Database Schema

CREATE TABLE live_rooms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  host_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  channel_name TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  status TEXT DEFAULT 'scheduled',
  viewer_count INTEGER DEFAULT 0,
  total_gifts INTEGER DEFAULT 0,
  started_at TIMESTAMPTZ,
  ended_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE TABLE live_chat_messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  room_id UUID REFERENCES live_rooms(id) ON DELETE CASCADE,
  user_id UUID REFERENCES auth.users(id),
  display_name TEXT NOT NULL,
  content TEXT NOT NULL,
  message_type TEXT DEFAULT 'chat',
  gift_type TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE TABLE coin_balances (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id),
  balance INTEGER DEFAULT 0,
  total_purchased INTEGER DEFAULT 0,
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE TABLE gift_transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  sender_id UUID REFERENCES auth.users(id),
  receiver_id UUID REFERENCES auth.users(id),
  room_id UUID REFERENCES live_rooms(id),
  gift_type TEXT NOT NULL,
  coin_cost INTEGER NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
-- Row Level Security
ALTER TABLE live_rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE live_chat_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE coin_balances ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY "Anyone can view rooms" ON live_rooms FOR SELECT USING (true);
CREATE POLICY "Authenticated users can create rooms" ON live_rooms FOR INSERT WITH CHECK (auth.uid() = host_id);
CREATE POLICY "Host can update their own room" ON live_rooms FOR UPDATE USING (auth.uid() = host_id);
CREATE POLICY "Anyone can view chat" ON live_chat_messages FOR SELECT USING (true);
CREATE POLICY "Authenticated users can send messages" ON live_chat_messages FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can only see their own balance" ON coin_balances FOR SELECT USING (auth.uid() = user_id);

Chapter 3: Host (Broadcaster) Screen

3-1. Agora Host Hook

// hooks/useAgoraHost.ts
import { useState, useEffect, useRef } from "react";
import {
  createAgoraRtcEngine, IRtcEngine,
  ClientRoleType, ChannelProfileType, FrameRate,
} from "react-native-agora";
import { supabase } from "../lib/supabase";
 
export function useAgoraHost({ channelName, token, uid }: {
  channelName: string; token: string; uid: number;
}) {
  const engineRef = useRef<IRtcEngine | null>(null);
  const [isStreaming, setIsStreaming] = useState(false);
  const [isMuted, setIsMuted] = useState(false);
  const [isCameraOff, setIsCameraOff] = useState(false);
  const [viewerCount, setViewerCount] = useState(0);
 
  useEffect(() => { initEngine(); return cleanup; }, []);
 
  const initEngine = async () => {
    const engine = createAgoraRtcEngine();
    engineRef.current = engine;
 
    engine.initialize({
      appId: process.env.EXPO_PUBLIC_AGORA_APP_ID\!,
      channelProfile: ChannelProfileType.ChannelProfileLiveBroadcasting,
    });
    engine.setVideoEncoderConfiguration({
      dimensions: { width: 1280, height: 720 },
      frameRate: FrameRate.FrameRateFps30,
      bitrate: 1500,
    });
    engine.setClientRole(ClientRoleType.ClientRoleBroadcaster);
    engine.enableVideo();
    engine.enableAudio();
    engine.startPreview();
 
    engine.addListener("onUserJoined", () => setViewerCount(prev => prev + 1));
    engine.addListener("onUserOffline", () => setViewerCount(prev => Math.max(0, prev - 1)));
 
    // Auto token renewal — keeps streams alive beyond 1 hour
    engine.addListener("onRequestToken", async () => {
      const { data } = await supabase.functions.invoke("agora-token", {
        body: { channelName, uid, role: "host" },
      });
      engine.renewToken(data.token);
    });
  };
 
  const startStream = async () => {
    await engineRef.current?.joinChannel(token, channelName, uid, {
      clientRoleType: ClientRoleType.ClientRoleBroadcaster,
    });
    setIsStreaming(true);
  };
 
  const endStream = async () => {
    await engineRef.current?.leaveChannel();
    setIsStreaming(false);
  };
 
  const cleanup = () => {
    engineRef.current?.stopPreview();
    engineRef.current?.leaveChannel();
    engineRef.current?.release();
  };
 
  return {
    isStreaming, isMuted, isCameraOff, viewerCount,
    startStream, endStream,
    toggleMute: () => { const m = \!isMuted; engineRef.current?.muteLocalAudioStream(m); setIsMuted(m); },
    toggleCamera: () => { const o = \!isCameraOff; engineRef.current?.enableLocalVideo(\!o); setIsCameraOff(o); },
    switchCamera: () => engineRef.current?.switchCamera(),
  };
}

3-2. Host UI Component

// screens/HostStreamScreen.tsx
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet, Alert } from "react-native";
import { RtcSurfaceView, VideoSourceType } from "react-native-agora";
import { useAgoraHost } from "../hooks/useAgoraHost";
import { supabase } from "../lib/supabase";
import ChatOverlay from "../components/ChatOverlay";
import GiftAnimationOverlay from "../components/GiftAnimationOverlay";
 
export default function HostStreamScreen({ roomId, channelName, token, uid }: {
  roomId: string; channelName: string; token: string; uid: number;
}) {
  const host = useAgoraHost({ channelName, token, uid });
 
  const handleStart = async () => {
    await host.startStream();
    await supabase.from("live_rooms")
      .update({ status: "live", started_at: new Date().toISOString() })
      .eq("id", roomId);
  };
 
  const handleEnd = () => Alert.alert("End Stream?", "All viewers will be disconnected.", [
    { text: "Cancel", style: "cancel" },
    { text: "End", style: "destructive", onPress: async () => {
      await host.endStream();
      await supabase.from("live_rooms")
        .update({ status: "ended", ended_at: new Date().toISOString() })
        .eq("id", roomId);
    }},
  ]);
 
  return (
    <View style={s.container}>
      <RtcSurfaceView style={s.camera} canvas={{ uid: 0, sourceType: VideoSourceType.VideoSourceCamera }} />
      <View style={s.statusBar}>
        <View style={s.liveTag}><Text style={s.liveText}>● LIVE</Text></View>
        <Text style={s.viewerText}>👁 {host.viewerCount} watching</Text>
      </View>
      <ChatOverlay roomId={roomId} />
      <GiftAnimationOverlay roomId={roomId} />
      <View style={s.controls}>
        <TouchableOpacity onPress={host.switchCamera} style={s.btn}><Text style={s.icon}>🔄</Text></TouchableOpacity>
        <TouchableOpacity onPress={host.toggleCamera} style={s.btn}><Text style={s.icon}>{host.isCameraOff ? "📵" : "📹"}</Text></TouchableOpacity>
        <TouchableOpacity onPress={host.toggleMute} style={s.btn}><Text style={s.icon}>{host.isMuted ? "🔇" : "🎤"}</Text></TouchableOpacity>
        {host.isStreaming
          ? <TouchableOpacity onPress={handleEnd} style={s.endBtn}><Text style={s.endText}>End Stream</Text></TouchableOpacity>
          : <TouchableOpacity onPress={handleStart} style={s.startBtn}><Text style={s.startText}>Go Live</Text></TouchableOpacity>
        }
      </View>
    </View>
  );
}
 
const s = StyleSheet.create({
  container: { flex: 1, backgroundColor: "#000" },
  camera: { flex: 1 },
  statusBar: { position: "absolute", top: 50, left: 16, right: 16, flexDirection: "row", alignItems: "center", gap: 12 },
  liveTag: { backgroundColor: "#FF4444", paddingHorizontal: 12, paddingVertical: 4, borderRadius: 4 },
  liveText: { color: "#fff", fontWeight: "bold", fontSize: 13 },
  viewerText: { color: "#fff", fontSize: 14 },
  controls: { position: "absolute", bottom: 40, left: 16, right: 16, flexDirection: "row", justifyContent: "space-around", alignItems: "center" },
  btn: { width: 52, height: 52, borderRadius: 26, backgroundColor: "rgba(255,255,255,0.2)", justifyContent: "center", alignItems: "center" },
  icon: { fontSize: 24 },
  startBtn: { backgroundColor: "#FF4444", paddingHorizontal: 28, paddingVertical: 14, borderRadius: 8 },
  startText: { color: "#fff", fontWeight: "bold", fontSize: 16 },
  endBtn: { backgroundColor: "#333", paddingHorizontal: 28, paddingVertical: 14, borderRadius: 8 },
  endText: { color: "#fff", fontWeight: "bold", fontSize: 16 },
});

Chapter 4: Audience (Viewer) Screen

// hooks/useAgoraAudience.ts
import { useState, useEffect, useRef } from "react";
import { createAgoraRtcEngine, ClientRoleType, ChannelProfileType } from "react-native-agora";
 
export function useAgoraAudience({ channelName, token, uid }: {
  channelName: string; token: string; uid: number;
}) {
  const engineRef = useRef<any>(null);
  const [hostUid, setHostUid] = useState<number | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const [networkQuality, setNetworkQuality] = useState(0);
 
  useEffect(() => {
    const engine = createAgoraRtcEngine();
    engineRef.current = engine;
 
    engine.initialize({
      appId: process.env.EXPO_PUBLIC_AGORA_APP_ID\!,
      channelProfile: ChannelProfileType.ChannelProfileLiveBroadcasting,
    });
 
    // Ultra-low latency mode (<1.5 seconds)
    engine.setClientRole(ClientRoleType.ClientRoleAudience, { audienceLatencyLevel: 1 });
    engine.enableVideo();
 
    engine.addListener("onUserPublishStream", (connection: any, remoteUid: number) => {
      setHostUid(remoteUid); setIsConnected(true);
    });
    engine.addListener("onUserUnpublishStream", () => {
      setHostUid(null); setIsConnected(false);
    });
    engine.addListener("onNetworkQuality", (connection: any, uid: number, tx: number, rx: number) => {
      setNetworkQuality(rx);
    });
 
    engine.joinChannel(token, channelName, uid, {
      clientRoleType: ClientRoleType.ClientRoleAudience,
    });
 
    return () => { engine.leaveChannel(); engine.release(); };
  }, []);
 
  return { hostUid, isConnected, networkQuality };
}

The audience screen renders the host's video via RtcSurfaceView with hostUid, overlays ChatOverlay and GiftPanel. The gift panel shows emoji buttons (⭐💎🎉), tapping one deducts coins atomically and fires an animation across all viewers via Supabase Realtime.


Chapter 5: Gift System and In-App Purchases

5-1. Coin Purchases via RevenueCat

// services/coinPurchase.ts
import Purchases from "react-native-purchases";
import { supabase } from "../lib/supabase";
 
export const COIN_PACKAGES = {
  COINS_100:  { productId: "coins_100",  amount: 100,  display: "$1.29" },
  COINS_500:  { productId: "coins_500",  amount: 500,  display: "$5.99" },
  COINS_1200: { productId: "coins_1200", amount: 1200, display: "$11.99" },
  COINS_3000: { productId: "coins_3000", amount: 3000, display: "$27.99" },
};
 
export async function purchaseCoins(productId: string, coinAmount: number) {
  const offerings = await Purchases.getOfferings();
  const pkg = offerings.current?.availablePackages.find(p => p.product.identifier === productId);
  if (\!pkg) throw new Error("Package not found");
 
  await Purchases.purchasePackage(pkg);
 
  const { data: { user } } = await supabase.auth.getUser();
  if (\!user) throw new Error("Not authenticated");
 
  await supabase.rpc("add_coins", { p_user_id: user.id, p_amount: coinAmount });
}
-- Atomic coin balance update
CREATE OR REPLACE FUNCTION add_coins(p_user_id UUID, p_amount INTEGER)
RETURNS INTEGER AS $$
DECLARE v_new_balance INTEGER;
BEGIN
  INSERT INTO coin_balances (user_id, balance, total_purchased)
  VALUES (p_user_id, p_amount, p_amount)
  ON CONFLICT (user_id) DO UPDATE
  SET balance = coin_balances.balance + p_amount,
      total_purchased = coin_balances.total_purchased + p_amount,
      updated_at = NOW()
  RETURNING balance INTO v_new_balance;
  RETURN v_new_balance;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

5-2. Sending Gifts — Atomic Coin Deduction

// services/giftService.ts
import { supabase } from "../lib/supabase";
 
export const GIFTS = {
  STAR:  { type: "gift_small",  coinCost: 10,  emoji: "⭐", label: "Star" },
  GEM:   { type: "gift_medium", coinCost: 50,  emoji: "💎", label: "Gem" },
  BURST: { type: "gift_large",  coinCost: 200, emoji: "🎉", label: "Burst" },
} as const;
 
export async function sendGift(roomId: string, receiverId: string, giftKey: keyof typeof GIFTS) {
  const gift = GIFTS[giftKey];
  const { data: { user } } = await supabase.auth.getUser();
  if (\!user) throw new Error("Login required");
 
  const { data, error } = await supabase.rpc("send_gift", {
    p_sender_id: user.id, p_receiver_id: receiverId,
    p_room_id: roomId, p_gift_type: gift.type, p_coin_cost: gift.coinCost,
  });
  if (error) throw new Error(error.message);
 
  // Broadcast to all viewers via Realtime to trigger gift animation
  await supabase.from("live_chat_messages").insert({
    room_id: roomId, user_id: user.id, display_name: data.display_name,
    content: `${gift.emoji} sent a ${gift.label}\!`,
    message_type: "gift", gift_type: gift.type,
  });
}
CREATE OR REPLACE FUNCTION send_gift(
  p_sender_id UUID, p_receiver_id UUID, p_room_id UUID,
  p_gift_type TEXT, p_coin_cost INTEGER
) RETURNS JSON AS $$
DECLARE v_balance INTEGER; v_display_name TEXT;
BEGIN
  SELECT balance INTO v_balance FROM coin_balances WHERE user_id = p_sender_id FOR UPDATE;
  IF v_balance IS NULL OR v_balance < p_coin_cost THEN
    RAISE EXCEPTION 'Insufficient coins';
  END IF;
  UPDATE coin_balances SET balance = balance - p_coin_cost, updated_at = NOW() WHERE user_id = p_sender_id;
  INSERT INTO gift_transactions (sender_id, receiver_id, room_id, gift_type, coin_cost)
  VALUES (p_sender_id, p_receiver_id, p_room_id, p_gift_type, p_coin_cost);
  UPDATE live_rooms SET total_gifts = total_gifts + p_coin_cost WHERE id = p_room_id;
  SELECT COALESCE(raw_user_meta_data->>'display_name', email) INTO v_display_name
  FROM auth.users WHERE id = p_sender_id;
  RETURN json_build_object('success', true, 'display_name', v_display_name);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Chapter 6: Real-Time Chat with Supabase

// hooks/useLiveChat.ts
import { useState, useEffect } from "react";
import { supabase } from "../lib/supabase";
 
interface ChatMessage {
  id: string; display_name: string; content: string;
  message_type: "chat" | "gift" | "system"; gift_type?: string; created_at: string;
}
 
export function useLiveChat(roomId: string) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
 
  useEffect(() => {
    // Fetch last 100 messages
    supabase.from("live_chat_messages").select("*")
      .eq("room_id", roomId).order("created_at", { ascending: true }).limit(100)
      .then(({ data }) => { if (data) setMessages(data); });
 
    // Subscribe to new messages in real time
    const channel = supabase.channel(`room:${roomId}`)
      .on("postgres_changes", {
        event: "INSERT", schema: "public",
        table: "live_chat_messages", filter: `room_id=eq.${roomId}`,
      }, ({ new: msg }) => {
        setMessages(prev => [...prev, msg as ChatMessage].slice(-200));
      })
      .subscribe();
 
    return () => { supabase.removeChannel(channel); };
  }, [roomId]);
 
  const sendMessage = async (content: string, displayName: string) => {
    const { data: { user } } = await supabase.auth.getUser();
    if (\!user) return;
    await supabase.from("live_chat_messages").insert({
      room_id: roomId, user_id: user.id,
      display_name: displayName, content, message_type: "chat",
    });
  };
 
  return { messages, sendMessage };
}

Chapter 7: Performance Optimization and Scaling

7-1. Minimizing Latency

Agora provides two latency modes for audience:

  • Standard mode (latencyLevel: 2): 2–4 seconds. Stable, suitable for one-way broadcasts with little interaction.
  • Ultra-low latency (latencyLevel: 1): under 1.5 seconds. Best for Q&A, interactive coaching, or gaming streams.
engine.setClientRole(ClientRoleType.ClientRoleAudience, {
  audienceLatencyLevel: 1, // Ultra-low latency
});

7-2. Adaptive Bitrate Based on Network Quality

engine.addListener("onNetworkQuality", (connection, uid, txQuality, rxQuality) => {
  if (rxQuality >= 4) {
    // Network is poor — drop to 360p to maintain playback
    engine.setVideoEncoderConfiguration({
      dimensions: { width: 640, height: 360 },
      frameRate: FrameRate.FrameRateFps15, bitrate: 500,
    });
  } else if (rxQuality <= 2) {
    // Network is good — restore 720p
    engine.setVideoEncoderConfiguration({
      dimensions: { width: 1280, height: 720 },
      frameRate: FrameRate.FrameRateFps30, bitrate: 1500,
    });
  }
});

Viewers tolerate lower resolution far better than choppy frames. Always prioritize framerate when network degrades.

7-3. CDN Push for Large-Scale Broadcasts (1,000+ Viewers)

For audiences beyond ~1,000 concurrent viewers, switch to Agora's CDN streaming mode. The host pushes RTMP to your CDN; viewers receive HLS via a standard video player:

await engine.startRtmpStreamWithTranscoding(
  "rtmp://your-cdn/live/channel-name",
  {
    width: 1280, height: 720,
    videoBitrate: 1500, videoFramerate: 30,
    audioSampleRate: 44100, audioBitrate: 128, audioChannels: 2,
    transcodingUsers: [{ uid: 0, x: 0, y: 0, width: 1280, height: 720, zOrder: 1, alpha: 1 }],
  }
);

Chapter 8: Common Issues and Fixes

Q1. Channel join fails with "token invalid" error

Cause and fix: Tokens expire after 1 hour. Add an onRequestToken listener (see Chapter 3 code) to auto-renew. Without this, streams running longer than one hour will silently disconnect.

Q2. Camera feed doesn't appear on iOS

Cause and fix: Info.plist is missing NSCameraUsageDescription and NSMicrophoneUsageDescription. Prompt Rork: "Add camera and microphone permissions to the app." It handles this automatically.

Q3. Android build fails with NDK error

Cause and fix: react-native-agora requires Android NDK. Set ndkVersion "24.0.8215888" (or newer) in android/build.gradle.

Q4. Video stutters during long streams

Cause and fix: The device's thermal throttling reduces CPU/GPU performance after sustained load. Reduce bitrate proactively for streams over 30 minutes. Viewers accept lower resolution — they don't accept frame drops.

Q5. Gift coin balance doesn't update after sending

Cause and fix: The send_gift SQL function must be SECURITY DEFINER so it can bypass RLS and run the balance check and deduction atomically as a unit. Removing SECURITY DEFINER exposes the function to row-level restrictions that break the atomic update.


Chapter 9: Monetization Strategy

Coin-to-Creator Payout Model

A typical split looks like this:

  • Platform fee: 30–40% (covers App Store / Google Play commissions and infrastructure)
  • Creator payout: 60–70%

Design a coin-to-cash exchange rate (for example: 100 coins = $0.70 creator credit) and run monthly payouts via Stripe Connect. Be transparent with creators about the rate upfront — it builds loyalty.

Super Listener Monthly Subscription

Alongside gift revenue, a "Super Listener" monthly subscription adds predictable recurring income. Perks to offer:

  • A badge displayed next to their username in chat
  • Priority comment pinning
  • Monthly free coin grant (e.g., 100 coins/month)

For subscription implementation details, see the Rork × RevenueCat In-App Purchase Complete Guide. For payment security design, refer to the Rork App Security Complete Implementation Guide.


Summary

In this guide, you built a production-grade live streaming app using Rork and Agora SDK:

  • Agora project setup with a Supabase Edge Function token server (secure mode)
  • Host and Audience Agora RTC hooks with auto token renewal
  • Coin purchase and gift sending with RevenueCat and atomic Supabase RPCs
  • Real-time chat powered by Supabase Realtime subscriptions
  • Adaptive video quality control and CDN push for scaling to thousands of viewers

Live streaming creates a powerful engagement loop: viewers buy coins to delight creators, creators go live more often, which attracts more viewers. Find your niche — fitness, language learning, investing, creative arts — and build the community-first platform that doesn't exist yet.

For the foundations of real-time app features, Building a Real-Time Chat App with Rork and Supabase is a great companion read.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-05-18
Adapting Your Rork iOS App to iPhone Air and 17 Pro Series — Layout Patterns for 2026's New Resolutions
When iPhone Air added a new point resolution to the mix, existing Rork apps needed layout adjustments. Based on updating 4 iOS apps simultaneously — including Beautiful HD Wallpapers with 50M+ downloads — this guide covers device constant management, Safe Area handling, and full-screen wallpaper display fixes.
Dev Tools2026-05-02
Why StatusBar Colors Won't Apply in Rork — and How to Fix It
A practical, case-by-case guide to fixing StatusBar color and style issues in Rork apps across iOS and Android, based on real shipping experience.
Dev Tools2026-05-01
EAS Update Published but Nothing Changes? Five Patterns That Quietly Break OTA Delivery in Rork
You ran eas update, the CLI showed a green Published, but your iPhone keeps loading the old code. Here are the five patterns I keep running into, plus a five-minute diagnostic flow you can use the next time OTA goes silent.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →