MediumCapturing and Uploading Photos in Capacitor with the Camera Plugin | by Pranjal Vyas | Oct, 2026 | Medium
Sitemap

Capturing and Uploading Photos in Capacitor with the Camera Plugin

5 min readOct 27, 2026

--

Almost every app eventually needs a photo: a profile picture, a receipt, a damaged parcel, a proof of delivery. In Capacitor that job belongs to the official @capacitor/camera plugin, which gives you the native camera and photo library on iOS and Android and a web fallback in the browser, all from one TypeScript API. In this guide we will capture, pick, store, and upload photos step by step.

Press enter or click to view image in full size

What you’ll build

  • A small photoService module that wraps permissions, capture, and gallery picking.
  • Native permission strings for iOS and a clean permission flow that handles a denied state.
  • Multi-image picking from the photo library.
  • Offline-safe storage of photos in the app’s private data directory with @capacitor/filesystem.
  • A multipart upload of a captured photo to your backend.
Press enter or click to view image in full size
How a photo moves from tap to permission, capture, local storage, and upload.

1. Install the plugins

Install the Camera and Filesystem plugins, then sync the native projects:

npm install @capacitor/camera @capacitor/filesystem
npx cap sync

If you also run the app in a desktop browser during development, add PWA Elements. The web implementation of the camera uses it to show a capture and file-picker UI:

npm install @ionic/pwa-elements
// src/main.ts
import { defineCustomElements } from '@ionic/pwa-elements/loader';

defineCustomElements(window);

2. Add the native permission strings

iOS will crash the moment you open the camera if the usage descriptions are missing. Add these keys to ios/App/App/Info.plist, and write the text as something a real user would understand:

<key>NSCameraUsageDescription</key>
<string>We use the camera so you can take a photo for your profile.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to your photos so you can choose a profile picture.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We save photos you take to your library when you ask us to.</string>

On Android, recent versions of the plugin use the system photo picker, so basic capture and picking work without extra storage permissions. If you turn on saveToGallery for older Android versions, check the plugin README for the manifest entries it needs.

3. Check and request permissions

Asking for permission right before the user taps “Take photo” converts far better than asking on launch. Wrap the check in one helper so every screen behaves the same way:

// src/services/photoService.ts
import { Camera, CameraPermissionType } from '@capacitor/camera';

export async function ensurePermission(type: CameraPermissionType): Promise<boolean> {
const current = await Camera.checkPermissions();
if (current[type] === 'granted' || current[type] === 'limited') {
return true;
}
if (current[type] === 'denied') {
// The OS will not show the prompt again; send the user to Settings instead.
return false;
}
const result = await Camera.requestPermissions({ permissions: [type] });
return result[type] === 'granted' || result[type] === 'limited';
}

The limited state appears on iOS when the user shares only selected photos, and it is still enough for picking.

4. Take a photo or pick one

Camera.getPhoto opens either the camera, the library, or a prompt that lets the user choose. Ask for a Uri result instead of base64, because it avoids pushing a multi-megabyte string across the bridge:

Get Pranjal Vyas’s stories in your inbox

Join Medium for free to get updates from this writer.

// src/services/photoService.ts (continued)
// Add to the existing imports at the top of the file
import { CameraResultType, CameraSource, Photo } from '@capacitor/camera';

export async function takePhoto(source: CameraSource = CameraSource.Prompt): Promise<Photo | null> {
const permission = source === CameraSource.Photos ? 'photos' : 'camera';
if (!(await ensurePermission(permission))) {
return null;
}

try {
return await Camera.getPhoto({
source,
resultType: CameraResultType.Uri,
quality: 80,
width: 1600,
correctOrientation: true,
allowEditing: false,
});
} catch (error) {
// Thrown when the user cancels the camera or the picker
console.warn('Photo capture cancelled', error);
return null;
}
}

The returned Photo has a webPath you can drop straight into an <img> tag, plus a native path and the image format. Setting width and quality keeps uploads small without visible loss on a phone screen.

