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.

Works on the Free planFree projects include 1:1 voice calls with 2,000 voice participant minutes a month, so you can follow this tutorial without a paid plan. Create a free project to get an App ID.

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-native

Autolinking 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',
};
Use user tokens, never your App SecretTest tokens expire, which is what you want. Your App Secret must never be built into a mobile app, because anyone can extract it from the APK.

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 roomJoined event, so the Call button only appears after it fires.
  • callUser rings the other user. On their phone, callInvite fires and they can acceptCall or rejectCall.
  • callState reports ringing, connecting, connected, rejected and ended, 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 start

In 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
  1. 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.
  2. On Alice's device, press Call bob.
  3. On Bob's device, press Accept.
  4. 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-android again (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:8081 and 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 alice and bob, 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.