One Codebase, Real Camera: Using the Capacitor Camera Plugin Without Regretting It Later
Capacitor's Camera plugin lets one web codebase launch the real native camera UI on iOS and Android. Here's the setup, a working capture example, and the permission and testing trade-offs to price in.
07 Apr 2026, 18:24 UTC

Your product team wants a photo upload feature in the mobile app. The app is a single web codebase wrapped in Capacitor, and the alternative — writing camera flows twice in Swift and Kotlin — would double the maintenance burden for a feature that ships maybe twice a quarter. The useful takeaway: Capacitor's official Camera plugin gives you one JavaScript call that launches the real native camera UI on each platform, but you pay for it in permission plumbing, binary size, and version discipline. This post walks through what that actually looks like.
What the plugin actually does (and doesn't)
The @capacitor/camera plugin is a thin bridge. When you call Camera.getPhoto(), it opens the platform's own camera interface — the same UI users see in any native app — and hands you back the result as a file URI, a base64 string, or a data URL. It is not a custom in-webview camera preview. If you need a live preview embedded in your layout (barcode scanning, AR overlays), this is the wrong tool; you'd look at something like a preview-layer plugin instead.
That distinction matters for the engineering decision: you're trading UI control for simplicity and native familiarity. For "take a photo of a receipt" or "pick a profile picture," the native UI is usually a feature, not a limitation — users already know how it works.
Setup: more than npm install
Installing the package is the easy part. Run these from your project root (the directory containing capacitor.config.ts), with normal user permissions:
npm install @capacitor/camera
npx cap syncThe sync step copies your web build into the native projects and updates native dependencies. Skipping it is the most common reason a freshly added plugin "doesn't work" — the JavaScript side loads, but the native bridge has no implementation to call.
Then the platform-specific declarations:
- Android: the plugin's manifest merging typically adds the
CAMERApermission, but verify it inandroid/app/src/main/AndroidManifest.xml. If you declareandroid.hardware.cameraas a required feature, devices without cameras can't install your app at all — make it optional unless the camera is core to your app. - iOS: add
NSCameraUsageDescriptiontoios/App/App/Info.plistwith a human-readable reason ("We use your camera to attach photos to reports"). Apple rejects builds missing this string, and the app crashes at runtime if the key is absent when the camera is invoked.
One version rule worth internalizing: keep the plugin's major version aligned with @capacitor/core. A Camera plugin built for Capacitor 6 bridged into a Capacitor 5 app can fail in ways that only surface on device.
A worked example: capture and display
Here's a minimal flow you can drop into any framework. It requests permission, captures a photo, and displays it:
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
async function takePhoto() {
// Check first; request only if needed
let perms = await Camera.checkPermissions();
if (perms.camera !== 'granted') {
perms = await Camera.requestPermissions({ permissions: ['camera'] });
}
if (perms.camera !== 'granted') {
throw new Error('Camera permission denied');
}
const photo = await Camera.getPhoto({
quality: 90,
resultType: CameraResultType.Uri,
source: CameraSource.Camera,
});
// webPath is safe to use as an <img> src on all platforms
return photo.webPath;
}Three details in this snippet earn their keep:
CameraResultType.Urireturns a file reference instead of a base64 string. Base64 inflates image size by roughly a third and shuttles megabytes across the native bridge as a string — fine for thumbnails, painful for full-resolution photos. UseUrifor display and upload; reserve base64 for APIs that demand it.webPathis the portable display path.photo.pathis the native file path, which webviews often refuse to load directly.CameraSource.Cameraforces the camera. UseCameraSource.Promptto let the user choose between camera and photo library — usually the friendlier default.
Wire the returned webPath into an <img> tag's src and you have a working capture-and-preview loop in one file.
The trade-offs you should price in
Permission UX is your problem. On both platforms, a user who denies permission (especially "permanently" on Android, or via Settings on iOS) can't be re-prompted programmatically — you must detect this and deep-link them to system settings. Test the denial path, not just the happy path.
Simulators lie. The iOS Simulator has no camera, and Android emulators often serve a mock image. Permission prompts and the real capture UI only behave truthfully on physical hardware. Budget for at least one real device per platform before calling the feature done.
Web fallback differs. In a plain browser, the plugin falls back to a file input or getUserMedia-style behavior depending on configuration — it will not look like the native flow. Verify both, and decide whether web is even a supported target for this feature.
Binary size and API lag. Each plugin adds native code to your bundle (a few megabytes per platform is a reasonable expectation), and wrapper plugins trail the newest OS camera capabilities. If you need bleeding-edge capture features, you'll outgrow the abstraction.
How to verify it actually works
Before shipping, run through this checklist:
- After
npx cap sync, confirmCAMERAappears in the Android manifest andNSCameraUsageDescriptionin the iOS plist. - On a physical device, deny the permission prompt and confirm your app handles it gracefully instead of hanging.
- Capture a photo, display it via
webPath, and rotate the device — confirm the image survives configuration changes (store the path in component state, not a transient variable). - Check
npx cap lsto confirm plugin and core versions are on the same major line.
If those four pass, you have native camera access from one codebase — which is exactly the bet Capacitor asks you to make, made with your eyes open.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.