REACT NATIVE • WEBRTC • STEP-BY-STEP
How to add voice calling to a React Native app
Build a one-to-one voice call in React Native with the RTCExpress SDK from npm. In about 15 minutes you will have two Android phones calling each other, with accept, reject, mute and hang up, and no WebRTC, TURN or signaling servers to run yourself.
1. What you need
- A working React Native Android setup (Node.js, JDK 17 and Android Studio). React Native 0.73 or newer.
- Two Android devices, or one Android phone and an Android emulator, on Android 7.0 (API 24) or newer.
- A free RTCExpress project. Its App ID is in the customer console.
Android is the supported platform for the React Native SDK today. iOS sources are included but are still experimental and not yet verified on devices. The SDK is a native module, so it does not run in Expo Go; use a React Native CLI project as shown below.
2. Create an app and install the SDK
Create a new React Native project and add rtcexpress-react-native:
npx @react-native-community/cli@latest init VoiceCallDemo
cd VoiceCallDemo
npm install rtcexpress-react-nativeAutolinking adds the native module, so you don't edit Gradle files or AndroidManifest.xml. The package already declares the internet and microphone permissions, and new projects already use a minimum SDK of 24. Your app only has to ask the user for microphone access at runtime, which the code below does.
3. Create two test tokens
Log in to your customer console → Token tools and create two short-lived tokens in the same project and room:
- User ID alice, room ID voice-test.
- User ID bob, room ID voice-test.
Create config.ts next to App.tsx and paste in your App ID and both tokens:
// Test values from the console's Token tools.
// In a real app, fetch the token from your own backend. Never ship an App Secret.
export const APP_ID = 'YOUR_APP_ID';
export const ROOM_ID = 'voice-test';
export const TOKENS: Record<'alice' | 'bob', string> = {
alice: 'ALICE_TOKEN',
bob: 'BOB_TOKEN',
};4. Write the call screen
Replace App.tsx with this. It is the whole app: one screen where you pick a user, connect, join a room, and place or answer a call.
import React, { useEffect, useRef, useState } from 'react';
import { Button, PermissionsAndroid, SafeAreaView, StyleSheet, Text, View } from 'react-native';
import RTCExpress from 'rtcexpress-react-native';
import { APP_ID, ROOM_ID, TOKENS } from './config';
type User = 'alice' | 'bob';
type CallStatePayload = { state: string; peerUserId: string };
type ErrorPayload = { message: string };
export default function App() {
const rtc = useRef(new RTCExpress()).current;
const [me, setMe] = useState<User | null>(null);
const [status, setStatus] = useState('Pick a user');
const [joined, setJoined] = useState(false);
const [incoming, setIncoming] = useState(false);
const [inCall, setInCall] = useState(false);
const [muted, setMuted] = useState(false);
const peer: User = me === 'alice' ? 'bob' : 'alice';
useEffect(() => {
rtc.on('roomJoined', () => {
setJoined(true);
setStatus(`Joined ${ROOM_ID}`);
});
rtc.on('callInvite', () => setIncoming(true));
rtc.on('callState', (p) => {
const { state } = p as CallStatePayload;
setStatus(`Call ${state}`);
const active = state === 'ringing' || state === 'connecting' || state === 'connected';
setInCall(active);
if (state !== 'ringing') setIncoming(false);
if (!active) setMuted(false);
});
rtc.on('error', (p) => setStatus(`Error: ${(p as ErrorPayload).message}`));
return () => rtc.destroy();
}, [rtc]);
async function connect(user: User) {
const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);
if (result !== PermissionsAndroid.RESULTS.GRANTED) {
setStatus('Allow microphone access to make calls');
return;
}
setMe(user);
setStatus('Connecting...');
try {
await rtc.init({
serverUrl: 'https://api.devsquarelabs.com',
appId: APP_ID,
userId: user,
token: TOKENS[user],
mediaMode: 'p2p', // 1:1 calls on the Free plan are peer-to-peer
});
rtc.joinRoom(ROOM_ID);
} catch (e) {
setStatus(`Error: ${(e as Error).message}`);
}
}
function toggleMute() {
rtc.muteMicrophone(!muted);
setMuted(!muted);
}
return (
<SafeAreaView style={styles.screen}>
<Text style={styles.title}>{me ? `Voice call: ${me}` : 'Voice call demo'}</Text>
<Text style={styles.status}>{status}</Text>
<View style={styles.buttons}>
{!me && (
<>
<Button title="I am Alice" onPress={() => connect('alice')} />
<Button title="I am Bob" onPress={() => connect('bob')} />
</>
)}
{joined && !inCall && <Button title={`Call ${peer}`} onPress={() => rtc.callUser(peer)} />}
{incoming && (
<>
<Button title="Accept" onPress={() => rtc.acceptCall()} />
<Button title="Reject" color="#b42318" onPress={() => rtc.rejectCall()} />
</>
)}
{inCall && !incoming && (
<>
<Button title={muted ? 'Unmute' : 'Mute'} onPress={toggleMute} />
<Button title="End call" color="#b42318" onPress={() => rtc.endCall()} />
</>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 22, fontWeight: '600', textAlign: 'center' },
status: { marginTop: 12, marginBottom: 24, textAlign: 'center' },
buttons: { gap: 12 },
});How it works
- init connects to RTCExpress with a short-lived user token.
mediaMode: 'p2p'sends audio directly between the two phones, through a hosted TURN relay when a direct path isn't possible. This is how 1:1 calls work on the Free plan. - joinRoom asks to join. Membership is confirmed by the
roomJoinedevent, so the Call button only appears after it fires. - callUser rings the other user. On their phone,
callInvitefires and they can acceptCall or rejectCall. - callState reports
ringing,connecting,connected,rejectedandended, which drive the buttons. - destroy runs when the screen unmounts. It closes the connection and removes the event listeners.
5. Run it on two devices
Connect both devices and list them with adb devices. Start Metro in one terminal:
npx react-native startIn a second terminal, install the app on each device:
npx react-native run-android --deviceId FIRST_DEVICE_ID
npx react-native run-android --deviceId SECOND_DEVICE_ID- On the first device press I am Alice, and on the second press I am Bob. Allow microphone access and wait for Joined voice-test.
- On Alice's device, press Call bob.
- On Bob's device, press Accept.
- Both screens should show Call connected. Talk, then try Mute and End call.
Two phones next to each other will echo; use headphones, or move apart. Want to hear the quality first without building anything? Open the live demo on your phone and laptop.
Troubleshooting
- "rtcexpress-react-native doesn't seem to be linked": the native module isn't in the build. Stop the app and run
npx react-native run-androidagain (a Metro reload is not enough), and don't use Expo Go. - "Unable to load script" on one device: that device can't reach Metro. Run
adb -s DEVICE_ID reverse tcp:8081 tcp:8081and reload. - Error after connecting: check the App ID, that each device uses its own user, and that the tokens haven't expired. Create new tokens if in doubt.
- Call button never appears: both tokens must be for the same room,
voice-test. - No incoming call: the user IDs in the tokens must be exactly
aliceandbob, and both devices must be connected. - Connected but no sound: check microphone permission in Android settings and the media volume. Emulators need the host microphone enabled in their extended controls.
6. Before you ship
Replace config.ts with a call to your own backend. Your server signs the user in, checks they may join the room, and requests a short-lived token from POST https://api.devsquarelabs.com/v1/token with your App ID and App Secret. The App Secret stays on the server.
Then test on real devices across Wi-Fi and mobile data, with denied permissions, rejected calls, expired tokens and an app in the background. This tutorial shows foreground 1:1 voice calling; ringing a phone while the app is closed needs push notifications (the SDK includes an RTCPushClient helper for registering devices).
React Native voice calling questions
Do I need to run my own WebRTC, TURN or signaling server?
No. RTCExpress hosts signaling, TURN relays and media servers. The React Native SDK connects to them with the token your backend issues.
Can I add video to the same app?
Yes. Call rtc.callUser(peer, true), ask for camera permission too, and show the streams with the RTCVideoView component (Android). Video calling is included in the Pro plan.
Does it work with Expo?
Not in Expo Go, because the SDK includes native code. Use a React Native CLI project. Expo development builds have not been tested yet.
How are voice minutes counted?
In participant minutes: a two-person call lasting ten minutes uses twenty. The Free plan includes 2,000 a month and Starter includes 30,000. See pricing.