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
- Sign in at Agora Console and create a new project
- Set Authentication Mode to Secure mode (Token) — required for production
- 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.