Framework Integrations

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:

app.json
{
  "expo": {
    "web": {
      "output": "server"
    },
    "plugins": [
      [
        "expo-router",
        {
          "origin": "https://your-domain.com"
        }
      ]
    ]
  }
}

Configure upload router

lib/upload.ts
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

app/api/upload/[...slug]+api.ts
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

app/api/upload/[...slug]+api.ts
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

app/api/upload/[...slug]+api.ts
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

lib/upload.ts
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

hooks/useUpload.ts
import { createUploadClient } from 'pushduck/client';
import type { AppUploadRouter } from '../lib/upload';

export const upload = createUploadClient<AppUploadRouter>({
  endpoint: '/api/upload'
});

Image Upload Component

components/ImageUploader.tsx
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

components/DocumentUploader.tsx
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:

_layout.tsx
upload.ts
app.json
.env

Complete Example

Main Upload Screen

app/(tabs)/upload.tsx
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

app/(tabs)/_layout.tsx
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:

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:

app.json
{
  "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

.env
# 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

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:

lib/upload.ts
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

  1. File System Access: Use expo-file-system for advanced file operations
  2. Permissions: Always request permissions before accessing camera or photo library
  3. Web Compatibility: Components work on web out of the box with Expo Router
  4. Platform Detection: Use Platform.OS to handle platform-specific logic
  5. Environment Variables: Server variables don't need EXPO_PUBLIC_ prefix in API routes