Building Hybrid Apps with Capacitor: Native Plugins and Web-to-App Migration
Capacitor by Ionic bridges the gap between web and native mobile development. It wraps web applications in a native shell while providing access to native device features through a plugin system. This guide covers production Capacitor development, from native plugin creation to migrating existing web apps.
What is Capacitor?
Capacitor is a cross-platform native runtime that runs web apps natively on iOS, Android, and the web. Unlike Cordova, Capacitor:
- Uses modern web standards (ES modules, async/await)
- Integrates with existing native tooling (Xcode, Android Studio)
- Supports Swift and Kotlin plugins natively
- Has a much smaller runtime footprint
Setting Up Capacitor
New Project Setup
npm init @capacitor/app my-app
cd my-app
npm install @capacitor/core @capacitor/cli
npx cap init
# Add platforms
npx cap add ios
npx cap add android
Existing Web App Integration
cd existing-web-app
npm install @capacitor/core @capacitor/cli
npx cap init "My App" com.example.myapp --web-dir dist
# Build your web app first
npm run build
# Sync to native projects
npx cap sync
capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.example.myapp',
appName: 'My App',
webDir: 'dist',
server: {
androidScheme: 'https',
// For development
// url: 'http://localhost:5173',
// cleartext: true,
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
launchAutoHide: true,
backgroundColor: '#ffffff',
iosSpinnerStyle: 'small',
showSpinner: false,
},
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert'],
},
LocalNotifications: {
smallIcon: 'ic_stat_icon_config_sample',
iconColor: '#488AFF',
},
},
ios: {
contentInset: 'automatic',
allowsLinkPreview: false,
},
android: {
allowMixedContent: false,
captureInput: true,
},
};
export default config;
Using Official Capacitor Plugins
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Filesystem, Directory } from '@capacitor/filesystem';
import { Geolocation } from '@capacitor/geolocation';
import { PushNotifications } from '@capacitor/push-notifications';
import { Haptics, ImpactStyle } from '@capacitor/haptics';
// Camera with fallback
async function capturePhoto(): Promise<string> {
const photo = await Camera.getPhoto({
resultType: CameraResultType.DataUrl,
source: CameraSource.Camera,
quality: 90,
allowEditing: false,
});
return photo.dataUrl ?? '';
}
// File system operations
async function saveFile(filename: string, data: string): Promise<void> {
await Filesystem.writeFile({
path: filename,
data,
directory: Directory.Documents,
encoding: 'utf8',
});
}
// Geolocation
async function getCurrentPosition() {
const position = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 10000,
});
return {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy,
};
}
// Haptic feedback
async function triggerHaptic() {
await Haptics.impact({ style: ImpactStyle.Medium });
}
Creating Custom Native Plugins
Define the Plugin Interface
// src/plugins/my-plugin/definitions.ts
export interface MyPluginPlugin {
echo(options: { value: string }): Promise<{ value: string }>;
getBatteryInfo(): Promise<BatteryInfo>;
watchBattery(callback: (info: BatteryInfo) => void): Promise<string>;
removeListener(listenerId: string): Promise<void>;
}
export interface BatteryInfo {
level: number;
isCharging: boolean;
temperature?: number;
}
iOS Implementation (Swift)
// ios/App/Plugins/MyPlugin/MyPlugin.swift
import Foundation
import Capacitor
@objc(MyPlugin)
public class MyPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "MyPlugin"
public let jsName = "MyPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "echo", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getBatteryInfo", returnType: CAPPluginReturnPromise),
]
@objc func echo(_ call: CAPPluginCall) {
let value = call.getString("value") ?? ""
call.resolve(["value": value])
}
@objc func getBatteryInfo(_ call: CAPPluginCall) {
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel
let isCharging = UIDevice.current.batteryState == .charging
|| UIDevice.current.batteryState == .full
call.resolve([
"level": Int(level * 100),
"isCharging": isCharging,
])
}
}
Android Implementation (Kotlin)
// android/app/src/main/java/com/example/app/MyPlugin.kt
package com.example.app
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.JSObject
@CapacitorPlugin(name = "MyPlugin")
class MyPlugin : Plugin() {
@PluginMethod
fun echo(call: PluginCall) {
val value = call.getString("value") ?: ""
val ret = JSObject()
ret.put("value", value)
call.resolve(ret)
}
@PluginMethod
fun getBatteryInfo(call: PluginCall) {
val batteryIntent = context.registerReceiver(
null,
IntentFilter(Intent.ACTION_BATTERY_CHANGED)
)
val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
val batteryPct = if (level != -1 && scale != -1) level * 100 / scale.toFloat() else -1f
val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING
|| status == BatteryManager.BATTERY_STATUS_FULL
val ret = JSObject()
ret.put("level", batteryPct.toInt())
ret.put("isCharging", isCharging)
call.resolve(ret)
}
}
Migrating from Cordova to Capacitor
# Install migration tool
npm install -g @capacitor/cordova-migration
# Run migration
capacitor-cordova-migration migrate
Manual steps:
- Replace Cordova plugins with Capacitor equivalents
- Update
config.xmlsettings tocapacitor.config.ts - Update JavaScript API calls
- Test each native feature
Live Reload for Development
// capacitor.config.ts - development config
const config: CapacitorConfig = {
appId: 'com.example.myapp',
appName: 'My App',
webDir: 'dist',
server: {
url: 'http://192.168.1.100:5173', // Your local IP
cleartext: true,
},
};
# Start dev server
npm run dev
# Open in native IDE
npx cap open ios
npx cap open android
Push Notifications with Capacitor
import { PushNotifications } from '@capacitor/push-notifications';
async function registerPushNotifications() {
let permStatus = await PushNotifications.checkPermissions();
if (permStatus.receive === 'prompt') {
permStatus = await PushNotifications.requestPermissions();
}
if (permStatus.receive !== 'granted') {
throw new Error('Push notification permission denied');
}
await PushNotifications.register();
}
// Listen for registration token
PushNotifications.addListener('registration', (token) => {
console.log('Push registration success:', token.value);
sendTokenToServer(token.value);
});
// Handle push notification received
PushNotifications.addListener('pushNotificationReceived', (notification) => {
console.log('Push received:', notification);
showInAppNotification(notification.title, notification.body);
});
// Handle tap on notification
PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
const { data } = action.notification;
navigateToScreen(data.screen);
});
Conclusion
Capacitor provides an excellent bridge between web development skills and native mobile delivery. Its plugin architecture makes it straightforward to access native APIs while keeping your web app as the primary codebase. For teams with existing web applications or strong web expertise, Capacitor offers a pragmatic path to cross-platform mobile apps without sacrificing native capabilities.