Skip to content

Guide · Developer

How to Add Video Calling to a React Native App

Add 1:1 and group video calls to a React Native app with @voltkit-rtc/react-native: token endpoint, hooks, video tiles, speaker, background, reconnects.

Author
VoltKit
Published
Reading time
12 min read

This guide adds a video call to a React Native app with @voltkit-rtc/react-native, the React Native SDK of VoltKit RTC: a token endpoint on your backend, the SDK in a bare React Native app or an Expo development build, permissions, joining and publishing, video tiles, mute, camera and loudspeaker controls, calls that keep going in the background on Android, and reconnects. The same code serves a 1:1 call and a group call. The snippets follow the React Native SDK guide, which has the full reference and the whole call screen in one file. The SDK is tested on Android; iOS is a preview.

Before you start

You need a VoltKit account and an RTC project; the quickstart creates both and gives you the API base URL and secret. On your computer you need Node.js and Android Studio's SDK, and an Android phone with USB debugging or the Android emulator. A phone is better: the emulator's microphone records silence and it has no earpiece.

The SDK needs React Native 0.82 or newer with the New Architecture (VoltKit tests with 0.88.0), React 19.1 or newer, and Android 7.0 (minSdk 24) or newer. An Expo app needs Expo SDK 58 or newer.

Step 1: the token endpoint

The app never holds your API secret. Your backend makes two REST calls with it: PUT /v1/rooms/{roomId} creates the room or returns the live one, and POST /v1/rooms/{roomId}/tokens mints a token for one user with the role you decide. Put both behind your own login. In Node.js with Express 5 and the server SDK:

const express = require("express");
const { StreamClient } = require("@voltkit-rtc/server");

const rtc = new StreamClient({ baseUrl: process.env.VOLTKIT_RTC_URL, apiKey: process.env.VOLTKIT_RTC_SECRET });
const app = express();
app.use(express.json());

// POST /calls/:callId/token  (your auth middleware sets req.user)
app.post("/calls/:callId/token", requireUser, async (req, res) => {
  const call = await db.calls.find(req.params.callId);
  if (!call || ![call.callerId, call.calleeId].includes(req.user.id)) return res.sendStatus(404);
  await rtc.ensureRoom({ roomId: call.id, hostIdentity: call.callerId });
  const role = req.user.id === call.callerId ? "host" : "cohost";
  const { token, wsUrl, expiresAt } = await rtc.createToken({ roomId: call.id, identity: req.user.id, role });
  res.set("Cache-Control", "no-store").json({ token, wsUrl, expiresAt });
});

The caller gets host and the callee cohost, so both can publish, and anyone outside the call gets a 404. How tokens and roles work is on Authentication; the same endpoint in Spring Boot, Python, Go and PHP is in build a video call token server, and every option is on issue tokens from your backend.

Step 2: create the app

Either kind of React Native app works:

  • Expo: a development build, which is your own build of the app with its native code. Create the app with npx create-expo-app@latest, and see React Native with Expo for the details.
  • React Native without Expo: create it with npx @react-native-community/cli@latest init CallApp. The New Architecture is on by default.

Step 3: install and configure

npm install @voltkit-rtc/react-native @voltkit-rtc/react @voltkit-rtc/client react-native-webrtc

@voltkit-rtc/client, @voltkit-rtc/react and react-native-webrtc are peer dependencies, so the app holds one copy of each. Do not add another WebRTC package, such as a fork of react-native-webrtc: two copies of WebRTC in one app clash.

In an Expo app, add the SDK's config plugin to app.json, with expo-dev-client installed (npx expo install expo-dev-client). It declares the permissions and the call service:

{
  "expo": {
    "plugins": [
      [
        "@voltkit-rtc/react-native",
        {
          "cameraPermission": "Your camera is shown to the people in your call.",
          "microphonePermission": "Your microphone is heard by the people in your call."
        }
      ]
    ]
  }
}

In a React Native app without Expo, add the permissions and the call service to android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application>
  <!-- Inside your existing <application> element. -->
  <service
    android:name="com.voltkit.rtc.reactnative.VoltkitCallService"
    android:exported="false"
    android:foregroundServiceType="microphone|mediaPlayback" />
</application>

