Learn how to upload videos from React Native devices or ingest videos from URLs for AI-generated content workflows.
There are two primary ways to get videos into Mux from a React Native application:
This guide covers both approaches and helps you choose the right one for your use case.
| Method | Use Case | React Native Role | Backend Required | User Experience |
|---|---|---|---|---|
| Direct Upload | User-generated content (camera, library) | High - handles file upload | Yes - generates upload URL | Shows upload progress |
| Upload from URL | AI-generated videos, pre-hosted content | Low - just displays result | Yes - creates asset | Background process |
Use direct upload when:
Use upload from URL when:
For apps that use on-demand generative AI video, upload from URL is the right choice since videos are generated by AI services and returned as URLs.
Direct uploads allow users to upload videos directly from their React Native app to Mux without the file touching your backend servers.
User Device → Your Backend (generate upload URL) → Mux
↓
Upload URL returned
↓
User Device → Mux (upload file directly)
↓
Mux processes video
↓
Webhook → Your Backend (asset ready)Your backend must generate a signed upload URL using the Mux API:
// Backend: Node.js + Mux SDK
import Mux from '@mux/mux-node';
const mux = new Mux({
tokenId: process.env.MUX_TOKEN_ID,
tokenSecret: process.env.MUX_TOKEN_SECRET,
});
// API endpoint: POST /api/generate-upload-url
export async function generateUploadUrl(req, res) {
try {
const upload = await mux.video.uploads.create({
cors_origin: '*', // Or specify your app's origin
new_asset_settings: {
playback_policies: ['public'],
video_quality: "basic"
},
});
res.json({
uploadUrl: upload.url,
uploadId: upload.id,
});
} catch (error) {
console.error('Failed to create upload URL:', error);
res.status(500).json({ error: 'Failed to generate upload URL' });
}
}Never expose your Mux API credentials in your React Native app. Always generate upload URLs from your backend.
Use Expo Camera or ImagePicker to get a video file:
import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';
export function useVideoPicker() {
const [videoUri, setVideoUri] = useState<string | null>(null);
const pickVideo = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
allowsEditing: true,
quality: 1,
});
if (!result.canceled) {
setVideoUri(result.assets[0].uri);
}
};
const recordVideo = async () => {
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
allowsEditing: true,
quality: 1,
});
if (!result.canceled) {
setVideoUri(result.assets[0].uri);
}
};
return { videoUri, pickVideo, recordVideo };
}Upload the video file using Expo FileSystem:
import * as FileSystem from 'expo-file-system';
import { useState } from 'react';
interface UploadResult {
uploadId: string;
assetId?: string;
}
export function useVideoUpload() {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const uploadVideo = async (videoUri: string): Promise<UploadResult | null> => {
setUploading(true);
setUploadProgress(0);
setError(null);
try {
// Step 1: Get upload URL from your backend
const response = await fetch('https://your-api.com/generate-upload-url', {
method: 'POST',
});
const { uploadUrl, uploadId } = await response.json();
// Step 2: Upload video file to Mux with progress tracking
const uploadTask = FileSystem.createUploadTask(
uploadUrl,
videoUri,
{
httpMethod: 'PUT',
uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
},
(uploadProgress) => {
const progress = uploadProgress.totalBytesSent / uploadProgress.totalBytesExpectedToSend;
setUploadProgress(Math.round(progress * 100));
}
);
const uploadResponse = await uploadTask.uploadAsync();
if (!uploadResponse || uploadResponse.status !== 200) {
throw new Error('Upload failed');
}
setUploading(false);
return { uploadId };
} catch (err) {
console.error('Upload error:', err);
setError('Failed to upload video');
setUploading(false);
setUploadProgress(0);
return null;
}
};
return { uploadVideo, uploading, uploadProgress, error };
}The video asset won't be ready immediately after upload. You'll need to:
video.asset.ready webhook on your backendSee the async processing guide for implementation details.
import React, { useState } from 'react';
import {
View,
TouchableOpacity,
Text,
ActivityIndicator,
StyleSheet,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
export default function VideoUploader() {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadId, setUploadId] = useState<string | null>(null);
const selectAndUploadVideo = async () => {
// Step 1: Select video
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
quality: 1,
});
if (result.canceled) return;
const videoUri = result.assets[0].uri;
setUploading(true);
setUploadProgress(0);
try {
// Step 2: Get upload URL
const response = await fetch('https://your-api.com/generate-upload-url', {
method: 'POST',
});
const { uploadUrl, uploadId } = await response.json();
// Step 3: Upload to Mux with progress tracking
const uploadTask = FileSystem.createUploadTask(
uploadUrl,
videoUri,
{
httpMethod: 'PUT',
uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
},
(progress) => {
const percentage = progress.totalBytesSent / progress.totalBytesExpectedToSend;
setUploadProgress(Math.round(percentage * 100));
}
);
await uploadTask.uploadAsync();
setUploadId(uploadId);
setUploading(false);
// Video is now processing - see async processing guide
// for how to get notified when it's ready
} catch (error) {
console.error('Upload failed:', error);
setUploading(false);
setUploadProgress(0);
}
};
return (
<View style={styles.container}>
{uploading ? (
<View style={styles.uploadingContainer}>
<ActivityIndicator size="large" />
<Text style={styles.progressText}>Uploading: {uploadProgress}%</Text>
</View>
) : uploadId ? (
<Text style={styles.text}>
Video uploaded! Processing...
</Text>
) : (
<TouchableOpacity style={styles.button} onPress={selectAndUploadVideo}>
<Text style={styles.buttonText}>Select Video</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 20,
alignItems: 'center',
},
uploadingContainer: {
alignItems: 'center',
},
button: {
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
text: {
fontSize: 16,
},
progressText: {
marginTop: 10,
fontSize: 16,
color: '#666',
},
});This approach is ideal when videos are generated by AI services or already hosted elsewhere. The video never touches the React Native app - your backend handles everything.
User submits prompt → Your Backend → AI Service (Fal.ai, Runway, etc.)
↓
AI returns video URL
↓
Your Backend → Mux (create asset from URL)
↓
Mux ingests & processes
↓
Webhook → Your Backend (asset ready)
↓
Realtime DB → React Native AppYour backend receives the AI-generated video URL and creates a Mux asset:
// Backend: Node.js + Mux SDK
import Mux from '@mux/mux-node';
const mux = new Mux({
tokenId: process.env.MUX_TOKEN_ID,
tokenSecret: process.env.MUX_TOKEN_SECRET,
});
// API endpoint: POST /api/create-video-from-url
export async function createVideoFromUrl(req, res) {
const { videoUrl, userId, prompt } = req.body;
try {
// Create Mux asset from URL
const asset = await mux.video.assets.create({
input: [{ url: videoUrl }],
playback_policies: ['public'],
video_quality: "basic"
});
// Store in your database
await db.videos.create({
id: generateId(),
userId,
prompt,
muxAssetId: asset.id,
status: 'processing', // Will be updated via webhook
createdAt: new Date(),
});
res.json({
videoId: video.id,
assetId: asset.id,
status: 'processing',
});
} catch (error) {
console.error('Failed to create asset:', error);
res.status(500).json({ error: 'Failed to create video asset' });
}
}The video URL must be publicly accessible. Mux will fetch the video file from that URL to ingest it.
When Mux finishes processing, it sends a webhook to your backend:
// Backend: Webhook handler
// check the mux-node-sdk docs for details
// https://github.com/muxinc/mux-node-sdk/blob/master/api.md#webhooks
const mux = new Mux();
// API endpoint: POST /api/webhooks/mux
export async function handleMuxWebhook(req, res) {
const webhookSecret = process.env.MUX_WEBHOOK_SECRET;
const signature = req.headers['mux-signature'];
// Verify webhook signature
try {
mux.webhooks.verifySignature(req.body, req.headers, webhookSecret);
} catch (error) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
const event = req.body;
// Handle video.asset.ready
if (event.type === 'video.asset.ready') {
const { id, playback_ids, duration } = event.data;
// Update database
await db.videos.update({
where: { muxAssetId: id },
data: {
status: 'ready',
playbackId: playback_ids[0].id,
duration,
},
});
// Video is now ready to play!
// Your realtime database will notify the React Native app
}
// Handle video.asset.errored
if (event.type === 'video.asset.errored') {
const { id } = event.data;
await db.videos.update({
where: { muxAssetId: id },
data: {
status: 'failed',
error: 'Video processing failed',
},
});
}
res.json({ received: true });
}Always verify webhook signatures to ensure requests are actually from Mux. See the webhooks guide for details.
Your React Native app doesn't handle the upload - it just waits for the video to be ready:
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { supabase } from './supabase'; // or Firebase, etc.
import { useVideoPlayer, VideoView } from 'expo-video';
interface VideoGenerationProps {
videoId: string;
}
export default function VideoGeneration({ videoId }: VideoGenerationProps) {
const [status, setStatus] = useState<'processing' | 'ready' | 'failed'>('processing');
const [playbackId, setPlaybackId] = useState<string | null>(null);
const player = useVideoPlayer(
playbackId ? `https://stream.mux.com/${playbackId}.m3u8` : null,
(player) => {
player.loop = false;
}
);
useEffect(() => {
// Subscribe to video status changes using Supabase Realtime
const subscription = supabase
.channel('video-updates')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'videos',
filter: `id=eq.${videoId}`,
},
(payload) => {
const video = payload.new;
setStatus(video.status);
if (video.status === 'ready') {
setPlaybackId(video.playback_id);
}
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [videoId]);
if (status === 'failed') {
return (
<View style={styles.container}>
<Text style={styles.errorText}>
Video generation failed. Please try again.
</Text>
</View>
);
}
if (status === 'processing' || !playbackId) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.text}>Generating your video...</Text>
</View>
);
}
return (
<VideoView
player={player}
style={styles.video}
nativeControls
/>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
video: {
width: '100%',
aspectRatio: 16 / 9,
},
text: {
marginTop: 10,
fontSize: 16,
color: '#666',
},
errorText: {
fontSize: 16,
color: '#ff0000',
textAlign: 'center',
},
});Here's the full flow for an async video generation example app:
import React, { useState } from 'react';
import {
View,
TextInput,
TouchableOpacity,
Text,
ActivityIndicator,
StyleSheet,
} from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
export default function AIVideoGenerator() {
const [prompt, setPrompt] = useState('');
const [generating, setGenerating] = useState(false);
const [videoId, setVideoId] = useState<string | null>(null);
const [playbackId, setPlaybackId] = useState<string | null>(null);
const player = useVideoPlayer(
playbackId ? `https://stream.mux.com/${playbackId}.m3u8` : null,
(player) => {
player.loop = false;
}
);
const generateVideo = async () => {
if (!prompt.trim()) return;
setGenerating(true);
try {
// Step 1: Submit prompt to your backend
const response = await fetch('https://your-api.com/generate-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const { videoId } = await response.json();
setVideoId(videoId);
// Step 2: Your backend handles:
// - Calling AI service (Fal.ai, Runway, etc.)
// - Getting video URL from AI service
// - Creating Mux asset from URL
// - Waiting for Mux webhook
// Step 3: Poll or subscribe for updates
const checkStatus = async () => {
const statusResponse = await fetch(
`https://your-api.com/videos/${videoId}`
);
const video = await statusResponse.json();
if (video.status === 'ready') {
setPlaybackId(video.playbackId);
setGenerating(false);
} else if (video.status === 'failed') {
setGenerating(false);
alert('Video generation failed');
} else {
// Still processing, check again in 3 seconds
setTimeout(checkStatus, 3000);
}
};
checkStatus();
} catch (error) {
console.error('Generation failed:', error);
setGenerating(false);
}
};
if (playbackId) {
return (
<View style={styles.container}>
<VideoView
player={player}
style={styles.video}
nativeControls
/>
<TouchableOpacity
style={styles.button}
onPress={() => {
setPrompt('');
setPlaybackId(null);
setVideoId(null);
}}
>
<Text style={styles.buttonText}>Generate Another</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="Enter video prompt..."
value={prompt}
onChangeText={setPrompt}
multiline
editable={!generating}
/>
{generating ? (
<>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.statusText}>
Generating your video...
</Text>
<Text style={styles.subText}>
This usually takes 30-60 seconds
</Text>
</>
) : (
<TouchableOpacity style={styles.button} onPress={generateVideo}>
<Text style={styles.buttonText}>Generate Video</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
},
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 15,
fontSize: 16,
marginBottom: 20,
minHeight: 100,
},
button: {
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
alignItems: 'center',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
video: {
width: '100%',
aspectRatio: 16 / 9,
marginBottom: 20,
},
statusText: {
fontSize: 16,
marginTop: 10,
textAlign: 'center',
},
subText: {
fontSize: 14,
color: '#666',
marginTop: 5,
textAlign: 'center',
},
});Mobile networks are unreliable, so robust error handling is essential. Here are common upload errors and how to handle them:
Common error scenarios:
| Error | Cause | Solution |
|---|---|---|
| Network timeout | Slow/unstable connection | Implement retry logic, allow resumable uploads |
| 403 Forbidden | Upload URL expired (valid for 48 hours) | Request a new upload URL from your backend |
| Connection lost | User switched from WiFi to cellular | Cancel upload, show option to retry |
| File too large | Video exceeds Mux limits | Validate file size before upload, compress if needed |
| Out of storage | Device storage full | Check available storage before upload |
Enhanced error handling:
import { useState } from 'react';
import * as FileSystem from 'expo-file-system';
import * as Network from 'expo-network';
interface UploadError {
message: string;
canRetry: boolean;
shouldRequestNewUrl: boolean;
}
export function useVideoUploadWithRetry() {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const MAX_RETRIES = 3;
const handleUploadError = (error: any): UploadError => {
const message = error.message?.toLowerCase() || '';
// Network errors - can retry
if (message.includes('network') || message.includes('connection')) {
return {
message: 'Network error. Check your connection and try again.',
canRetry: true,
shouldRequestNewUrl: false,
};
}
// Timeout errors - can retry
if (message.includes('timeout')) {
return {
message: 'Upload timed out. Try again or use a shorter video.',
canRetry: true,
shouldRequestNewUrl: false,
};
}
// Upload URL expired - need new URL
if (message.includes('403') || message.includes('forbidden')) {
return {
message: 'Upload URL expired. Requesting a new one...',
canRetry: true,
shouldRequestNewUrl: true,
};
}
// Server errors - can retry
if (message.includes('500') || message.includes('502') || message.includes('503')) {
return {
message: 'Server error. Retrying...',
canRetry: true,
shouldRequestNewUrl: false,
};
}
// Client errors - cannot retry
if (message.includes('400') || message.includes('413')) {
return {
message: 'Invalid video file or file too large.',
canRetry: false,
shouldRequestNewUrl: false,
};
}
// Generic error
return {
message: 'Upload failed. Please try again.',
canRetry: true,
shouldRequestNewUrl: false,
};
};
const uploadVideoWithRetry = async (
videoUri: string,
getUploadUrl: () => Promise<{ uploadUrl: string; uploadId: string }>
): Promise<{ uploadId: string } | null> => {
setUploading(true);
setUploadProgress(0);
setError(null);
setRetryCount(0);
let uploadUrl: string;
let uploadId: string;
// Get initial upload URL
try {
const result = await getUploadUrl();
uploadUrl = result.uploadUrl;
uploadId = result.uploadId;
} catch (err) {
setError('Failed to get upload URL');
setUploading(false);
return null;
}
// Retry loop
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
setRetryCount(attempt);
// Check network connectivity before upload
const networkState = await Network.getNetworkStateAsync();
if (!networkState.isConnected) {
throw new Error('No network connection');
}
// Create upload task
const uploadTask = FileSystem.createUploadTask(
uploadUrl,
videoUri,
{
httpMethod: 'PUT',
uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
},
(progress) => {
const percentage =
progress.totalBytesSent / progress.totalBytesExpectedToSend;
setUploadProgress(Math.round(percentage * 100));
}
);
const uploadResponse = await uploadTask.uploadAsync();
if (!uploadResponse || uploadResponse.status !== 200) {
throw new Error(`Upload failed with status ${uploadResponse?.status}`);
}
// Success!
setUploading(false);
return { uploadId };
} catch (err: any) {
console.error(`Upload attempt ${attempt + 1} failed:`, err);
const errorInfo = handleUploadError(err);
setError(errorInfo.message);
// If we can't retry or we've exhausted retries, give up
if (!errorInfo.canRetry || attempt === MAX_RETRIES - 1) {
setUploading(false);
setUploadProgress(0);
return null;
}
// If URL expired, get a new one
if (errorInfo.shouldRequestNewUrl) {
try {
const result = await getUploadUrl();
uploadUrl = result.uploadUrl;
uploadId = result.uploadId;
} catch {
setError('Failed to get new upload URL');
setUploading(false);
setUploadProgress(0);
return null;
}
}
// Wait before retrying (exponential backoff)
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
setUploading(false);
setUploadProgress(0);
return null;
};
return { uploadVideoWithRetry, uploading, uploadProgress, error, retryCount };
}Using the retry hook:
import * as ImagePicker from 'expo-image-picker';
function VideoUploader() {
const { uploadVideoWithRetry, uploading, uploadProgress, error, retryCount } =
useVideoUploadWithRetry();
const selectAndUpload = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
});
if (result.canceled) return;
const getUploadUrl = async () => {
const response = await fetch('https://your-api.com/generate-upload-url', {
method: 'POST',
});
return response.json();
};
await uploadVideoWithRetry(result.assets[0].uri, getUploadUrl);
};
return (
<View>
<TouchableOpacity onPress={selectAndUpload} disabled={uploading}>
<Text>Select and Upload Video</Text>
</TouchableOpacity>
{uploading && (
<View>
<Text>Uploading: {uploadProgress}%</Text>
{retryCount > 0 && <Text>Retry attempt {retryCount + 1}</Text>}
</View>
)}
{error && <Text style={{ color: 'red' }}>{error}</Text>}
</View>
);
}Install expo-network to check connectivity: npx expo install expo-network
Common issues when creating assets from URLs:
FileSystem.createUploadTask for progress updatesLearn more about Mux encoding tiers and pricing to optimize costs for your use case.