5. Pick several images at once

For galleries and multi-attachment forms, Camera.pickImages opens the native multi-select picker:

import { Camera, GalleryPhoto } from '@capacitor/camera';

export async function pickImages(limit = 5): Promise<GalleryPhoto[]> {
const { photos } = await Camera.pickImages({
quality: 80,
width: 1600,
limit, // Respected on iOS 14+ and Android
});
return photos;
}

Each GalleryPhoto again carries a webPath for previews and a format such as jpeg or heic.

6. Save photos to the app’s private storage

The webPath points at a temporary file that the OS may clean up. If the photo must survive an app restart, for example in an offline queue, copy it into Directory.Data with the Filesystem plugin:

import { Capacitor } from '@capacitor/core';
import { Directory, Filesystem } from '@capacitor/filesystem';
import { Photo } from '@capacitor/camera';

async function toBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => resolve((reader.result as string).split(',')[1]);
reader.readAsDataURL(blob);
});
}

export async function savePhoto(photo: Photo): Promise<{ uri: string; displayUrl: string }> {
const blob = await (await fetch(photo.webPath!)).blob();
const fileName = `photo_${Date.now()}.${photo.format}`;

const { uri } = await Filesystem.writeFile({
path: `photos/${fileName}`,
data: await toBase64(blob),
directory: Directory.Data,
recursive: true,
});

// convertFileSrc turns a native file URI into a URL the WebView can render
return { uri, displayUrl: Capacitor.convertFileSrc(uri) };
}

Store the returned uri in your local database, and use displayUrl whenever you render the image again.

7. Upload the photo to your backend

Because webPath is a URL the WebView can fetch, uploading is plain web code. Turn it into a Blob and send it as multipart form data:

import { Photo } from '@capacitor/camera';

export async function uploadPhoto(photo: Photo, token: string): Promise<string> {
const blob = await (await fetch(photo.webPath!)).blob();

const form = new FormData();
form.append('file', blob, `upload.${photo.format}`);

const response = await fetch('https://api.example.com/v1/photos', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
});

if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
const { url } = await response.json();
return url;
}

Do not set the Content-Type header yourself: the browser adds the multipart boundary for you. If your API lives on another domain, remember to allow the Capacitor origins (capacitor://localhost on iOS and https://localhost on Android) in your CORS settings.

8. Wire it into a screen

Here is how the pieces come together in a simple framework-agnostic handler:

import { CameraSource } from '@capacitor/camera';
import { takePhoto, savePhoto, uploadPhoto } from './services/photoService';

export async function onChangeAvatar(img: HTMLImageElement, token: string) {
const photo = await takePhoto(CameraSource.Prompt);
if (!photo) return; // cancelled or permission denied

img.src = photo.webPath!; // instant preview

const saved = await savePhoto(photo); // survives restarts and offline mode
try {
const remoteUrl = await uploadPhoto(photo, token);
img.src = remoteUrl;
} catch {
console.warn('Upload failed, will retry later from', saved.uri);
}
}

Practical checklist

  1. Add all three iOS usage descriptions before your first test on a device.
  2. Ask for permission just in time, and handle the denied state with a link to Settings.
  3. Prefer CameraResultType.Uri over base64 to keep memory use low.
  4. Resize with width and quality before uploading.
  5. Copy photos into Directory.Data if they must outlive the session.
  6. Test on a real device: simulators have no camera, and the web fallback behaves differently.

Wrapping up

With a thin service around @capacitor/camera and @capacitor/filesystem, photo capture in Capacitor stays small, testable, and identical across iOS, Android, and the web. Start with a single profile picture flow, then reuse the same helpers for receipts, attachments, and offline queues. Happy building!

--

--

Pranjal Vyas
Pranjal Vyas

Written by Pranjal Vyas

Passionate iOS, Android, and Flutter developer creating seamless mobile experiences. Backend explorer diving into Node.js and Rust for robust APIs.