Then build the app, since the SDK has native code: npx expo run:android for Expo, npx react-native run-android without it. Nothing goes into MainApplication or MainActivity, and release builds need no R8 rules of your own.

TypeScript needs nothing added in either kind of app: the SDK's streams and tracks are typed by shape (MediaStreamLike, MediaStreamTrackLike), so step 6's local.stream?.getVideoTracks()[0] compiles with @react-native/typescript-config as it comes, without the DOM library.

Step 4: permissions

publish() asks for the microphone and camera before it opens them, so there is nothing to call first. A refusal rejects with media-unavailable, and data says which permission and whether it is denied (ask again later) or blocked (only the app's settings can grant it):

import { Linking } from "react-native";
import { RpcError } from "@voltkit-rtc/client";

type PermissionProblem = { permission?: "camera" | "microphone"; status?: "denied" | "blocked" };

export function explainPublishError(err: unknown, show: (text: string, fix?: () => void) => void) {
  const problem =
    err instanceof RpcError && err.code === "media-unavailable" ? (err.data as PermissionProblem | undefined) : undefined;
  if (problem?.status === "blocked") show(`Allow the ${problem.permission} in Settings.`, () => void Linking.openSettings());
  else if (problem?.permission) show(`The ${problem.permission} is needed for the call.`);
  else show("Could not start the camera and microphone.");
}

To ask before the call instead, requestMediaPermissions(["camera", "microphone"]) shows the system's dialogs and resolves to each one's status. On Android 13 and later, ask for "notifications" too, so the call's notification shows in the notification shade; the call works without it.

Step 5: join and publish

Ask your endpoint for a token right before joining, because a token lives ten minutes:

// token.ts
export type Credentials = { token: string; wsUrl: string };

export async function fetchCredentials(callId: string, accessToken: string): Promise<Credentials> {
  const res = await fetch(`https://api.example.com/calls/${encodeURIComponent(callId)}/token`, {
    method: "POST",
    headers: { Authorization: `Bearer ${accessToken}` }, // your app's sign-in, never the RTC secret
  });
  if (!res.ok) throw new Error(`Could not get a token (${res.status}).`);
  return (await res.json()) as Credentials;
}

Then render VoltkitProvider with the token: it makes the client, connects it, takes the phone's call audio and starts the call service, and lets everything go when it unmounts. Hosts and co-hosts publish with useLocalMedia().publish():

import { useState } from "react";
import { Button, Text, View } from "react-native";
import { VoltkitProvider, useLocalMedia, useRoom } from "@voltkit-rtc/react-native";
import { fetchCredentials, type Credentials } from "./token";

export function JoinCall({ callId, accessToken }: { callId: string; accessToken: string }) {
  const [credentials, setCredentials] = useState<Credentials | null>(null);
  const [notice, setNotice] = useState<string | null>(null);

  if (!credentials) {
    return (
      <View>
        {notice && <Text>{notice}</Text>}
        <Button
          title="Join the call"
          onPress={() => fetchCredentials(callId, accessToken).then(setCredentials, (err: Error) => setNotice(err.message))}
        />
      </View>
    );
  }
  return (
    <VoltkitProvider wsUrl={credentials.wsUrl} token={credentials.token} onClosed={() => setCredentials(null)}>
      <PublishButton />
    </VoltkitProvider>
  );
}

function PublishButton() {
  const { state, info } = useRoom();
  const local = useLocalMedia();
  if (state !== "connected") return <Text>Joining…</Text>;
  const canPublish = info?.role === "host" || info?.role === "cohost";
  if (!canPublish || local.stream) return null;
  return <Button title="Start camera and microphone" onPress={() => void local.publish()} />;
}

Remote sound plays by itself: react-native-webrtc plays every microphone in the call, so only video needs a component. Roles come from the token: host and cohost publish, viewer watches.

Step 6: video tiles

useRemoteTracks({ kind: "video" }) lists every video the call receives, and VideoTrack shows one. Pass producerId for remote videos: each is then received at the size of its tile, and paused while the tile is hidden (visible={false}) or the app is in the background. Your own camera comes from useLocalMedia().stream, mirrored while the front camera is in use:

import { StyleSheet, View } from "react-native";
import { VideoTrack, useLocalMedia, useRemoteTracks } from "@voltkit-rtc/react-native";

export function Tiles() {
  const local = useLocalMedia();
  const videos = useRemoteTracks({ kind: "video" });
  const camera = local.stream?.getVideoTracks()[0];
  return (
    <View style={styles.tiles}>
      {camera && <VideoTrack track={camera} mirror={local.facingMode === "user"} style={styles.tile} />}
      {videos.map((t) => (
        <VideoTrack key={t.producerId} track={t.track} producerId={t.producerId} objectFit="cover" style={styles.tile} />
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  tiles: { flex: 1, flexDirection: "row", flexWrap: "wrap" },
  tile: { width: "50%", aspectRatio: 3 / 4 },
});

Key tiles by producerId, not by identity: one person can send a camera and a screen share at once.

Step 7: mute, camera and loudspeaker

Each control is one call, and every component that uses the hooks follows it:

import { Button, View } from "react-native";
import { useAudioOutput, useLocalMedia, useRoom } from "@voltkit-rtc/react-native";

export function Controls({ onProblem }: { onProblem: (err: Error) => void }) {
  const local = useLocalMedia();
  const audio = useAudioOutput();
  const { leave } = useRoom();
  return (
    <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
      <Button title={local.micEnabled ? "Mute" : "Unmute"} onPress={() => local.setMicEnabled(!local.micEnabled).catch(onProblem)} />
      <Button title={local.camEnabled ? "Camera off" : "Camera on"} onPress={() => local.setCamEnabled(!local.camEnabled).catch(onProblem)} />
      <Button title="Flip" onPress={() => local.switchCamera().catch(onProblem)} />
      {audio.supported && (
        <Button
          title={audio.speakerphone ? "Earpiece" : "Loudspeaker"}
          onPress={() => audio.setSpeakerphoneOn(!audio.speakerphone).catch(onProblem)}
        />
      )}
      <Button title="Leave" onPress={leave} />
    </View>
  );
}
  • switchCamera() turns the camera on the same track, so the others see no gap, and resolves true when the front camera is in use.
  • Calls start on the loudspeaker, and a Bluetooth or wired headset wins while it is connected. For a voice call held to the ear, give the provider audio={{ speakerphone: false }} and publish with publish({ video: false }). A picker of every output is in Audio routing.

Step 8: calls in the background on Android

Without a foreground service, Android cuts a call a few seconds after the app goes to the background or the screen turns off, and silences its microphone. The SDK's call service keeps it going: the Expo plugin and the manifest block in step 3 declare it, and the provider starts it when the call connects, while the app is visible, and stops it when the call ends.

While it runs, Android shows a notification with a Leave button (on Android 13 and later, once the user allows notifications; the service runs either way). In the background, received videos pause at the server and the camera switches off; the microphone keeps sending and the sound keeps playing. Everything comes back when the app returns.

An app that targets Android 14 or later declares the service's types, microphone and media playback, in Play Console under App content, with a short video of a call going on from the home screen. Calls in the background has the details and what to write.

Step 9: errors, reconnects and leaving

The SDK reconnects by itself with backoff, and publishes your camera and microphone again when it is back. While useRoom().state is reconnecting, keep the call screen and show a banner: it is not the end of the call. When the session does end, the provider's onClosed says why:

import type { ClosedReason } from "@voltkit-rtc/react-native";

export const ENDED: Record<ClosedReason, string> = {
  left: "You left the call.",
  kicked: "You were removed from the call.",
  "room-closed": "The call has ended.",
  replaced: "You joined this call from another device.",
  "gave-up": "The connection was lost.",
};

A closed session stays closed: to join again, mount a new provider (a new key) with a fresh token. When the join itself fails, useRoom().error holds an RpcError: bad-token means fetch a new token and try once more; no-room or room-ended means the call is over; kicked means this person was removed; participant-quota means the room is full; quota-exhausted and the daily or monthly limit codes mean the project cannot take calls right now. The call screen puts every step together, with retry, and Errors lists every code.

Step 10: test on a phone and the emulator

  • A release build. Test the build you ship: npx expo run:android --variant release or npx react-native run-android --mode release. VoltKit tests the SDK this way, R8-minified, with Hermes.
  • A phone. Join the same room from the phone and from a second device (another phone, or a web page with the JavaScript SDK), and check both see and hear each other. Then try the loudspeaker and the earpiece, a headset, the home button and the screen turned off in the middle of the call, and mobile data instead of Wi-Fi.
  • The emulator. It shows video and plays sound, but its microphone records silence and it has only the loudspeaker: check receiving there, and publishing on a phone.

If something does not work, the troubleshooting entries are named after the errors you will see.

Group calls and roles

A group call is the same room with more people. Give everyone who should be on camera host or cohost; give people who only watch viewer, and they receive without publishing. Use a room id per call that nobody can guess, such as a random UUID. Hosts can also mute, remove and promote people from the app with the client from useVoltkitClient(); the moderation calls are on Rooms, and an audio room with a stage is in Group audio rooms.

What a 1:1 call costs

Two people on video for an hour is 120 participant-minutes:

A 60-minute 1:1 Full HD video call

2 people × 60 minutes = 120 participant-minutes

120 participant-minutes × 99 ÷ 1,000 = 11.88 credits, ₹11.88 (≈ $0.13) at today's rate

Full HD Video + HD Audio: 99 credits per 1,000 participant-minutes. Billed per second, so a 7½-minute call costs 7½ minutes.

Prices are in Indian rupees; dollar figures are approximate, at ₹95 per US dollar on 24 October 2026, for orientation only. You are billed in rupees.

It is billed per second from your prepaid wallet, and tokens and API calls cost nothing. Work out other sessions with the participant-minute calculator.

iOS

iOS is a preview in the React Native SDK: the JavaScript is written for react-native-webrtc's iOS code, but VoltKit has not compiled or run it on an iPhone, and audio routing, the call service and screen sharing are Android only. The Flutter guide builds the same call with voltkit_rtc, which supports Android and iOS.

Where to go next

Check a token while you develop

In code, treat the token as opaque and read its expiry from expiresAt in your endpoint's reply. While you develop, the JWT Decoder shows a test token's expiry on your own device and never sends it anywhere:

Inspect a test token with theJWT Decoder

Use test tokens only: a token is a credential for a room, and production tokens belong nowhere but the app that asked for them.

Frequently asked questions

How do I add video calling to a React Native app?

Install @voltkit-rtc/react-native with @voltkit-rtc/react, @voltkit-rtc/client and react-native-webrtc, and build the app again. Your backend mints a token; in the app, wrap the call screen in VoltkitProvider with the token and wsUrl, call publish() from useLocalMedia, and show each remote video from useRemoteTracks with VideoTrack.

Does it work with Expo?

Yes, in a development build: add the SDK's config plugin to app.json and build with npx expo run:android or EAS Build. Expo Go cannot load the native WebRTC code, and the SDK says it needs a development build instead of crashing.

Does it work on iOS?

Tested on Android (emulator, API 37.1); iOS in preview; earpiece and Bluetooth routing tested in unit tests, not on hardware. On iOS the JavaScript is written for react-native-webrtc's iOS code, but VoltKit has not compiled or run it on an iPhone, and audio routing, the background call service and screen sharing are Android only.

Where does the token come from?

From your backend. It calls the VoltKit RTC API with your API secret and returns token and wsUrl to the app. The app sends your own login to your backend, never the secret.

Does a call keep going when the app is in the background?

On Android, yes, when the app declares the SDK's call service, which the Expo plugin does by default: a foreground service with an ongoing notification (on Android 13 and later it shows once the user allows notifications), started when the call connects. The camera switches off in the background and the microphone keeps sending. Without the service, Android cuts the call a few seconds after the app leaves the screen.

How do I switch between the loudspeaker and the earpiece?

useAudioOutput() gives the outputs and setSpeakerphoneOn(on). Calls start on the loudspeaker; for a voice call held to the ear, set speakerphone to false in the provider's audio prop. A Bluetooth or wired headset wins while it is connected, and no Bluetooth permission is needed.

How is a 1:1 video call billed?

Per participant-second at the rate of the media used: two people on video for an hour is 120 participant-minutes at the video rate, which already includes HD audio. API calls and tokens are not billed.

← Back to Guides

You might also like

Something useful from another corner of VoltKit.