Capacitor wraps web apps in a native WebView and provides a bridge to native device APIs — the same web app runs on iOS, Android, and as a PWA with native capabilities. npm install @capacitor/core @capacitor/cli installs the runtime; npx cap init creates capacitor.config.ts; npx cap add ios android generates the native projects. @capacitor/camera, @capacitor/geolocation, @capacitor/filesystem, and @capacitor/push-notifications provide typed JavaScript APIs to native features. Custom native plugins bridge between TypeScript and Swift/Kotlin for platform-specific APIs. npx cap sync copies web assets and syncs plugin changes to native projects. Live reload during development streams code changes to the device. Claude Code generates Capacitor configurations, plugin implementations, native bridge code in Swift and Kotlin, and the build workflows for App Store and Play Store deployment.
CLAUDE.md for Capacitor
## Capacitor Stack
- Version: @capacitor/core >= 6.0, @capacitor/cli >= 6.0
- Config: capacitor.config.ts — appId, appName, webDir, server.url for live reload
- Platforms: npx cap add ios — generates ios/ Xcode project; npx cap add android — generates android/
- Sync: npx cap sync — copies dist/ to native webview + syncs plugin pods/gradle deps
- Core plugins: @capacitor/camera, filesystem, geolocation, push-notifications, preferences
- Custom plugin: @capacitor/plugin-template — Swift implementation + Kotlin + TypeScript interface
- Live reload: server.url in config pointing to dev server — requires same network
- Build: npx cap build ios/android — or open Xcode/Android Studio for release signing
Configuration
// capacitor.config.ts — root Capacitor configuration
import type { CapacitorConfig } from "@capacitor/cli"
const isDev = process.env.NODE_ENV === "development"
const config: CapacitorConfig = {
appId: "com.example.myapp",
appName: "MyStore",
webDir: "dist",
// Live reload: point to Vite/Next.js dev server
server: isDev
? {
url: "http://192.168.1.100:3000", // Your machine's LAN IP
cleartext: true, // Allow HTTP in dev
}
: undefined,
// iOS configuration
ios: {
contentInset: "always",
preferredContentMode: "mobile",
backgroundColor: "#ffffff",
allowsLinkPreview: false,
},
// Android configuration
android: {
allowMixedContent: false,
captureInput: true,
webContentsDebuggingEnabled: isDev,
backgroundColor: "#ffffff",
},
// Plugin configuration
plugins: {
SplashScreen: {
launchShowDuration: 2000,
launchAutoHide: true,
backgroundColor: "#111827",
androidSplashResourceName: "splash",
showSpinner: false,
},
PushNotifications: {
presentationOptions: ["badge", "sound", "alert"],
},
Camera: {
// iOS: requestPermissions before calling .getPhoto
},
StatusBar: {
style: "Dark",
backgroundColor: "#ffffff",
},
},
}
export default config
Core Plugins
// src/lib/native/camera.ts — Camera plugin wrapper
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"
import { Capacitor } from "@capacitor/core"
export async function takeOrPickPhoto(): Promise<string | null> {
// Request permission on first use
if (Capacitor.getPlatform() !== "web") {
const permissions = await Camera.requestPermissions({ permissions: ["photos", "camera"] })
if (permissions.photos === "denied" || permissions.camera === "denied") {
throw new Error("Camera permission denied")
}
}
const image = await Camera.getPhoto({
quality: 85,
allowEditing: false,
resultType: CameraResultType.DataUrl, // base64 data URL
source: CameraSource.Prompt, // Prompt: camera or photos
width: 1200,
height: 1200,
correctOrientation: true,
})
return image.dataUrl ?? null
}
export async function captureReceipt(): Promise<string | null> {
const image = await Camera.getPhoto({
quality: 90,
resultType: CameraResultType.Base64,
source: CameraSource.Camera,
direction: "REAR",
})
return image.base64String ? `data:image/jpeg;base64,${image.base64String}` : null
}
// src/lib/native/storage.ts — Preferences (key-value) + Filesystem
import { Preferences } from "@capacitor/preferences"
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"
// Preferences: simple key-value (replaces localStorage with native backing)
export const storage = {
async get<T>(key: string): Promise<T | null> {
const { value } = await Preferences.get({ key })
return value ? JSON.parse(value) : null
},
async set<T>(key: string, value: T): Promise<void> {
await Preferences.set({ key, value: JSON.stringify(value) })
},
async remove(key: string): Promise<void> {
await Preferences.remove({ key })
},
}
// Filesystem: write files to Documents/Cache directories
export async function saveDownloadedFile(
filename: string,
data: string
): Promise<string> {
const result = await Filesystem.writeFile({
path: filename,
data,
directory: Directory.Documents,
encoding: Encoding.UTF8,
recursive: true, // Create directories
})
return result.uri // Native file URI
}
export async function readLocalFile(path: string): Promise<string> {
const contents = await Filesystem.readFile({
path,
directory: Directory.Documents,
encoding: Encoding.UTF8,
})
return contents.data as string
}
// src/lib/native/notifications.ts — Push Notifications
import { PushNotifications } from "@capacitor/push-notifications"
import { LocalNotifications } from "@capacitor/local-notifications"
import { Capacitor } from "@capacitor/core"
export async function registerForPushNotifications(): Promise<string | null> {
if (!Capacitor.isPluginAvailable("PushNotifications")) return null
const permission = await PushNotifications.requestPermissions()
if (permission.receive !== "granted") return null
return new Promise((resolve, reject) => {
PushNotifications.addListener("registration", token => {
resolve(token.value)
})
PushNotifications.addListener("registrationError", err => {
reject(err)
})
PushNotifications.register()
})
}
// Handle foreground notifications
PushNotifications.addListener("pushNotificationReceived", notification => {
// Show as local notification when app is foreground
LocalNotifications.schedule({
notifications: [{
id: Date.now(),
title: notification.title ?? "Notification",
body: notification.body ?? "",
extra: notification.data,
}],
})
})
Custom Native Plugin
// src/plugins/biometric/index.ts — TypeScript interface
import { registerPlugin } from "@capacitor/core"
export interface BiometricAuthPlugin {
isAvailable(): Promise<{ available: boolean; biometryType: string }>
authenticate(options: { reason: string }): Promise<{ authenticated: boolean }>
}
const BiometricAuth = registerPlugin<BiometricAuthPlugin>("BiometricAuth", {
// Web fallback
web: () => ({
isAvailable: async () => ({ available: false, biometryType: "none" }),
authenticate: async () => ({ authenticated: false }),
}),
})
export { BiometricAuth }
// ios/App/App/BiometricAuthPlugin.swift — Swift implementation
import Capacitor
import LocalAuthentication
@objc(BiometricAuthPlugin)
public class BiometricAuthPlugin: CAPPlugin {
@objc func isAvailable(_ call: CAPPluginCall) {
let context = LAContext()
var error: NSError?
let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
let biometryType: String
if available {
biometryType = context.biometryType == .faceID ? "faceID" : "touchID"
} else {
biometryType = "none"
}
call.resolve(["available": available, "biometryType": biometryType])
}
@objc func authenticate(_ call: CAPPluginCall) {
let reason = call.getString("reason") ?? "Authenticate to continue"
let context = LAContext()
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
) { success, error in
call.resolve(["authenticated": success])
}
}
}
Geolocation with Platform Check
// src/lib/native/location.ts — Geolocation
import { Geolocation, type Position } from "@capacitor/geolocation"
import { Capacitor } from "@capacitor/core"
export async function getCurrentLocation(): Promise<{ lat: number; lng: number }> {
if (Capacitor.isNativePlatform()) {
const permission = await Geolocation.requestPermissions()
if (permission.location === "denied") {
throw new Error("Location permission denied")
}
}
const position: Position = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 10_000,
})
return {
lat: position.coords.latitude,
lng: position.coords.longitude,
}
}
For the React Native alternative that uses native components instead of a WebView — better for apps requiring native UI fidelity, complex animations, or frame-rate-sensitive interactions that a WebView won’t match, see the React Native New Architecture guide for JSI and TurboModules. For the Expo Router alternative that builds on React Native with a managed workflow and file-based routing — better for teams that want React Native’s full native UI without managing Xcode/Android Studio builds, the React Native Expo guide covers the Expo SDK. The Claude Skills 360 bundle includes Capacitor skill sets covering native plugins, push notifications, and store deployment. Start with the free tier to try Capacitor configuration generation.