Pushduck
Pushduck// S3 uploads for any framework

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 — pass result.assets directly
  • expo-document-picker — pass result.assets directly
  • react-native-image-picker — filter for uri then 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 pushduck
yarn add pushduck
pnpm add pushduck
bun add pushduck

Install 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 directly

Create the upload client

Import from pushduck/react-native and provide an absolute endpoint:

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

(your backend) 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().maxFileSize('10MB'),
  documentUpload: s3.file().maxFileSize('25MB'),
});

export type AppRouter = typeof uploadRouter;

Usage by Picker

expo-image-picker

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

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

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

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

lib/upload.ts
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`,
});
.env
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 URL

Environment Variables

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

Server-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 mimeType field
  • React Native 0.72+ for the File global

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