Expo Router
Full-stack React Native file uploads with Expo Router API routes - no adapter needed!
Expo Router Integration
Expo Router is a file-based router for React Native and web applications that enables full-stack development with API routes. Since Expo Router uses Web Standards APIs, pushduck handlers work directly without any adapters!
Web Standards Native: Expo Router API routes use standard Request
/Response
objects, making pushduck integration seamless with zero overhead. Perfect for universal React Native apps!
Quick Setup
Install dependencies
npx expo install expo-router pushduck
# For file uploads on mobile
npx expo install expo-document-picker expo-image-picker
# For file system operations
npx expo install expo-file-system
yarn expo install expo-router pushduck
# For file uploads on mobile
yarn expo install expo-document-picker expo-image-picker
# For file system operations
yarn expo install expo-file-system
pnpm expo install expo-router pushduck
# For file uploads on mobile
pnpm expo install expo-document-picker expo-image-picker
# For file system operations
pnpm expo install expo-file-system
bun expo install expo-router pushduck
# For file uploads on mobile
bun expo install expo-document-picker expo-image-picker
# For file system operations
bun expo install expo-file-system
Configure server output
Enable server-side rendering in your app.json
:
{
"expo": {
"web": {
"output": "server"
},
"plugins": [
[
"expo-router",
{
"origin": "https://your-domain.com"
}
]
]
}
}
Configure upload router
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().max("5MB"),
documentUpload: s3.file().max("10MB")
});
export type AppUploadRouter = typeof uploadRouter;
Create API route
import { uploadRouter } from '../../../lib/upload';
// Direct usage - no adapter needed!
export async function GET(request: Request) {
return uploadRouter.handlers(request);
}
export async function POST(request: Request) {
return uploadRouter.handlers(request);
}
Basic Integration
Simple Upload Route
import { uploadRouter } from '../../../lib/upload';
// Method 1: Combined handler (recommended)
export async function GET(request: Request) {
return uploadRouter.handlers(request);
}
export async function POST(request: Request) {
return uploadRouter.handlers(request);
}
// Method 2: Individual methods (if you need method-specific logic)
export async function PUT(request: Request) {
return uploadRouter.handlers(request);
}
export async function DELETE(request: Request) {
return uploadRouter.handlers(request);
}
With CORS Headers
import { uploadRouter } from '../../../lib/upload';
function addCorsHeaders(response: Response) {
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
export async function OPTIONS() {
return addCorsHeaders(new Response(null, { status: 200 }));
}
export async function GET(request: Request) {
const response = await uploadRouter.handlers(request);
return addCorsHeaders(response);
}
export async function POST(request: Request) {
const response = await uploadRouter.handlers(request);
return addCorsHeaders(response);
}
Advanced Configuration
Authentication with Expo Auth
import { createUploadConfig } from 'pushduck/server';
import { jwtVerify } from 'jose';
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!,
})
.paths({
prefix: 'uploads',
generateKey: (file, metadata) => {
return `${metadata.userId}/${Date.now()}/${file.name}`;
}
})
.build();
export const uploadRouter = s3.createRouter({
// Private uploads with JWT authentication
privateUpload: s3
.image()
.max("5MB")
.formats(['jpeg', 'jpg', 'png', 'webp'])
.middleware(async ({ req }) => {
const authHeader = req.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new Error('Authorization required');
}
const token = authHeader.substring(7);
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret);
return {
userId: payload.sub as string,
platform: 'mobile'
};
} catch (error) {
throw new Error('Invalid token');
}
}),
// User profile pictures
profilePicture: s3
.image()
.max("2MB")
.count(1)
.formats(['jpeg', 'jpg', 'png', 'webp'])
.middleware(async ({ req }) => {
const userId = await authenticateUser(req);
return { userId, category: 'profile' };
})
.paths({
generateKey: ({ metadata, file }) => {
return `profiles/${metadata.userId}/avatar.${file.name.split('.').pop()}`;
}
}),
// Document uploads
documents: s3
.file()
.max("10MB")
.types(['application/pdf', 'text/plain'])
.count(5)
.middleware(async ({ req }) => {
const userId = await authenticateUser(req);
return { userId, category: 'documents' };
}),
// Public uploads (no auth)
publicUpload: s3
.image()
.max("2MB")
// No middleware = public access
});
async function authenticateUser(req: Request): Promise<string> {
const authHeader = req.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new Error('Authorization required');
}
const token = authHeader.substring(7);
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret);
return payload.sub as string;
}
export type AppUploadRouter = typeof uploadRouter;
Client-Side Usage (React Native)
Upload Hook
import { createUploadClient } from 'pushduck/client';
import type { AppUploadRouter } from '../lib/upload';
export const upload = createUploadClient<AppUploadRouter>({
endpoint: '/api/upload'
});
Image Upload Component
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, Image, Alert, Platform } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { upload } from '../hooks/useUpload';
export default function ImageUploader() {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const { uploadFiles, files, isUploading, error } = upload.imageUpload();
const pickImage = async () => {
// Request permission
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: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 1,
});
if (!result.canceled) {
const asset = result.assets[0];
setSelectedImage(asset.uri);
// Create File object for upload
const file = {
uri: asset.uri,
name: asset.fileName || 'image.jpg',
type: asset.type || 'image/jpeg',
} as any;
uploadFiles([file]);
}
};
return (
<View style={{ padding: 20 }}>
<TouchableOpacity
onPress={pickImage}
disabled={isUploading}
style={{
backgroundColor: isUploading ? '#ccc' : '#007AFF',
padding: 15,
borderRadius: 8,
alignItems: 'center',
}}
>
<Text style={{ color: 'white', fontSize: 16 }}>
{isUploading ? 'Uploading...' : 'Pick Image'}
</Text>
</TouchableOpacity>
{error && (
<View style={{ padding: 10, backgroundColor: '#ffebee', marginTop: 10, borderRadius: 8 }}>
<Text style={{ color: '#c62828' }}>Error: {error.message}</Text>
</View>
)}
{selectedImage && (
<Image
source={{ uri: selectedImage }}
style={{ width: 200, height: 200, marginTop: 20, borderRadius: 8 }}
/>
)}
{files.length > 0 && (
<View style={{ marginTop: 20 }}>
{files.map((file) => (
<View key={file.id} style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 10,
backgroundColor: '#f5f5f5',
marginBottom: 10,
borderRadius: 8
}}>
<View style={{ flex: 1 }}>
<Text style={{ fontWeight: 'bold' }}>{file.name}</Text>
<Text style={{ fontSize: 12, color: '#666' }}>
{file.status === 'success' ? 'Complete' : `${file.progress}%`}
</Text>
</View>
{file.status === 'success' && file.url && (
<Text style={{ color: '#007AFF', fontSize: 12 }}>✓ Uploaded</Text>
)}
</View>
))}
</View>
)}
</View>
);
}
Document Upload Component
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, Alert, FlatList } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import { upload } from '../hooks/useUpload';
interface UploadedFile {
name: string;
size: number;
url: string;
}
export default function DocumentUploader() {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const { uploadFiles, isUploading, error } = upload.documents();
const pickDocument = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'text/plain'],
multiple: true,
});
if (!result.canceled) {
const files = result.assets.map(asset => ({
uri: asset.uri,
name: asset.name,
type: asset.mimeType || 'application/octet-stream',
})) as any[];
const uploadResult = await uploadFiles(files);
if (uploadResult.success) {
const newFiles = uploadResult.results.map(file => ({
name: file.name,
size: file.size,
url: file.url,
}));
setUploadedFiles(prev => [...prev, ...newFiles]);
Alert.alert('Success', `${files.length} file(s) uploaded successfully!`);
}
}
} catch (error) {
Alert.alert('Error', 'Failed to pick document');
}
};
return (
<View style={{ padding: 20 }}>
<TouchableOpacity
onPress={pickDocument}
disabled={isUploading}
style={{
backgroundColor: isUploading ? '#ccc' : '#34C759',
padding: 15,
borderRadius: 8,
alignItems: 'center',
marginBottom: 20,
}}
>
<Text style={{ color: 'white', fontSize: 16 }}>
{isUploading ? 'Uploading...' : 'Pick Documents'}
</Text>
</TouchableOpacity>
{error && (
<View style={{ padding: 10, backgroundColor: '#ffebee', marginBottom: 10, borderRadius: 8 }}>
<Text style={{ color: '#c62828' }}>Error: {error.message}</Text>
</View>
)}
<FlatList
data={uploadedFiles}
keyExtractor={(item, index) => index.toString()}
renderItem={({ item }) => (
<View style={{
padding: 10,
backgroundColor: '#f5f5f5',
marginBottom: 10,
borderRadius: 8,
}}>
<Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
<Text style={{ color: '#666', fontSize: 12 }}>
{(item.size / 1024).toFixed(1)} KB
</Text>
</View>
)}
/>
</View>
);
}
Project Structure
Here's a recommended project structure for Expo Router with pushduck:
Complete Example
Main Upload Screen
import React from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import ImageUploader from '../../components/ImageUploader';
import DocumentUploader from '../../components/DocumentUploader';
export default function UploadScreen() {
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>File Upload Demo</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Image Upload</Text>
<ImageUploader />
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Document Upload</Text>
<DocumentUploader />
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginVertical: 20,
},
section: {
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 15,
},
});
Tab Layout
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="upload"
options={{
title: 'Upload',
tabBarIcon: ({ color, size }) => (
<Ionicons name="cloud-upload" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
Deployment Options
EAS Build Configuration
Configure automatic server deployment in your eas.json
:
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": {
"EXPO_UNSTABLE_DEPLOY_SERVER": "1"
}
},
"preview": {
"distribution": "internal",
"env": {
"EXPO_UNSTABLE_DEPLOY_SERVER": "1"
}
},
"production": {
"env": {
"EXPO_UNSTABLE_DEPLOY_SERVER": "1"
}
}
}
}
Deploy with automatic server:
# Build for all platforms
eas build --platform all
# Deploy server only
npx expo export --platform web
eas deploy
Development Build Setup
# Install dev client
npx expo install expo-dev-client
# Create development build
eas build --profile development
# Or run locally
npx expo run:ios --configuration Release
npx expo run:android --variant release
Configure local server origin:
{
"expo": {
"plugins": [
[
"expo-router",
{
"origin": "http://localhost:8081"
}
]
]
}
}
Local Development Server
# Start Expo development server
npx expo start
# Test API routes
curl http://localhost:8081/api/upload/presigned-url
# Clear cache if needed
npx expo start --clear
For production testing:
# Export for production
npx expo export
# Serve locally
npx expo serve
Environment Variables
# AWS/Cloudflare R2 Configuration
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=auto
AWS_ENDPOINT_URL=https://your-account.r2.cloudflarestorage.com
S3_BUCKET_NAME=your-bucket-name
R2_ACCOUNT_ID=your-cloudflare-account-id
# JWT Authentication
JWT_SECRET=your-jwt-secret
# Expo Configuration (for client-side, use EXPO_PUBLIC_ prefix)
EXPO_PUBLIC_API_URL=https://your-domain.com
Important: Server environment variables (without EXPO_PUBLIC_
prefix) are only available in API routes, not in client code. Client-side variables must use the EXPO_PUBLIC_
prefix.
Performance Benefits
🚀 Universal Code
Share upload logic between web and native platforms with a single codebase.
📱 Native Performance
Direct access to native file system APIs for optimal performance on mobile.
🔄 Real-time Updates
Built-in support for upload progress tracking and real-time status updates.
🌐 Cross-Platform
Deploy to iOS, Android, and web with the same upload infrastructure.
Troubleshooting
File Permissions: Always request proper permissions for camera and photo library access on mobile devices before file operations.
Server Bundle: Expo Router API routes require server output to be enabled in your app.json
configuration.
Common Issues
Metro bundler errors:
# Clear Metro cache
npx expo start --clear
# Reset Expo cache
npx expo r -c
Permission denied errors:
// Always check permissions before file operations
import * as ImagePicker from 'expo-image-picker';
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'Camera roll permission is required');
return;
}
Network errors in development:
// Make sure your development server is accessible
const { upload } = useUpload('/api/upload', {
endpoint: __DEV__ ? 'http://localhost:8081' : 'https://your-domain.com',
});
File upload timeout:
const { upload } = useUpload('/api/upload', {
timeout: 60000, // 60 seconds
});
Debug Mode
Enable debug logging for development:
const { s3 } = createUploadConfig()
.provider("cloudflareR2",{ /* config */ })
.defaults({
debug: __DEV__, // Only in development
})
.build();
This will log detailed information about upload requests, file processing, and S3 operations to help diagnose issues during development.
Framework-Specific Notes
- File System Access: Use
expo-file-system
for advanced file operations - Permissions: Always request permissions before accessing camera or photo library
- Web Compatibility: Components work on web out of the box with Expo Router
- Platform Detection: Use
Platform.OS
to handle platform-specific logic - Environment Variables: Server variables don't need
EXPO_PUBLIC_
prefix in API routes