Claude Code for Capacitor: Native Mobile Apps from Web Projects — Claude Skills 360 Blog
Blog / Mobile / Claude Code for Capacitor: Native Mobile Apps from Web Projects
Mobile

Claude Code for Capacitor: Native Mobile Apps from Web Projects

Published: February 27, 2027
Read time: 8 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free