livekit-expo

star 0

Set up LiveKit with Expo SDK for audio/video rooms using development builds and expo-dev-client

FutureAtoms By FutureAtoms schedule Updated 3/11/2026

name: livekit-expo description: Set up LiveKit with Expo SDK for audio/video rooms using development builds and expo-dev-client argument-hint: "" allowed-tools: Read, Write, Bash(npx expo, npm install, eas), Glob, Grep

LiveKit Expo Integration

Implement LiveKit in Expo apps: $ARGUMENTS

Expert Knowledge

You are a LiveKit Expo specialist with expertise in:

  • Expo development builds and expo-dev-client
  • Config plugins for native modules
  • EAS Build configuration
  • Audio/video permissions in Expo
  • Expo Router integration

Important Note

LiveKit is NOT compatible with Expo Go due to native WebRTC code requirements. You must use development builds via expo-dev-client.

Installation

# Install LiveKit packages
npm install @livekit/react-native @livekit/react-native-expo-plugin @livekit/react-native-webrtc @config-plugins/react-native-webrtc livekit-client

# Install expo-dev-client for development builds
npx expo install expo-dev-client

Configuration

app.json / app.config.js

{
  "expo": {
    "name": "My LiveKit App",
    "slug": "my-livekit-app",
    "plugins": [
      "@livekit/react-native-expo-plugin",
      "@config-plugins/react-native-webrtc"
    ],
    "ios": {
      "bundleIdentifier": "com.myapp.livekit",
      "infoPlist": {
        "NSCameraUsageDescription": "Camera access for video calls",
        "NSMicrophoneUsageDescription": "Microphone access for voice calls",
        "UIBackgroundModes": ["audio", "voip"]
      }
    },
    "android": {
      "package": "com.myapp.livekit",
      "permissions": [
        "CAMERA",
        "RECORD_AUDIO",
        "MODIFY_AUDIO_SETTINGS",
        "ACCESS_NETWORK_STATE",
        "INTERNET"
      ]
    }
  }
}

Dynamic Config (app.config.ts)

import { ExpoConfig, ConfigContext } from 'expo/config';

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: 'My LiveKit App',
  slug: 'my-livekit-app',
  plugins: [
    '@livekit/react-native-expo-plugin',
    '@config-plugins/react-native-webrtc',
  ],
  ios: {
    ...config.ios,
    bundleIdentifier: 'com.myapp.livekit',
    infoPlist: {
      NSCameraUsageDescription: 'Camera access for video calls',
      NSMicrophoneUsageDescription: 'Microphone access for voice calls',
      UIBackgroundModes: ['audio', 'voip'],
    },
  },
  android: {
    ...config.android,
    package: 'com.myapp.livekit',
    permissions: [
      'CAMERA',
      'RECORD_AUDIO',
      'MODIFY_AUDIO_SETTINGS',
      'ACCESS_NETWORK_STATE',
    ],
  },
});

Create Development Build

Local Development Build

# iOS Simulator
npx expo run:ios

# Android Emulator
npx expo run:android

# Physical iOS device
npx expo run:ios --device

EAS Build

# Install EAS CLI
npm install -g eas-cli

# Configure EAS
eas build:configure

# Create development build
eas build --profile development --platform ios
eas build --profile development --platform android

eas.json Configuration

{
  "cli": {
    "version": ">= 5.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      }
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {}
  }
}

Initialize SDK

// App.tsx or app/_layout.tsx (Expo Router)
import { registerGlobals } from '@livekit/react-native';

// Call before any LiveKit usage
registerGlobals();

Audio Session Setup

import { AudioSession } from '@livekit/react-native';
import { useEffect } from 'react';

export function useAudioSession() {
  useEffect(() => {
    const startAudio = async () => {
      try {
        await AudioSession.startAudioSession();
        console.log('Audio session started');
      } catch (error) {
        console.error('Failed to start audio session:', error);
      }
    };

    startAudio();

    return () => {
      AudioSession.stopAudioSession();
    };
  }, []);
}

Expo Router Integration

File Structure

app/
├── _layout.tsx           # Root layout with registerGlobals
├── index.tsx             # Home screen
├── room/
│   ├── _layout.tsx       # Room layout
│   └── [id].tsx          # Dynamic room screen
└── join.tsx              # Join room form

Root Layout

// app/_layout.tsx
import { Stack } from 'expo-router';
import { registerGlobals } from '@livekit/react-native';
import { useEffect } from 'react';

// Initialize LiveKit
registerGlobals();

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Home' }} />
      <Stack.Screen name="join" options={{ title: 'Join Room' }} />
      <Stack.Screen
        name="room/[id]"
        options={{
          headerShown: false,
          presentation: 'fullScreenModal',
        }}
      />
    </Stack>
  );
}

Room Screen

// app/room/[id].tsx
import { useLocalSearchParams, router } from 'expo-router';
import { SafeAreaView, View, StyleSheet } from 'react-native';
import {
  LiveKitRoom,
  useTracks,
  useLocalParticipant,
  VideoTrack,
  isTrackReference,
} from '@livekit/react-native';
import { AudioSession } from '@livekit/react-native';
import { Track } from 'livekit-client';
import { useEffect, useState } from 'react';

