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
- Check native logs:
npx expo run:iosshows Xcode logs - Use React Native Debugger for JS debugging
- Test on physical devices for accurate WebRTC behavior
- 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
- Always use development builds for testing
- Handle audio session lifecycle properly
- Request permissions before accessing media
- Test on physical devices for production behavior
- Use EAS Build for distribution builds
Deliverables
For: $ARGUMENTS
Provide:
- Package installation
- Config plugin setup
- Development build commands
- Room component with Expo Router
- Permission handling
- Environment variable configuration