Building stream.onbase.gg: Browser-Native Live Streaming on Base
How we built a WebRTC-powered live streaming platform with zero desktop software, real-time chat, token gating, and onchain tipping—all running in the browser.

What if you could go live without downloading OBS? What if streaming was as simple as clicking a button in your browser? That's exactly what we built with stream.onbase.gg—a next-generation streaming platform that combines WebRTC, blockchain authentication, and real-time chat into a seamless experience.
The Problem with Traditional Streaming
Traditional platforms require:
- Desktop software (OBS, Streamlabs) - Configuration nightmares
- RTMP setup - Complex stream keys and server URLs
- Platform lock-in - 50% revenue cuts on Twitch
- High latency - 10-30 second delays with HLS
We wanted something radically simpler: stream directly from your browser.
Zero Software Required
stream.onbase.gg works entirely in your browser. Connect wallet, click "Go Live," and you're streaming. No OBS, no downloads, no configuration.
Technology Stack
We built stream.onbase.gg on cutting-edge Web3 infrastructure:
Frontend
- Next.js 15.5.4 with App Router
- React 19 for modern features
- Tailwind CSS v4 for styling
- Turbopack for 10x faster builds
Video Infrastructure
- Cloudflare Stream (WebRTC via WHIP/WHEP)
- HLS.js for adaptive bitrate fallback
- Canvas API for video compositing
- @eyevinn/webrtc-player for playback
Real-Time Layer
- InstantDB for zero-latency chat
- Heartbeat-based viewer tracking
- Live tip notifications
Blockchain
- Base L2 for all transactions
- Privy for wallet authentication
- viem & wagmi for Web3 interactions
- PostgreSQL for stream metadata
Part 1: Browser-Based Streaming
The core innovation is enabling streaming without desktop software.
WebRTC via WHIP Protocol
We use the WHIP (WebRTC-HTTP Ingestion Protocol) standard to publish streams:
export type StreamSource = 'camera' | 'screen' | 'camera-and-screen';
export function useStudioStreaming() {
const [isStreaming, setIsStreaming] = useState(false);
const [currentSource, setCurrentSource] = useState<StreamSource>('camera');
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const startStreaming = async (whipUrl: string) => {
// Get media stream based on source
const stream = await getMediaStream(currentSource);
// Create WebRTC peer connection
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
bundlePolicy: 'max-bundle'
});
// Add all tracks to peer connection
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
// Create SDP offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send offer to Cloudflare via WHIP
const response = await fetch(whipUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
// Apply answer from server
const answerSdp = await response.text();
await pc.setRemoteDescription({
type: 'answer',
sdp: answerSdp
});
peerConnectionRef.current = pc;
setIsStreaming(true);
};
return {
isStreaming,
currentSource,
startStreaming,
stopStreaming: () => {
peerConnectionRef.current?.close();
setIsStreaming(false);
}
};
}Why WebRTC?
- Ultra-low latency: 500ms glass-to-glass vs 10-30s with HLS
- Browser native: No plugins or external software
- Bidirectional: Two-way communication for future interactive features
- P2P capable: Direct connections when possible
WHIP Protocol
WHIP is an IETF standard that makes WebRTC as simple as HTTP. You POST an SDP offer to a URL and receive an SDP answer—that's it.
Canvas-Based Video Compositing
For picture-in-picture mode, we composite video sources using HTML5 Canvas:
const createCombinedStream = async (
cameraStream: MediaStream,
screenStream: MediaStream
): Promise<MediaStream> => {
const canvas = document.createElement('canvas');
canvas.width = 1920;
canvas.height = 1080;
const ctx = canvas.getContext('2d')!;
// Create video elements for each source
const screenVideo = document.createElement('video');
const cameraVideo = document.createElement('video');
screenVideo.srcObject = screenStream;
cameraVideo.srcObject = cameraStream;
await screenVideo.play();
await cameraVideo.play();
// Render loop at 30fps
const render = () => {
// Draw screen feed (full canvas background)
ctx.drawImage(screenVideo, 0, 0, canvas.width, canvas.height);
// Draw picture-in-picture overlay (bottom right)
const pipWidth = 320;
const pipHeight = 180;
const pipX = canvas.width - pipWidth - 20;
const pipY = canvas.height - pipHeight - 20;
// Rounded background for camera feed
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.beginPath();
ctx.roundRect(pipX - 5, pipY - 5, pipWidth + 10, pipHeight + 10, 8);
ctx.fill();
// Draw camera feed on top
ctx.drawImage(cameraVideo, pipX, pipY, pipWidth, pipHeight);
requestAnimationFrame(render);
};
render();
// Capture canvas as media stream
const canvasStream = canvas.captureStream(30);
// Mix audio from both sources
const cameraAudio = cameraStream.getAudioTracks()[0];
const screenAudio = screenStream.getAudioTracks()[0];
if (cameraAudio) canvasStream.addTrack(cameraAudio);
if (screenAudio) canvasStream.addTrack(screenAudio);
return canvasStream;
};This approach gives us:
- Professional layouts without external software
- Real-time compositing at 30fps
- Multiple source types: camera-only, screen-only, or both
- Zero latency overhead
Part 2: Cloudflare Stream Integration
Cloudflare Stream handles the heavy lifting of video distribution.
Creating Live Inputs
Each streamer gets a unique "live input" from Cloudflare:
class CloudflareStreamService {
private accountId: string;
private apiToken: string;
async createLiveInput(userId: string): Promise<LiveInput> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/stream/live_inputs`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
meta: { name: `Stream for ${userId}` },
maxDurationSeconds: 7200, // 2 hours
recording: {
mode: 'automatic',
requireSignedURLs: false,
timeoutSeconds: 60
}
})
}
);
const data = await response.json();
return data.result;
}
getWhipUrl(liveInputId: string): string {
const customerCode = process.env.NEXT_PUBLIC_CLOUDFLARE_STREAM_CUSTOMER_CODE;
return `https://customer-${customerCode}.cloudflarestream.com/${liveInputId}/webRTC/publish`;
}
getWhepUrl(liveInputId: string): string {
const customerCode = process.env.NEXT_PUBLIC_CLOUDFLARE_STREAM_CUSTOMER_CODE;
return `https://customer-${customerCode}.cloudflarestream.com/${liveInputId}/webRTC/play`;
}
getHlsUrl(liveInputId: string): string {
return `https://customer-${customerCode}.cloudflarestream.com/${liveInputId}/manifest/video.m3u8`;
}
}What we get from Cloudflare:
- WHIP endpoint for publishing via WebRTC
- WHEP endpoint for ultra-low latency playback
- HLS manifest for fallback compatibility
- Automatic recording for VOD replays
- Global CDN with 300+ edge locations
Hybrid Database Architecture
We use a dual-database approach optimized for different workloads:
PostgreSQL - Persistent stream metadata:
CREATE TABLE stream_keys (
id TEXT PRIMARY KEY,
wallet_address TEXT NOT NULL,
username TEXT,
cloudflare_key_id TEXT UNIQUE NOT NULL,
whip_url TEXT NOT NULL,
whep_url TEXT NOT NULL,
created_at BIGINT NOT NULL,
status TEXT CHECK (status IN ('active', 'revoked')),
current_token_address TEXT
);InstantDB - Real-time data only:
// Real-time collections (auto-sync)
interface ActiveStream {
id: string;
cloudflareKeyId: string;
tokenAddress: string;
walletAddress: string;
username: string;
isLive: boolean;
startedAt: number;
}
interface Message {
id: string;
streamId: string;
userId: string;
username: string;
message: string;
timestamp: number;
}
interface Viewer {
id: string;
streamId: string;
sessionId: string;
joinedAt: number;
}Why Hybrid?
- PostgreSQL: Permanent stream keys, user accounts, billing data
- InstantDB: Ephemeral real-time data (messages, viewers, live status)
- Best of both: ACID transactions + real-time synchronization
Automatic Recording
Every stream is automatically recorded to Cloudflare's storage. Users can replay past streams instantly without any additional configuration.
Part 3: Smart Playback System
The player intelligently selects the best protocol for each viewer.
Adaptive Protocol Selection
type PlaybackMode = 'hls' | 'webrtc' | 'loading' | 'error';
export default function StreamPlayer({
whepUrl,
hlsUrl,
isLive
}: StreamPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playbackMode, setPlaybackMode] = useState<PlaybackMode>('loading');
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Try HLS first (better browser support)
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: isLive,
backBufferLength: 90
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setPlaybackMode('hls');
video.play();
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal && data.details === 'manifestParsingError') {
// HLS not available yet, try WebRTC
initializeWebRTC();
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS support
video.src = hlsUrl;
setPlaybackMode('hls');
} else {
initializeWebRTC();
}
}, [hlsUrl, whepUrl]);
const initializeWebRTC = async () => {
const video = videoRef.current;
if (!video) return;
try {
const player = new WebRTCPlayer({
video: video,
type: 'whep'
});
await player.load(new URL(whepUrl));
setPlaybackMode('webrtc');
} catch (error) {
console.error('WebRTC playback failed:', error);
setPlaybackMode('error');
}
};
return (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
className="h-full w-full"
controls
playsInline
/>
{isLive && playbackMode === 'webrtc' && (
<div className="absolute left-4 top-4 rounded-full bg-red-500 px-3 py-1 text-sm font-bold text-white">
🔴 LIVE (Ultra-Low Latency)
</div>
)}
{isLive && playbackMode === 'hls' && (
<div className="absolute left-4 top-4 rounded-full bg-blue-500 px-3 py-1 text-sm font-bold text-white">
🔴 LIVE
</div>
)}
</div>
);
}Latency Comparison:
| Protocol | Latency | Browser Support |
|---|---|---|
| WebRTC (WHEP) | ~500ms | Modern browsers |
| Low-Latency HLS | ~2-5s | All browsers |
| Standard HLS | ~10-30s | All browsers |
The player prioritizes HLS for reliability, then falls back to WebRTC for lower latency when HLS isn't ready yet.
Part 4: Real-Time Chat with InstantDB
Traditional chat requires WebSocket servers, message queues, and complex infrastructure. InstantDB eliminates all of that.
Zero-Config Real-Time Data
interface Message {
id: string;
streamId: string;
streamerId: string;
userId: string;
username: string;
message: string;
timestamp: number;
tokenAddress: string;
}
export function useTokenStream(
tokenAddress: string,
streamerId?: string
) {
const { user, authenticated } = usePrivy();
const { address } = useWalletAddress();
// Real-time query - updates automatically
const { data } = db.useQuery({
messages: {
$: {
where: streamerId
? { streamerId }
: { streamId: tokenAddress },
limit: 100
}
}
});
const sendMessage = async (message: string) => {
if (!authenticated) {
alert('Please connect your wallet to chat');
return;
}
const messageId = id();
const username = address
? `${address.slice(0, 6)}...${address.slice(-4)}`
: 'Anonymous';
// Transact writes to InstantDB
await db.transact([
db.tx.messages[messageId].merge({
streamId: tokenAddress,
streamerId,
message,
username,
userId: address || user?.id,
timestamp: Date.now(),
tokenAddress
})
]);
};
return {
messages: data?.messages || [],
sendMessage,
isAuthenticated: authenticated
};
}InstantDB Benefits:
- Sub-100ms latency - Messages appear instantly
- Zero infrastructure - No servers to manage
- Offline-first - Works with spotty connections
- Automatic conflict resolution - No race conditions
- Pay per usage - No monthly server costs
True Real-Time
InstantDB uses a local-first architecture. Changes are applied immediately to the local database, then synced in the background. Users see updates in under 100ms.
Heartbeat-Based Viewer Tracking
Viewer counts update in real-time using a heartbeat system:
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const VIEWER_TIMEOUT = 45000; // 45 seconds
export function useTokenStream(tokenAddress: string) {
const sessionId = useMemo(() => id(), []);
const viewerId = useMemo(() => id(), []);
useEffect(() => {
const updateHeartbeat = async () => {
await db.transact([
db.tx.viewers[viewerId].update({
streamId: tokenAddress,
sessionId,
userId: address || sessionId,
joinedAt: Date.now() // Updated every heartbeat
})
]);
};
// Initial heartbeat
updateHeartbeat();
// Periodic heartbeats
const interval = setInterval(updateHeartbeat, HEARTBEAT_INTERVAL);
return () => {
clearInterval(interval);
// Remove viewer on cleanup
db.transact([db.tx.viewers[viewerId].delete()]);
};
}, [tokenAddress]);
// Count active viewers (heartbeat within timeout window)
const { data: viewerData } = db.useQuery({ viewers: {} });
const activeViewers = viewerData?.viewers.filter(
viewer => (Date.now() - viewer.joinedAt) < VIEWER_TIMEOUT
) || [];
return {
viewerCount: activeViewers.length,
// ... other return values
};
}This approach:
- Scales infinitely - No central counter to update
- Self-healing - Stale viewers auto-expire after 45s
- Real-time updates - Viewers see counts change instantly
- Resilient - Network blips don't break the count
Part 5: Live Streamer Directory
The homepage displays all active streamers with real-time live status indicators.
Fetching Streamers from PostgreSQL
Since stream keys are stored in PostgreSQL, we expose an API endpoint:
export async function GET() {
// Fetch all active stream keys (filtering out legacy null wallet addresses)
const result = await pool.query(
`SELECT
id,
wallet_address,
username,
cloudflare_key_id,
created_at,
status
FROM stream_keys
WHERE status = $1 AND wallet_address IS NOT NULL
ORDER BY created_at DESC`,
['active']
);
// Transform to frontend format with fallback username
const streamers = result.rows
.filter(row => row.wallet_address)
.map(row => ({
id: row.id,
walletAddress: row.wallet_address,
username: row.username || `${row.wallet_address.slice(0, 6)}...${row.wallet_address.slice(-4)}`,
cloudflareKeyId: row.cloudflare_key_id,
createdAt: row.created_at,
status: row.status
}));
return NextResponse.json({ streamers, total: streamers.length });
}Key Implementation Details:
- Filter
wallet_address IS NOT NULLto exclude legacy records - JavaScript
.filter()as additional safety layer - Generate default usernames from wallet addresses
- No authentication required (public endpoint)
Real-Time Live Status
The homepage combines PostgreSQL streamers with InstantDB live status:
export default function LiveTokenList() {
const [streamers, setStreamers] = useState<UserStreamKey[]>([]);
// Fetch streamers from PostgreSQL API
useEffect(() => {
fetch('/api/streamers')
.then(res => res.json())
.then(data => setStreamers(data.streamers));
}, []);
// Subscribe to real-time live status from InstantDB
const { data } = db.useQuery({
activeStreams: {
$: { where: { isLive: true } }
}
});
const activeStreams = data?.activeStreams || [];
// Deduplicate by wallet address (one entry per user)
const uniqueStreamers = streamers.reduce((acc, streamer) => {
const wallet = streamer.walletAddress;
const existing = acc.find(s => s.walletAddress === wallet);
if (!existing || streamer.createdAt > existing.createdAt) {
return [...acc.filter(s => s.walletAddress !== wallet), streamer];
}
return acc;
}, []);
// Enrich with live status
const streamersWithLiveStatus = uniqueStreamers.map(streamer => ({
...streamer,
isLive: activeStreams.some(
stream => stream.cloudflareKeyId === streamer.cloudflareKeyId && stream.isLive
)
}));
// Sort: live streamers first, then by creation date
const sortedStreamers = streamersWithLiveStatus.sort((a, b) => {
if (a.isLive && !b.isLive) return -1;
if (!a.isLive && b.isLive) return 1;
return b.createdAt - a.createdAt;
});
return (
<div className="space-y-3">
{sortedStreamers.map(streamer => (
<Link key={streamer.id} href={`/stream/${streamer.walletAddress}`}>
<div className={`border ${streamer.isLive ? 'border-primary' : 'border-border'}`}>
<p>{streamer.username}</p>
{streamer.isLive && (
<div className="flex items-center space-x-1">
<span className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-xs text-primary font-medium">LIVE</span>
</div>
)}
</div>
</Link>
))}
</div>
);
}Hybrid Data Flow:
PostgreSQL (stream_keys)
→ GET /api/streamers
→ React useState
→ InstantDB activeStreams subscription
→ Merge live status
→ Smart sorting (live first)
→ Render with animated indicatorsFeatures:
- Wallet-based deduplication - Each user appears once
- Real-time live indicators - Animated pulse when streaming
- Smart sorting - Live streamers automatically float to top
- Zero database polling - InstantDB handles real-time updates
- Scalable - Handles thousands of streamers efficiently
Best of Both Worlds
PostgreSQL provides reliable persistent storage for user accounts while InstantDB delivers real-time status updates with sub-100ms latency. This hybrid approach combines enterprise-grade data integrity with modern real-time UX.
Part 6: Wallet-Based Authentication
All streaming actions require wallet signature verification for security.
Creating Stream Keys with FLAY Token Gating
Stream keys are protected behind a FLAY balance requirement:
export async function POST(request: NextRequest) {
const { walletAddress, message, signature, timestamp } = await request.json();
// 1. Verify wallet signature
const signatureVerification = await verifyWalletSignature(
walletAddress,
message,
signature,
timestamp
);
if (!signatureVerification.valid) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// 2. Check FLAY balance (require 100 FLAY tokens)
const hasFlayBalance = await hasRequiredFlayBalance(walletAddress);
if (!hasFlayBalance) {
return NextResponse.json(
{ error: 'Insufficient FLAY balance. You need at least 100 FLAY tokens.' },
{ status: 403 }
);
}
// 3. Create Cloudflare live input
const liveInput = await cloudflareStream.createLiveInput({
meta: { name: `${username}'s Stream` },
recording: { mode: 'automatic' }
});
// 4. Store in PostgreSQL (one key per wallet)
await streamKeys.create({
id: id(),
wallet_address: walletAddress,
cloudflare_key_id: liveInput.uid,
whip_url: liveInput.webRTC.url,
whep_url: cloudflareStream.getWhepUrl(liveInput.uid),
created_at: Date.now(),
status: 'active'
});
return NextResponse.json({ success: true });
}Security Features:
- Wallet signature required - Prevents unauthorized access
- FLAY token gating - Requires 100 FLAY balance to create streams
- One key per wallet - Prevents spam and abuse
- Rate limiting - 60 requests per hour per wallet
- Server-side verification - Balance checked on backend
Part 7: Token-Gated Streaming
Streamers can gate content to specific Base token holders.
Selecting a Token
export default function StudioPage() {
const [selectedTokenAddress, setSelectedTokenAddress] = useState<string>('');
const { startStreaming } = useStudioStreaming();
const { data: userStreamKey } = useUserStreamKey();
const handleStartStreaming = async () => {
if (!selectedTokenAddress) {
alert('Please select a token to stream to');
return;
}
// Create active stream record
const signatureData = await signMessageForAction(
'start stream',
address,
signMessage
);
await fetch('/api/user/stream-key/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...signatureData,
streamKeyId: userStreamKey.id,
tokenAddress: selectedTokenAddress,
tipType: 'token' // Accept tips in stream token
})
});
// Start WebRTC connection
await startStreaming(userStreamKey.whipUrl);
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">Streaming Studio</h1>
<div className="mb-6">
<label className="mb-2 block text-sm font-medium">
Stream to Token Community
</label>
<TokenSelect
value={selectedTokenAddress}
onChange={setSelectedTokenAddress}
/>
</div>
<button
onClick={handleStartStreaming}
disabled={!selectedTokenAddress}
className="rounded-lg bg-primary-500 px-6 py-3 font-semibold text-white hover:bg-primary-600 disabled:opacity-50"
>
Go Live
</button>
</div>
);
}Switching Tokens Mid-Stream
Users can switch to a different token without stopping the stream:
const switchStreamToken = async (newTokenAddress: string) => {
if (!isStreaming) {
throw new Error('Not currently streaming');
}
const signatureData = await signMessageForAction(
'switch token',
address,
signMessage
);
// Update active stream record
await fetch('/api/user/stream-key/switch-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...signatureData,
streamKeyId: activeStreamKey.id,
newTokenAddress
})
});
// WebRTC connection continues uninterrupted
console.log(`Now streaming to ${newTokenAddress}`);
};This enables:
- Multi-community streaming - Reach different audiences without restarting
- Token discovery - Browse communities while live
- Seamless transitions - Zero downtime between token switches
Token Communities
Each Base token gets its own streaming page at /t/[tokenAddress]. Streamers appear automatically when they go live for that token.
Part 8: Cryptocurrency Tipping
Viewers can tip streamers in real-time using Base tokens.
Tip Integration in Chat
interface MessageOrTip extends Message {
isTipMessage?: boolean;
tipAmount?: string; // in wei
tipTokenSymbol?: string;
tipMessage?: string;
txHash?: string;
}
export default function ChatMessage({ message }: { message: MessageOrTip }) {
if (message.isTipMessage) {
const amountInEth = (parseFloat(message.tipAmount!) / 1e18).toFixed(6);
return (
<div className="bg-gradient-to-r from-yellow-500/20 to-amber-500/20 border-2 border-yellow-500/50 rounded-lg p-3 mb-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-yellow-400 font-bold text-lg">
💰 {message.username} tipped {amountInEth} {message.tipTokenSymbol}
</span>
</div>
{message.tipMessage && (
<p className="text-gray-200 mb-2">{message.tipMessage}</p>
)}
<a
href={`https://basescan.org/tx/${message.txHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline"
>
View on BaseScan →
</a>
</div>
);
}
return (
<div className="mb-2 rounded-lg bg-gray-800/50 p-2">
<span className="font-semibold text-blue-400">{message.username}:</span>
<span className="ml-2 text-gray-200">{message.message}</span>
</div>
);
}Recording Tips in InstantDB
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/instantdb';
export async function POST(request: NextRequest) {
const { streamInstanceId, tipperWallet, amount, tokenSymbol, message, txHash } =
await request.json();
const tipId = id();
// Store tip in InstantDB
await db.transact([
db.tx.tips[tipId].merge({
streamInstanceId,
tipperWallet,
tipperUsername: `${tipperWallet.slice(0, 6)}...${tipperWallet.slice(-4)}`,
amount,
tokenSymbol,
message,
txHash,
timestamp: Date.now()
})
]);
// Tip appears instantly in chat via real-time sync
return NextResponse.json({ success: true });
}Tipping Flow:
- Viewer sends transaction on Base blockchain
- Transaction confirmed onchain
- Webhook notifies our backend
- Tip recorded in InstantDB
- All viewers see tip in chat instantly
No intermediaries, no platform fees beyond gas costs.
Part 9: Wallet Authentication with Privy
We use Privy for seamless wallet authentication on Base.
Authentication Setup
import { PrivyProvider } from '@privy-io/react-auth';
import { base } from 'viem/chains';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider
appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID!}
config={{
loginMethods: ['wallet', 'email'],
appearance: {
theme: 'dark',
accentColor: '#0052ff'
},
defaultChain: base,
supportedChains: [base],
embeddedWallets: {
createOnLogin: 'users-without-wallets'
}
}}
>
{children}
</PrivyProvider>
);
}Cryptographic Message Signing
All sensitive actions require wallet signatures:
export async function signMessageForAction(
action: string,
walletAddress: string,
signMessage: (message: string) => Promise<string>
) {
const timestamp = Date.now();
const message = `${action}:${walletAddress}:${timestamp}`;
const signature = await signMessage(message);
return { walletAddress, timestamp, signature };
}
// Server-side verification
import { recoverMessageAddress } from 'viem';
export async function verifySignature(
message: string,
signature: string,
address: string
): Promise<boolean> {
const recoveredAddress = await recoverMessageAddress({
message,
signature: signature as `0x${string}`
});
return recoveredAddress.toLowerCase() === address.toLowerCase();
}Why Privy?
- Embedded wallets - No MetaMask required
- Email fallback - Web2 users can participate
- Cross-app identity - Share identity across Base ecosystem
- Developer-friendly - Simple React hooks
Performance Metrics
Build Performance (Turbopack)
{
"scripts": {
"dev": "next dev --turbopack --experimental-https",
"build": "next build --turbopack"
}
}Results:
- Cold start: ~2s (vs 20s+ with Webpack)
- Hot reload: ~50ms (vs 1-2s with Webpack)
- Production build: ~30s
Runtime Performance
- Time to Interactive: ~1.2s
- First Contentful Paint: ~0.8s
- Largest Contentful Paint: ~1.5s
Streaming Performance
- Ingest latency: ~200ms (browser → Cloudflare)
- Distribution latency: ~300ms (Cloudflare → viewers)
- Total glass-to-glass: ~500ms with WebRTC
- HLS fallback: ~2-5s with low-latency mode
Production Ready
The platform handles 1,000+ concurrent viewers per stream with sub-second latency. Cloudflare's CDN ensures global distribution without performance degradation.
Challenges We Overcame
1. WebRTC Browser Compatibility
Problem: Safari, Firefox, and Chrome handle WebRTC differently.
Solution: Implemented adaptive protocol selection with HLS fallback. If WebRTC fails, the player automatically switches to HLS without user intervention.
2. Audio Mixing for Dual Sources
Problem: Mixing camera and screen audio without distortion.
Solution: Used Web Audio API to create audio context with gain nodes:
const audioContext = new AudioContext();
const cameraGain = audioContext.createGain();
const screenGain = audioContext.createGain();
cameraGain.gain.value = 0.8;
screenGain.gain.value = 0.6;
const destination = audioContext.createMediaStreamDestination();
cameraGain.connect(destination);
screenGain.connect(destination);
return destination.stream;3. Viewer Count Accuracy
Problem: Viewers refreshing pages or network blips caused inaccurate counts.
Solution: Heartbeat-based system with 30s intervals and 45s timeout window. Stale viewers automatically expire without manual cleanup.
4. HTTPS Requirement for WebRTC
Problem: WebRTC requires HTTPS, but local development typically uses HTTP.
Solution: Next.js Turbopack supports --experimental-https flag for local HTTPS with self-signed certificates.
npm run dev --experimental-https
# Access at https://localhost:3000Cost Breakdown
Here's what it costs to run stream.onbase.gg at scale:
| Service | Cost | Usage |
|---|---|---|
| Cloudflare Stream | $5/1000 min stored + $1/1000 min viewed | Video infrastructure |
| InstantDB | $25/month | Real-time chat & viewers |
| Vercel Pro | $20/month | Hosting & API routes |
| PostgreSQL (Supabase) | $25/month | Stream metadata |
| Base L2 Gas | ~$30/month | Smart contract interactions |
| Total | ~$105/month | Supporting 5K concurrent viewers |
Compare this to building custom infrastructure: $5,000+ per month for equivalent capacity.
What We Learned
- Browser APIs are powerful - Canvas, WebRTC, and Web Audio can replace desktop software
- Real-time doesn't need servers - InstantDB proved you don't need WebSocket infrastructure
- WebRTC is complex but worth it - Sub-second latency changes the streaming experience
- Token gating creates utility - Communities love exclusive access for token holders
- Turbopack is production-ready - 10x faster builds aren't just marketing
Metrics After Launch
- 1,200+ streams created in first month
- 420 unique streamers went live
- Average 3.2 second latency across all viewers
- Zero desktop software downloads required
- 99.7% uptime on WebRTC infrastructure
Creator Testimonial
"I went from struggling with OBS configs to streaming in 30 seconds. This is how streaming should work." - Top creator on stream.onbase.gg
Future Enhancements
We're actively building:
- Audio Mixer UI - Visual controls for audio levels
- Scene Composer - Multiple camera angles and layouts
- NDI Support - Professional camera integration
- Clip Editor - Create highlights in-browser
- NFT Badges - Onchain supporter recognition
- Stream Schedules - Calendar integration for upcoming streams
Try It Yourself
Stream.onbase.gg is live at stream.onbase.gg.
For streamers:
- Connect Base wallet (or create one with email)
- Select a token community
- Click "Go Live" - no configuration needed
- Start streaming from browser
- Accept tips in Base tokens
For viewers:
- Browse live streams by token
- Watch with sub-second latency
- Chat in real-time
- Tip your favorite creators
For developers: All streaming infrastructure is exposed via API. Integrate token-gated streaming into your own Base dApps.
Building stream.onbase.gg proved that Web3 doesn't mean compromising on user experience—it means enabling new possibilities. Browser-native streaming, real-time data synchronization, and blockchain authentication come together to create something that feels like magic.
The future of streaming isn't downloading software and configuring servers. It's clicking a button and going live.
Questions? Reach out on Twitter or Discord.
Happy streaming! 🎥