Pick your upload method first
The picker code is the same either way; the upload code is not. Decide this before you write anything:
| Method | Use when | Progress | Large files | Note |
|---|---|---|---|---|
| FormData multipart | Your own API endpoint | ⚠️ XHR only | Good | Most portable; the { uri, name, type } shape is React-Native-specific |
| expo-file-system uploadAsync | Any HTTP endpoint | ✅ Built in | Best — streams | No JS-side buffering, so big files do not spike memory |
| Supabase storage.upload | Supabase backend | ❌ | Good | Pass an ArrayBuffer; RLS policies do the auth |
| S3 signed PUT | AWS / R2 / any S3 API | ⚠️ XHR only | Best | Backend issues the URL; no credentials in the app |
| base64 in JSON | Tiny avatars only | ❌ | ❌ +33% | Crashes on real photos — avoid |
Step 1: install and declare permissions
npx expo install expo-image-picker expo-image-manipulator expo-file-system
// app.json — the config plugin writes the native permission strings
{
"expo": {
"plugins": [
["expo-image-picker", {
"photosPermission": "MyApp needs access to your photos so you can add one to a listing.",
"cameraPermission": "MyApp needs the camera so you can take a photo for a listing."
}]
]
}
}Write specific strings. “This app needs photo access” is a documented App Store rejection reason; the string must say what the user gets out of it. This is a native change — rebuild, do not just reload.
Step 2: pick and compress
Compression is not an optimization here, it is the difference between an upload that works on a train and one that doesn’t. Resize before you upload, every time.
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
export async function pickAndPrepare() {
// Permission: the picker asks, but check so you can explain a denial
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) return { error: 'permission-denied' as const };
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'], // string array — 'MediaTypeOptions' is deprecated
allowsEditing: true,
aspect: [4, 3],
quality: 1, // full quality here; we compress deliberately below
});
if (result.canceled) return { canceled: true as const };
const asset = result.assets[0];
// 8MB original -> ~250KB. Do this before you touch the network.
const compressed = await ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: { width: 1080 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
);
return { uri: compressed.uri, width: compressed.width, height: compressed.height };
}Two API notes that break older tutorials: mediaTypes now takes a string array (MediaTypeOptions is deprecated), and the picker returns result.assets[] with canceled spelled with one “l”. Also: passing base64: true to the picker is tempting and wrong — it loads the whole image into JS memory for no benefit.
Step 3a: upload to your own endpoint, with progress
import * as FileSystem from 'expo-file-system';
export async function upload(uri: string, token: string, onProgress: (p: number) => void) {
const task = FileSystem.createUploadTask(
'https://api.example.com/uploads',
uri,
{
httpMethod: 'POST',
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
fieldName: 'file',
mimeType: 'image/jpeg',
headers: { Authorization: `Bearer ${token}` },
},
({ totalBytesSent, totalBytesExpectedToSend }) => {
onProgress(totalBytesSent / totalBytesExpectedToSend);
}
);
const res = await task.uploadAsync();
if (!res || res.status >= 400) throw new Error(`Upload failed: ${res?.status}`);
return JSON.parse(res.body) as { url: string };
}If you need plain FormData instead — for an existing API that expects it — the React Native shape is unusual and worth memorising:
const form = new FormData();
form.append('file', {
uri, // file:// path — NOT a Blob
name: 'photo.jpg',
type: 'image/jpeg', // omit this and Android 400s
} as any);
await fetch(url, {
method: 'POST',
body: form,
// Do NOT set Content-Type yourself — the runtime adds the multipart boundary.
headers: { Authorization: `Bearer ${token}` },
});Setting Content-Type: multipart/form-databy hand is the single most common cause of a server that reports “no file in request” — you overwrite the boundary token and the body becomes unparseable.
Step 3b: straight to Supabase Storage
import { decode } from 'base64-arraybuffer';
import * as FileSystem from 'expo-file-system';
import { supabase } from '../lib/supabase';
export async function uploadToSupabase(uri: string, userId: string) {
const b64 = await FileSystem.readAsStringAsync(uri, { encoding: 'base64' });
const path = `${userId}/${Date.now()}.jpg`; // user-scoped path for RLS
const { error } = await supabase.storage
.from('photos')
.upload(path, decode(b64), { contentType: 'image/jpeg', upsert: false });
if (error) throw error;
const { data } = supabase.storage.from('photos').getPublicUrl(path);
return data.publicUrl;
}The base64 read here is a real cost, which is exactly why you compressed first — a 250KB JPEG is fine, an 8MB one is not. And the bucket needs a policy, or every user can overwrite every other user’s photos:
-- Only let a user write inside their own folder create policy "own folder upload" on storage.objects for insert to authenticated with check ( bucket_id = 'photos' and (storage.foldername(name))[1] = auth.uid()::text );
The five failures you will actually hit
- Android content:// URI unreadable. Copy it into the cache first:
await FileSystem.copyAsync({ from: uri, to: FileSystem.cacheDirectory + 'up.jpg' }), then upload thefile://path. Running the image throughmanipulateAsyncalso does this as a side effect, which is one more reason to always compress. - Works on wifi, times out on cellular. You skipped compression. Also set a real timeout and offer retry rather than hanging forever.
- HEIC from the iPhone camera. iOS shoots HEIC and your server may not decode it.
manipulateAsyncwithSaveFormat.JPEGnormalises it — again, compress-always solves it. - Secrets in the bundle. Anything in
EXPO_PUBLIC_*ships inside the app and is readable. An AWS secret or a Supabase service-role key belongs on a server that hands out signed URLs, never in the client. - Optimistic UI that lies. Show the local URI immediately, but keep the row in a
pendingstate until the upload resolves, and reconcile on failure. Users background the app mid-upload constantly.
Skip the plumbing
Photo upload is table stakes for listings, profiles, and anything social — and it’s the same six files every time. ShipNative generates the picker, the compression step, the storage call, and the user-scoped bucket policy as part of the app, so you start from working upload instead of debugging a content://URI. It’s a real Expo project you export and own — describe your app and see it running on your phone in minutes.