Expo & React Native
Add S3 file uploads to any React Native or Expo app — pass picker assets directly, no field mapping needed.
Overview
pushduck/react-native is a drop-in replacement for pushduck/client that works in React Native and Expo apps. The API is identical — useUploadRoute and createUploadClient work exactly the same way. The only difference is that uploadFiles accepts picker asset objects directly, so you never need to map field names.
Supported pickers out of the box:
expo-image-picker— passresult.assetsdirectlyexpo-document-picker— passresult.assetsdirectlyreact-native-image-picker— filter forurithen pass directly
The server side is unchanged — your existing upload route works without any modification.
endpoint must be an absolute URL. React Native has no concept of a base URL, so relative paths like /api/s3-upload will throw a network error. Always pass the full URL: https://your-api.com/api/s3-upload.
Setup
Install pushduck
npm install pushduckyarn add pushduckpnpm add pushduckbun add pushduckInstall whichever picker(s) you need:
npx expo install expo-image-picker
npx expo install expo-document-picker
# react-native-image-picker is installed via npm/yarn directlyCreate the upload client
Import from pushduck/react-native and provide an absolute endpoint:
import { createUploadClient } from 'pushduck/react-native';
import type { AppRouter } from './upload-server'; // your server router type
export const upload = createUploadClient<AppRouter>({
endpoint: process.env.EXPO_PUBLIC_API_URL + '/api/s3-upload',
// e.g. 'https://your-api.com/api/s3-upload'
});Or use useUploadRoute directly in a component:
import { useUploadRoute } from 'pushduck/react-native';
const { uploadFiles, files, isUploading, progress } = useUploadRoute('imageUpload', {
endpoint: 'https://your-api.com/api/s3-upload',
});Configure the server
The server route is identical to any other pushduck integration. See Quick Start or the Next.js / Express guide depending on your backend.
import { createUploadConfig } from 'pushduck/server';
const { s3 } = createUploadConfig()
.provider('cloudflareR2', {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: 'auto',
endpoint: process.env.AWS_ENDPOINT_URL!,
bucket: process.env.S3_BUCKET_NAME!,
accountId: process.env.R2_ACCOUNT_ID!,
})
.build();
export const uploadRouter = s3.createRouter({
imageUpload: s3.image().maxFileSize('10MB'),
documentUpload: s3.file().maxFileSize('25MB'),
});
export type AppRouter = typeof uploadRouter;Usage by Picker
expo-image-picker
import React from 'react';
import { View, Text, TouchableOpacity, Image, Alert, Platform } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { upload } from '../lib/upload';
export default function ImageUploader() {
const { uploadFiles, files, isUploading, errors } = upload.imageUpload();
const pickAndUpload = async () => {
if (Platform.OS !== 'web') {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'Camera roll permission is required.');
return;
}
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsMultipleSelection: true,
});
if (!result.canceled) {
// Pass result.assets directly — no mapping needed
await uploadFiles(result.assets);
}
};
return (
<View style={{ padding: 20 }}>
<TouchableOpacity
onPress={pickAndUpload}
disabled={isUploading}
style={{ backgroundColor: isUploading ? '#ccc' : '#007AFF', padding: 15, borderRadius: 8 }}
>
<Text style={{ color: 'white', textAlign: 'center' }}>
{isUploading ? 'Uploading...' : 'Pick Images'}
</Text>
</TouchableOpacity>
{errors.length > 0 && (
<Text style={{ color: 'red', marginTop: 10 }}>{errors[0]}</Text>
)}
{files.map((file) => (
<View key={file.id} style={{ marginTop: 10 }}>
<Text>{file.name} — {file.status} ({file.progress}%)</Text>
{file.status === 'success' && file.url && (
<Image source={{ uri: file.url }} style={{ width: 200, height: 200, marginTop: 8, borderRadius: 8 }} />
)}
</View>
))}
</View>
);
}expo-document-picker
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import { upload } from '../lib/upload';
export default function DocumentUploader() {
const { uploadFiles, files, isUploading, errors } = upload.documentUpload();
const pickAndUpload = async () => {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'text/plain'],
multiple: true,
});
if (!result.canceled) {
// Pass result.assets directly — no mapping needed
await uploadFiles(result.assets);
}
};
return (
<View style={{ padding: 20 }}>
<TouchableOpacity
onPress={pickAndUpload}
disabled={isUploading}
style={{ backgroundColor: isUploading ? '#ccc' : '#34C759', padding: 15, borderRadius: 8 }}
>
<Text style={{ color: 'white', textAlign: 'center' }}>
{isUploading ? 'Uploading...' : 'Pick Documents'}
</Text>
</TouchableOpacity>
{errors.length > 0 && (
<Text style={{ color: 'red', marginTop: 10 }}>{errors[0]}</Text>
)}
{files.map((file) => (
<View key={file.id} style={{ marginTop: 10, padding: 10, backgroundColor: '#f5f5f5', borderRadius: 8 }}>
<Text style={{ fontWeight: 'bold' }}>{file.name}</Text>
<Text style={{ color: '#666' }}>
{file.status === 'success' ? 'Uploaded' : `${file.progress}%`}
</Text>
</View>
))}
</View>
);
}react-native-image-picker
react-native-image-picker types uri as optional on its Asset type. Filter for uri before passing:
import { launchImageLibrary } from 'react-native-image-picker';
import { useUploadRoute } from 'pushduck/react-native';
const { uploadFiles, isUploading } = useUploadRoute('imageUpload', {
endpoint: 'https://your-api.com/api/s3-upload',
});
const pickAndUpload = async () => {
const result = await launchImageLibrary({ mediaType: 'photo', selectionLimit: 0 });
// uri is typed as optional — filter to narrow the type before passing
const assets = result.assets?.filter(
(a): a is typeof a & { uri: string } => !!a.uri
);
if (assets?.length) await uploadFiles(assets);
};Progress Tracking
All the same progress state is available in React Native:
import { useUploadRoute, formatETA, formatUploadSpeed } from 'pushduck/react-native';
const {
uploadFiles,
files, // per-file state: name, status, progress, url, error
isUploading, // true while any upload is in progress
progress, // 0–100 overall across all files
uploadSpeed, // bytes/second overall
eta, // seconds remaining overall
errors, // string[] of error messages
reset, // clears all state
} = useUploadRoute('imageUpload', {
endpoint: 'https://your-api.com/api/s3-upload',
onProgress: (pct) => console.log(`${pct}%`),
onSuccess: (files) => console.log('Done:', files.map(f => f.url)),
onError: (err) => console.error(err.message),
});
// In your render:
// {isUploading && <Text>Speed: {formatUploadSpeed(uploadSpeed ?? 0)} — ETA: {formatETA(eta ?? 0)}</Text>}Authentication
Pass a token via the fetcher option to attach headers to every request:
import { createUploadClient } from 'pushduck/react-native';
import type { AppRouter } from './upload-server';
import { getAuthToken } from './auth'; // your token source
export const upload = createUploadClient<AppRouter>({
endpoint: 'https://your-api.com/api/s3-upload',
fetcher: async (url, init) => {
const token = await getAuthToken();
return fetch(url, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${token}` },
});
},
});On the server, read the token from the request header in your middleware:
imageUpload: s3
.image()
.maxFileSize('10MB')
.middleware(async ({ req }) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) throw new Error('Unauthorized');
const userId = await verifyToken(token);
return { userId };
}),Expo Router (Full-Stack)
If you're using Expo Router with API routes, the server runs inside your Expo app. Expo Router API routes use standard Request/Response, so no adapter is needed.
API Route
import { uploadRouter } from '../../../lib/upload';
export async function GET(request: Request) {
return uploadRouter.handlers(request);
}
export async function POST(request: Request) {
return uploadRouter.handlers(request);
}Enable Server Output
{
"expo": {
"web": { "output": "server" },
"plugins": [["expo-router", { "origin": "https://your-domain.com" }]]
}
}Client Setup for Expo Router
When the server is inside the Expo app itself, the endpoint is still an absolute URL — use EXPO_PUBLIC_API_URL to configure it:
import { createUploadClient } from 'pushduck/react-native';
import type { AppRouter } from './upload-server';
export const upload = createUploadClient<AppRouter>({
endpoint: `${process.env.EXPO_PUBLIC_API_URL}/api/s3-upload`,
});EXPO_PUBLIC_API_URL=https://your-domain.com
# Simulator/emulator local dev: http://localhost:8081
# Physical device local dev: use your machine's LAN IP (e.g. http://192.168.1.42:8081)
# or run `npx expo start --tunnel` and use the tunnel URLEnvironment Variables
# Server-side (API routes / backend) — no EXPO_PUBLIC_ prefix needed
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_ENDPOINT_URL=https://your-account.r2.cloudflarestorage.com
S3_BUCKET_NAME=your-bucket
R2_ACCOUNT_ID=your-cloudflare-account-id
# Client-side — must use EXPO_PUBLIC_ prefix so Metro bundles them
EXPO_PUBLIC_API_URL=https://your-domain.comServer-only variables (without EXPO_PUBLIC_) are available in Expo Router API routes but not in the React Native client bundle. Use EXPO_PUBLIC_API_URL to pass the API base URL to the client.
Requirements & Known Limitations
Minimum supported versions:
- Expo SDK 50+ / React Native 0.73+ recommended
- expo-image-picker 15+ (SDK 50) for reliable
mimeTypefield - React Native 0.72+ for the
Fileglobal
Older versions work for upload delivery but may store incorrect MIME types in S3 (e.g., 'image' instead of 'image/jpeg') because mimeType was absent from expo-image-picker before SDK 50.
Known upstream bugs (not fixable in pushduck):
Upload progress may be missing in Expo Go (SDK 49–51). XHR upload progress events do not fire reliably in Expo Go on those SDK versions due to a network debugging regression. Production builds (EAS Build) and development builds (expo-dev-client) are not affected. Uploads complete correctly — only progress tracking is missing.
fetch('file://') on Android is broken in React Native 0.82.0. If you're on Expo SDK 53 with RN 0.82, uploads will fail on Android with a scheme error. This was fixed in RN 0.83.1 / Expo SDK 53.x patch. Update your Expo SDK to resolve it.
Troubleshooting
Network request failed with a relative URL
You passed a relative endpoint. Fix it:
// ❌ fails in React Native
useUploadRoute('imageUpload')
// ✅ correct
useUploadRoute('imageUpload', { endpoint: 'https://your-api.com/api/s3-upload' })Cannot read content:// URIs error
You passed copyToCacheDirectory: false to expo-document-picker. Remove that option (or set it to true) so the picker copies the file to cache and returns a file:// URI:
// ❌ returns content:// on Android — not supported
const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: false });
// ✅ default behaviour — returns file:// URIs
const result = await DocumentPicker.getDocumentAsync({ type: '*/*' });Files uploaded with wrong MIME type / stored as application/octet-stream
This happens when your version of expo-image-picker doesn't populate the mimeType field (SDK < 50). Upgrade to Expo SDK 50+ to resolve it. In the meantime the file is still uploaded and stored correctly — only the Content-Type metadata on the S3 object is wrong.
Upload never starts / no progress
Check that your picker returned actual assets — result.canceled might be true. If you're in Expo Go on SDK 49–51, progress won't show (see limitations above), but the upload is still happening.
Permission denied
Request media library permissions before calling the picker:
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') return;CORS errors (Expo Router or separate backend)
React Native's native HTTP stack doesn't enforce browser CORS, so CORS errors only appear when running on web via Expo Router. Add CORS headers to your API route:
// Use a specific origin in production, not a wildcard.
const allowedOrigin = process.env.APP_ORIGIN ?? 'https://your-domain.com';
export async function OPTIONS() {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
export async function POST(request: Request) {
const response = await uploadRouter.handlers(request);
response.headers.set('Access-Control-Allow-Origin', allowedOrigin);
return response;
}Metro cache issues
npx expo start --clear