Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 10 min read

React Native Image Upload: Pick, Compress, Upload

Image upload is the feature that looks like ten lines and turns into a day. The picker part is genuinely easy. What costs the day is everything after: an 8MB photo timing out on cellular, a content://URI that fetch refuses to read on Android, a permission string you forgot until TestFlight crashed, and the question of how the app is supposed to talk to S3 without shipping a secret. Here’s the whole path — photo library to a URL in your database — with the failure modes named.

Pick your upload method first

The picker code is the same either way; the upload code is not. Decide this before you write anything:

MethodUse whenProgressLarge filesNote
FormData multipartYour own API endpoint⚠️ XHR onlyGoodMost portable; the { uri, name, type } shape is React-Native-specific
expo-file-system uploadAsyncAny HTTP endpoint✅ Built inBest — streamsNo JS-side buffering, so big files do not spike memory
Supabase storage.uploadSupabase backend❌GoodPass an ArrayBuffer; RLS policies do the auth
S3 signed PUTAWS / R2 / any S3 API⚠️ XHR onlyBestBackend issues the URL; no credentials in the app
base64 in JSONTiny 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

  1. Android content:// URI unreadable. Copy it into the cache first: await FileSystem.copyAsync({ from: uri, to: FileSystem.cacheDirectory + 'up.jpg' }) , then upload the file:// path. Running the image through manipulateAsync also does this as a side effect, which is one more reason to always compress.
  2. Works on wifi, times out on cellular. You skipped compression. Also set a real timeout and offer retry rather than hanging forever.
  3. HEIC from the iPhone camera. iOS shoots HEIC and your server may not decode it. manipulateAsync with SaveFormat.JPEG normalises it — again, compress-always solves it.
  4. 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.
  5. Optimistic UI that lies. Show the local URI immediately, but keep the row in a pending state 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.

Frequently Asked Questions

How do I upload an image in React Native?

Pick the file with expo-image-picker, compress it with expo-image-manipulator, then upload it. Send it either as multipart/form-data with a { uri, name, type } object appended to FormData, or as raw bytes via a signed URL from your storage provider. Never base64-encode a full-size photo — it inflates the payload roughly 33% and frequently crashes on large images.

Why does my React Native image upload fail on Android but work on iOS?

Usually the file URI. Android returns content:// URIs from some pickers, which fetch() cannot always read. Copying the file into the app cache with expo-file-system first, then uploading from that file:// path, fixes it. The second cause is a missing type on the FormData object — Android servers reject a part with no MIME type.

Should I compress images before uploading?

Yes, always. A modern phone camera produces 3–8MB photos; a 1080px-wide JPEG at 0.7 quality is typically 150–400KB and indistinguishable in an app UI. Compressing on-device cuts upload time roughly 10x, cuts storage cost, and avoids timeouts on cellular connections.

Do I need permission strings for the photo library?

Yes on iOS. NSPhotoLibraryUsageDescription is required to open the library and NSCameraUsageDescription to open the camera; a build without them crashes on launch of the picker, and a vague string is a common App Store rejection. In Expo, set them through the expo-image-picker config plugin in app.json.

How do I show upload progress in React Native?

fetch() has no progress events. Use expo-file-system uploadAsync, which accepts an onUploadProgress-style callback, or XMLHttpRequest with an upload.onprogress listener. Both give you bytes sent versus total, which you map to a progress bar.

Is it safe to upload directly from the app to S3 or Supabase?

Only with a short-lived signed URL issued by your backend, or a storage policy scoped to the authenticated user. Shipping a service-role key or an AWS secret in the app bundle exposes it to anyone who unzips the IPA — bundled env values are not secret.

→

Supabase vs Firebase for React Native

Picking the backend that will hold these images.

Compare →
→

Connect an AI-Generated App to a Real Backend

From mock data to a live database and storage bucket.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.