export default function RoomScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    // Start audio session
    AudioSession.startAudioSession();

    // Fetch token from your backend
    fetchToken(id).then(setToken);

    return () => {
      AudioSession.stopAudioSession();
    };
  }, [id]);

  if (!token) {
    return <LoadingScreen />;
  }

  return (
    <LiveKitRoom
      serverUrl={process.env.EXPO_PUBLIC_LIVEKIT_URL!}
      token={token}
      connect={true}
      audio={true}
      video={true}
      onDisconnected={() => router.back()}
    >
      <SafeAreaView style={styles.container}>
        <VideoGrid />
        <ControlBar />
      </SafeAreaView>
    </LiveKitRoom>
  );
}

function VideoGrid() {
  const tracks = useTracks([Track.Source.Camera]);

  return (
    <View style={styles.grid}>
      {tracks.map((trackRef) =>
        isTrackReference(trackRef) ? (
          <VideoTrack
            key={`${trackRef.participant.sid}-${trackRef.source}`}
            trackRef={trackRef}
            style={styles.video}
          />
        ) : null
      )}
    </View>
  );
}

function ControlBar() {
  const { localParticipant, isMicrophoneEnabled, isCameraEnabled } =
    useLocalParticipant();

  return (
    <View style={styles.controls}>
      <TouchableOpacity
        style={[styles.button, !isMicrophoneEnabled && styles.buttonOff]}
        onPress={() => localParticipant?.setMicrophoneEnabled(!isMicrophoneEnabled)}
      >
        <Ionicons
          name={isMicrophoneEnabled ? 'mic' : 'mic-off'}
          size={24}
          color="white"
        />
      </TouchableOpacity>

      <TouchableOpacity
        style={[styles.button, !isCameraEnabled && styles.buttonOff]}
        onPress={() => localParticipant?.setCameraEnabled(!isCameraEnabled)}
      >
        <Ionicons
          name={isCameraEnabled ? 'videocam' : 'videocam-off'}
          size={24}
          color="white"
        />
      </TouchableOpacity>

      <TouchableOpacity
        style={[styles.button, styles.leaveButton]}
        onPress={() => router.back()}
      >
        <Ionicons name="call" size={24} color="white" />
      </TouchableOpacity>
    </View>
  );
}

async function fetchToken(roomId: string): Promise<string> {
  const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ roomId }),
  });
  const { token } = await response.json();
  return token;
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#1a1a1a',
  },
  grid: {
    flex: 1,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },
  video: {
    width: '50%',
    aspectRatio: 16 / 9,
  },
  controls: {
    flexDirection: 'row',
    justifyContent: 'center',
    gap: 16,
    padding: 16,
  },
  button: {
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: '#374151',
    justifyContent: 'center',
    alignItems: 'center',
  },
  buttonOff: {
    backgroundColor: '#ef4444',
  },
  leaveButton: {
    backgroundColor: '#ef4444',
    transform: [{ rotate: '135deg' }],
  },
});

Environment Variables

# .env
EXPO_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
EXPO_PUBLIC_API_URL=https://your-api.com

Permission Handling

import * as MediaLibrary from 'expo-media-library';
import { Camera } from 'expo-camera';
import { Audio } from 'expo-av';

async function requestPermissions(): Promise<boolean> {
  const [cameraStatus] = await Camera.requestCameraPermissionsAsync();
  const [audioStatus] = await Audio.requestPermissionsAsync();

  return cameraStatus.granted && audioStatus.granted;
}

// In your component
const [hasPermissions, setHasPermissions] = useState(false);

useEffect(() => {
  requestPermissions().then(setHasPermissions);
}, []);

if (!hasPermissions) {
  return <PermissionRequest onGrant={() => setHasPermissions(true)} />;
}

Noise Cancellation (LiveKit Cloud)

import { useKrispNoiseFilter } from '@livekit/react-native';

function NoiseFilterToggle() {
  const { isEnabled, setEnabled, isSupported } = useKrispNoiseFilter();

  if (!isSupported) return null;

  return (
    <TouchableOpacity
      onPress={() => setEnabled(!isEnabled)}
      style={[styles.button, isEnabled && styles.buttonActive]}
    >
      <Ionicons name="volume-mute" size={24} color="white" />
      <Text>Noise Filter: {isEnabled ? 'ON' : 'OFF'}</Text>
    </TouchableOpacity>
  );
}

Testing

Local Development

# Start Expo development server
npx expo start --dev-client

# Or with tunnel for physical devices
npx expo start --dev-client --tunnel

Debugging Tips

  1. Check native logs: npx expo run:ios shows Xcode logs
  2. Use React Native Debugger for JS debugging
  3. Test on physical devices for accurate WebRTC behavior
  4. Monitor network in Chrome DevTools (when using debugger)

Common Issues

Issue Solution
"Native module not found" Use dev build, not Expo Go
Audio not working Call AudioSession.startAudioSession()
Camera black screen Check permissions in Settings
Build fails Clear cache: expo prebuild --clean

Best Practices

  1. Always use development builds for testing
  2. Handle audio session lifecycle properly
  3. Request permissions before accessing media
  4. Test on physical devices for production behavior
  5. Use EAS Build for distribution builds

Deliverables

For: $ARGUMENTS

Provide:

  1. Package installation
  2. Config plugin setup
  3. Development build commands
  4. Room component with Expo Router
  5. Permission handling
  6. Environment variable configuration
Install via CLI
npx skills add https://github.com/FutureAtoms/claude-skills-backup --skill livekit-expo
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator