Capacitor
Overview
Capacitor wraps your web app in a native container and gives it access to native device APIs — camera, file system, push notifications, biometrics, geolocation, and more. Your existing React/Vue/Angular/Svelte app becomes an iOS and Android app without rewriting in Swift or Kotlin. Built by the Ionic team, it's the modern replacement for Cordova/PhoneGap. This skill targets Capacitor 8 (iOS 15+, Android 7 / API 24+).
When to Use
- Have a web app and want native iOS/Android versions
- Need native device features (camera, push notifications, biometrics)
- Want one codebase for web + iOS + Android
- Converting a PWA to a native app for App Store distribution
- Team knows web tech but not Swift/Kotlin
Instructions
Setup
The web project needs a package.json and a build output directory (dist, build, www) whose index.html has a <head> tag.
npm install @capacitor/core
npm install -D @capacitor/cli
npx cap init "Trailhead" dev.fernhill.trailhead --web-dir dist
# Add platforms
npm install @capacitor/ios @capacitor/android
npx cap add ios # Swift Package Manager project (default since Capacitor 8)
npx cap add android
cap init writes capacitor.config.ts (or .json when TypeScript is not installed) with the first three keys; plugin settings are added by hand. With TypeScript 7 the CLI loads the .ts config through Node's ES-module loader, so package.json needs "type": "module" (Vite templates have it); without it every cap command stops with Parsing capacitor.config.ts failed:
// capacitor.config.ts
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "dev.fernhill.trailhead",
appName: "Trailhead",
webDir: "dist",
plugins: {
PushNotifications: { presentationOptions: ["badge", "sound", "alert"] },
},
};
export default config;
Build and Run
# Build your web app first
npm run build
# Copy web assets to native projects and update native dependencies
npx cap sync
# Open in native IDE
npx cap open ios # Opens Xcode
npx cap open android # Opens Android Studio
# Or run directly (run = sync + build + deploy)
npx cap run ios --list # available simulators and devices
npx cap run ios --target-name "iPhone 17" # or --target with an id from --list
npx cap run android
# Live reload from a running dev server (e.g. Vite on port 5173)
npx cap run android --live-reload --port 5173
# Signed release build from the terminal
npx cap build android --androidreleasetype AAB \
--keystorepath release.keystore --keystorepass "$ANDROID_KEYSTORE_PASSWORD" \
--keystorealias upload --keystorealiaspass "$ANDROID_KEY_PASSWORD"
Native Plugins
# Install plugins
npm install @capacitor/camera @capacitor/filesystem @capacitor/push-notifications
npm install @capacitor/haptics @capacitor/share @capacitor/browser
npx cap sync
// src/native/camera.ts — Access the device camera (@capacitor/camera 8.1+)
import { Camera, CameraDirection, MediaTypeSelection } from "@capacitor/camera";
export async function takePhoto(): Promise<string | undefined> {
const photo = await Camera.takePhoto({
quality: 80,
cameraDirection: CameraDirection.Rear,
saveToGallery: false,
});
return photo.webPath; // Path to display in an <img> element
}
export async function pickFromGallery(): Promise<string[]> {
const { results } = await Camera.chooseFromGallery({
mediaType: MediaTypeSelection.Photo,
allowMultipleSelection: true,
limit: 5,
});
return results.flatMap((item) => (item.webPath ? [item.webPath] : []));
}
Camera.getPhoto() and Camera.pickImages() still work but are deprecated since plugin version 8.1; new code should use takePhoto and chooseFromGallery.
// src/native/notifications.ts — Push notifications (iOS and Android only)
import { PushNotifications } from "@capacitor/push-notifications";
export async function registerPush(sendTokenToServer: (token: string) => Promise<void>) {
// Attach listeners before register() so the first event is not missed
await PushNotifications.addListener("registration", (token) => {
void sendTokenToServer(token.value); // FCM token on Android, APNs token on iOS
});
await PushNotifications.addListener("registrationError", (err) => {
console.error("Push registration failed:", err.error);
});
await PushNotifications.addListener("pushNotificationReceived", (notification) => {
console.log("Received:", notification.title, notification.body);
});
let status = await PushNotifications.checkPermissions();
if (status.receive === "prompt") status = await PushNotifications.requestPermissions();
if (status.receive !== "granted") throw new Error("Push permission denied");
await PushNotifications.register();
}
// src/native/filesystem.ts — File system access
import { Directory, Encoding, Filesystem } from "@capacitor/filesystem";
export async function saveFile(data: string, filename: string) {
await Filesystem.writeFile({
path: filename,
data: data,
directory: Directory.Documents,
encoding: Encoding.UTF8, // Without an encoding, data must be base64
});
}
export async function readFile(filename: string): Promise<string> {
const result = await Filesystem.readFile({
path: filename,
directory: Directory.Documents,
encoding: Encoding.UTF8,
});
return result.data as string;
}
Platform Detection
// src/utils/platform.ts — Adapt behavior per platform
import { Capacitor } from "@capacitor/core";
import { Share } from "@capacitor/share";
export const isNative = Capacitor.isNativePlatform();
export const platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
export const hasPush = Capacitor.isPluginAvailable("PushNotifications");
// Use the native share sheet when available, fall back to the clipboard
export async function shareLink(title: string, url: string) {
const { value: canShare } = await Share.canShare();
if (canShare) await Share.share({ title, url });
else await navigator.clipboard.writeText(url);
}
Examples
Example 1: Convert a React app to mobile
User prompt: "I have a React web app built with Vite. Make it available on iOS and Android."
npm install @capacitor/core @capacitor/ios @capacitor/android
npm install -D @capacitor/cli
npx cap init "Trailhead" dev.fernhill.trailhead --web-dir dist
npm run build
npx cap add android && npx cap add ios
npx cap sync
npx cap run android
cap add creates the android/ and ios/ folders (commit them), and cap sync reports each step (Android part shown; iOS prints the same):
✔ Copying web assets from dist to android/app/src/main/assets/public in 1.36ms
✔ Creating capacitor.config.json in android/app/src/main/assets in 229.50μs
✔ copy android in 3.65ms
✔ Updating Android plugins in 232.46μs
✔ update android in 8.14ms
[info] Sync finished in 0.021s
The app then installs and opens on the connected emulator or device. npx cap doctor lists installed versus latest Capacitor packages when something looks off.
Example 2: Add camera and file upload
User prompt: "Add photo capture and file upload to my mobile web app."
npm install @capacitor/camera
npx cap sync
Add NSCameraUsageDescription, NSPhotoLibraryUsageDescription and NSPhotoLibraryAddUsageDescription strings to ios/App/App/Info.plist, then upload the captured file:
import { takePhoto } from "./native/camera";
export async function uploadReceipt(): Promise<void> {
const webPath = await takePhoto();
if (!webPath) return;
const blob = await (await fetch(webPath)).blob();
const form = new FormData();
form.append("receipt", blob, "receipt.jpg");
await fetch(`${import.meta.env.VITE_API_URL}/receipts`, { method: "POST", body: form });
}
On a device the native camera opens and the photo is posted as multipart form data; in a browser the same code falls back to a file input (or the PWA Elements camera UI if installed).
Guidelines
npx cap sync after every web build and every plugin install — copies assets and updates native dependencies
npx cap run for testing — faster than opening IDE each time
- Platform detection —
Capacitor.isNativePlatform() for conditional native features
- Not every plugin runs on the web — Camera, Filesystem and Share have web implementations; Push Notifications does not. Guard with
Capacitor.isPluginAvailable()
- Live reload for development —
npx cap run ios --live-reload --host 192.168.1.68 --port 5173; the dev server must listen on 0.0.0.0 and the device must be on the same network. Never commit a server.url entry in the config
- Custom native code — write Swift/Kotlin plugins when needed
- App Store deployment — archive in Xcode for iOS; Android Studio or
npx cap build android for Android. iOS builds require a Mac
capacitor.config.ts — configure server URL, plugins, app info
- Permissions in native config — camera, location, etc. need
Info.plist usage descriptions; on Android the Camera plugin needs no permission unless saveToGallery is used
- Push setup is native — Android needs the Firebase
google-services.json in android/app/; iOS needs the Push Notifications capability and the two AppDelegate.swift forwarding methods from the plugin README
- Upgrading — keep
@capacitor/core, cli, ios and android on the same version; npx cap migrate moves a Capacitor 7 project to 8 (Node 22+, Xcode 26+, iOS 15 deployment target, minSdkVersion 24). New iOS projects use Swift Package Manager; pass --packagemanager CocoaPods to cap add ios to keep CocoaPods
- Not for gaming — great for content apps, tools, dashboards; use Unity/Flutter for games