← All posts
·17 min read

Claude Code with Capacitor: Hybrid Mobile Apps

Claude CodeCapacitorMobileWorkflow
Claude Code with Capacitor: Hybrid Mobile Apps for iOS and Android

Why Capacitor without CLAUDE.md generates platform-specific bugs

Capacitor is a hybrid mobile runtime. A web app compiled to www/ or your framework's dist folder runs inside a native WebView shell on iOS and Android. Native device capabilities, camera, filesystem, geolocation, push notifications, are accessed through @capacitor/* packages that bridge JavaScript to native APIs. The bridge is what makes Capacitor different from a plain PWA. It also introduces a failure surface that Claude Code does not navigate well without explicit guidance.

Without CLAUDE.md, Claude makes four categories of mistake that produce platform-specific bugs.

The first is skipping cap sync. Every time you add a native plugin or rebuild the web app, you must run npx cap sync to copy the updated web assets and plugin configuration into the ios/ and android/ native project folders. Claude knows this is necessary in theory but omits it in generated shell commands and setup instructions. The result is an app that runs correctly in the browser but uses the old web bundle or has unconfigured plugin native code on device.

The second is generating Cordova-style plugin code. Capacitor is intentionally backwards-compatible with some Cordova patterns, but Cordova plugins are deprecated in Capacitor 6. Claude's training data includes years of Ionic/Cordova content. Without a hard rule, it generates cordova.plugins.camera.getPicture() instead of the correct Camera.getPhoto() from @capacitor/camera. The Cordova call fails silently on modern Capacitor installs where the Cordova bridge is absent.

The third is missing platform permission declarations. iOS requires NSCameraUsageDescription, NSMicrophoneUsageDescription, NSLocationWhenInUseUsageDescription, and similar keys in ios/App/App/Info.plist. Android requires corresponding <uses-permission> entries in android/app/src/main/AndroidManifest.xml. Claude generates the JavaScript plugin call correctly but does not add the platform-specific permission strings. The app crashes on first permission request with a message that gives no indication the Info.plist entry is missing.

The fourth is using capacitor.config.json when capacitor.config.ts is available. The JSON format has no type checking. The TypeScript format, available since Capacitor 3, gives you autocomplete, validated field names, and environment-based configuration. Claude defaults to JSON unless told otherwise.

This guide covers the CLAUDE.md configuration that corrects all four patterns and adds the structural rules that let Claude generate Capacitor code reliably across the web layer, the native plugin layer, and the CI/CD pipeline. If you are coming from a React Native background, Claude Code with React Native covers a comparable anchoring approach. For the Expo-managed workflow alternative, Claude Code with Expo is the relevant contrast.

The Capacitor CLAUDE.md template

Place this at your project root. It is read at the start of every Claude Code session and sets the constraints that prevent the failure modes above.

# Capacitor project rules

## Stack
- Capacitor 6.x, TypeScript 5.x strict
- React 18.x (or Vue 3 / Angular 17; web framework agnostic rules below apply regardless)
- Vite 5.x for web build
- capacitor.config.ts at project root (NEVER capacitor.config.json)
- Node 20.x LTS

## Project structure
- src/                Web app source (React/Vue/Angular components, pages, hooks)
- dist/ or www/       Compiled web output (Capacitor copies this to native shells)
- ios/                Xcode project (native iOS shell, auto-generated by Capacitor)
- android/            Gradle project (native Android shell, auto-generated by Capacitor)
- capacitor.config.ts Runtime configuration (appId, appName, webDir, server options)

## The web/native split: CRITICAL
- NEVER modify files inside ios/ or android/ except for Info.plist, AndroidManifest.xml,
  and build configuration. The Capacitor CLI manages the rest.
- All application logic lives in src/ (web layer). Native shells are deployment targets only.
- ios/ and android/ are .gitignore candidates for teams using Capacitor CLI to regenerate.
  If you commit them, do not manually edit generated files.

## cap sync discipline: MANDATORY
After ANY of the following actions, run: npx cap sync
  - Adding a new @capacitor/* or community plugin (npm install + cap sync)
  - Rebuilding the web app (vite build or npm run build, then cap sync)
  - Changing capacitor.config.ts webDir, appId, or server settings
  - Upgrading Capacitor major/minor versions
NEVER skip cap sync. The native shells will run stale web assets if you do.

## capacitor.config.ts rules
- ALWAYS use TypeScript format. Example:
  import { CapacitorConfig } from '@capacitor/cli';
  const config: CapacitorConfig = {
    appId: 'com.example.app',
    appName: 'My App',
    webDir: 'dist',
    server: {
      androidScheme: 'https',
    },
  };
  export default config;
- webDir must match your build output folder (dist for Vite, www for Angular/Ionic)
- androidScheme: 'https' is required for Android API 30+ cookie/storage compatibility
- NEVER set allowNavigation to wildcard ('*') in production builds

## Native plugin rules, @capacitor/* ONLY
- ALL native device access uses @capacitor/* packages
- NEVER use cordova.plugins.*, window.plugins.*, or any Cordova-style call
- NEVER use navigator.getUserMedia for camera (browser API, unavailable in native context)
- Plugin pattern: import from package, call static method, handle Promise
  import { Camera, CameraResultType } from '@capacitor/camera';
  const photo = await Camera.getPhoto({ resultType: CameraResultType.Uri });
- ALWAYS handle plugin errors: wrap plugin calls in try/catch

## iOS Info.plist permission entries, REQUIRED for each plugin used
Add to ios/App/App/Info.plist BEFORE calling the plugin:
  Camera:       NSCameraUsageDescription (+ NSPhotoLibraryUsageDescription if saving)
  Microphone:   NSMicrophoneUsageDescription
  Location:     NSLocationWhenInUseUsageDescription (and/or NSLocationAlwaysUsageDescription)
  Contacts:     NSContactsUsageDescription
  Biometrics:   NSFaceIDUsageDescription
  Notifications: No Info.plist entry, use Capacitor Push Notifications plugin
NEVER call a plugin on iOS without the matching Info.plist entry. The app crashes.

## Android AndroidManifest.xml permissions, REQUIRED for each plugin used
Add to android/app/src/main/AndroidManifest.xml inside <manifest>:
  Camera:       <uses-permission android:name="android.permission.CAMERA"/>
  Storage:      <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
                <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  Location:     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
  Microphone:   <uses-permission android:name="android.permission.RECORD_AUDIO"/>
NEVER add permissions the app does not use (Play Store policy violation).

## Live reload workflow (development only)
- npx cap run ios --livereload --external (rebuilds + deploys to simulator with HMR)
- Requires the dev machine and simulator/device on the same network
- NEVER use --livereload in a production build or App Store submission
- For Android: npx cap run android --livereload --external

## Hard rules
- NEVER skip npx cap sync after plugin install or web rebuild
- NEVER use Cordova plugin APIs
- NEVER use capacitor.config.json, TypeScript only
- NEVER generate platform code inside src/ (web layer has no direct native file access)
- NEVER call a native plugin without corresponding Info.plist / AndroidManifest.xml entries
- ALWAYS install plugins as: npm install @capacitor/plugin-name, then npx cap sync
- ALWAYS import Capacitor plugins by package name, never from a global object

Three rules carry the most weight.

The cap sync after every plugin rule is the single highest-impact entry. A plugin installed with npm but not synced has JavaScript bindings in node_modules but no native code registered in the Xcode or Gradle projects. The app loads, the import succeeds, and the plugin call returns an error or silently does nothing. This looks like a Capacitor bug but is always a missed sync. Making the rule explicit in CLAUDE.md means Claude includes npx cap sync in every code snippet that installs a plugin.

The Cordova prohibition rule has to be categorical because Claude blends Cordova and Capacitor patterns unless told not to. Cordova is deeply indexed on the web. The rule must name the Cordova global objects specifically: cordova.plugins.*, window.plugins.*, navigator.camera.*. Generic "use Capacitor APIs" is not strong enough because Claude interprets that through a lens that still includes Cordova.

The Info.plist required before call rule prevents the most confusing class of runtime crash. An iOS app that calls Camera.getPhoto() without NSCameraUsageDescription in Info.plist crashes with a system assertion, not a JavaScript error. The crash appears in Xcode's console as a non-descriptive abort. Developers who have not seen it before spend time debugging the JavaScript layer when the fix is a six-line XML entry. The rule forces Claude to add the Info.plist entry in the same response as the plugin call.

Project structure and web/native separation

The Capacitor project has two distinct zones: the web app and the native shells. Understanding this split is what separates a maintainable hybrid app from one that is difficult to debug.

The web app lives entirely in src/. It is a standard web application, built with whatever framework you prefer. Capacitor does not constrain the web layer. You can use React, Vue, Angular, or plain HTML. The web app is unaware it is running inside a mobile WebView until it imports a Capacitor plugin.

The native shells in ios/ and android/ are Xcode and Gradle projects that Capacitor generates. They contain a WebView configured to load your compiled web app, a plugin registry where installed @capacitor/* packages register their native implementations, and platform-specific configuration files (Info.plist, AndroidManifest.xml, build.gradle).

Add a project structure section to CLAUDE.md to be explicit about what lives where:

## Where files belong

### Web layer (src/), your code
src/pages/         Route-level components
src/components/    Shared UI components
src/hooks/         Custom hooks including useCamera, useGeolocation wrappers
src/services/      Capacitor plugin wrappers (abstracts platform calls)
src/assets/        Static assets (Vite handles optimisation)

### Capacitor layer (root)
capacitor.config.ts   Runtime config
package.json           All JS dependencies including @capacitor/* plugins

### iOS native (ios/), Capacitor managed, minimal edits
ios/App/App/Info.plist            Permission strings (you edit this)
ios/App/App/AppDelegate.swift     Plugin registration (Capacitor manages this)
ios/App/Podfile                   CocoaPods dependencies (Capacitor manages this)

### Android native (android/), Capacitor managed, minimal edits
android/app/src/main/AndroidManifest.xml   Permissions (you edit this)
android/app/build.gradle                   SDK versions, signing config
android/variables.gradle                   Version numbers shared across modules

### Service wrapper pattern (recommended)
Create src/services/camera.service.ts:
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';

export async function takePhoto() {
  return Camera.getPhoto({
    quality: 90,
    allowEditing: false,
    resultType: CameraResultType.Uri,
    source: CameraSource.Camera,
  });
}

export async function pickFromGallery() {
  return Camera.getPhoto({
    quality: 90,
    allowEditing: false,
    resultType: CameraResultType.Uri,
    source: CameraSource.Photos,
  });
}

This isolates plugin calls to service files. Components import the service,
not the Capacitor plugin directly. Easier to mock in web-only tests.

The service wrapper pattern is worth including in CLAUDE.md because Claude will otherwise place Camera.getPhoto() calls inline in React components or Vue composables. That works but makes the component untestable in a browser environment where the plugin is unavailable. The service layer lets you swap in a mock during development or testing.

capacitor.config.ts rules and server configuration

The capacitor.config.ts file controls how Capacitor boots the WebView, which build output it loads, and how native-to-web navigation is handled. Getting this configuration correct matters more than it might seem because several default values changed between Capacitor 4, 5, and 6.

Add configuration rules to CLAUDE.md:

## capacitor.config.ts full template

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.company.appname',   // Reverse-domain, matches Apple/Google store listing
  appName: 'App Display Name',
  webDir: 'dist',                  // Match your build tool output (dist, www, build, out)

  server: {
    androidScheme: 'https',        // Required for Android API 30+ (cookies, storage)
    // hostname: 'app.domain.com', // Only set this if you need a custom origin for CORS
    // url: 'http://192.168.1.x:5173', // DEV ONLY, live reload target on local network
  },

  plugins: {
    SplashScreen: {
      launchShowDuration: 0,       // Remove splash screen instantly after load
      backgroundColor: '#ffffff',
    },
    // Add per-plugin config here as needed
  },
};

export default config;

## Configuration rules
- appId must be unique globally, use reverse domain notation
- webDir must EXACTLY match your framework's build output folder:
    Vite:    dist
    Angular: www
    Next.js: out (requires static export, next.config.js output: 'export')
    CRA:     build
- androidScheme 'https' is NOT optional on Android 12+, set it unconditionally
- server.url (live reload) must be removed or commented out for production builds
- NEVER commit a config with server.url pointing to localhost (App Store rejection)

## Environment-based config (for CI/production switching)
import { CapacitorConfig } from '@capacitor/cli';

const isDev = process.env.NODE_ENV === 'development';

const config: CapacitorConfig = {
  appId: 'com.company.appname',
  appName: 'App Name',
  webDir: 'dist',
  server: isDev
    ? { url: 'http://192.168.1.100:5173', cleartext: true }
    : { androidScheme: 'https' },
};

export default config;

The androidScheme: 'https' setting is the most commonly forgotten configuration entry in Capacitor 6. Android API 30 (Android 11) changed the default WebView origin from http://localhost to capacitor://localhost. Third-party authentication flows, cookies scoped to HTTPS origins, and localStorage partitioning all behave incorrectly without this setting. It is not in the default generated config in all versions. Claude will sometimes omit it. Putting it in the CLAUDE.md template makes it appear every time.

Native plugin integration: Camera, Filesystem, Geolocation

The three plugins most commonly added to Capacitor apps are also the three that most reliably trigger Info.plist omissions or Cordova-pattern mistakes. The CLAUDE.md entries for each plugin should be explicit about both the JavaScript call and the required platform configuration.

Add plugin patterns to CLAUDE.md:

## @capacitor/camera

Install: npm install @capacitor/camera && npx cap sync

iOS Info.plist entries (REQUIRED):
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to take photos.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app saves photos to your library.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app adds photos to your library.</string>

Android AndroidManifest.xml (inside <manifest>):
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>

Usage (TypeScript):
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';

async function capturePhoto(): Promise<string> {
  const image = await Camera.getPhoto({
    quality: 90,
    allowEditing: false,
    resultType: CameraResultType.DataUrl,  // or Uri, Base64
    source: CameraSource.Camera,
  });
  return image.dataUrl ?? '';
}

## @capacitor/filesystem

Install: npm install @capacitor/filesystem && npx cap sync

iOS Info.plist: No entry required for app-private directories.
For saving to Photos: NSPhotoLibraryAddUsageDescription (see Camera section).

Android AndroidManifest.xml:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
  android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
  android:maxSdkVersion="29"/>

Usage:
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';

async function writeFile(path: string, data: string): Promise<void> {
  await Filesystem.writeFile({
    path,
    data,
    directory: Directory.Data,  // App-private, no permission required
    encoding: Encoding.UTF8,
  });
}

async function readFile(path: string): Promise<string> {
  const result = await Filesystem.readFile({
    path,
    directory: Directory.Data,
    encoding: Encoding.UTF8,
  });
  return result.data as string;
}

## @capacitor/geolocation

Install: npm install @capacitor/geolocation && npx cap sync

iOS Info.plist (REQUIRED, app crashes without this):
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app uses your location to show nearby results.</string>

Android AndroidManifest.xml:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

Usage:
import { Geolocation } from '@capacitor/geolocation';

async function getCurrentPosition() {
  const coords = await Geolocation.getCurrentPosition({
    enableHighAccuracy: true,
    timeout: 10000,
  });
  return { lat: coords.coords.latitude, lng: coords.coords.longitude };
}

## Rules for all plugins
- Install order: npm install first, cap sync second. Never reverse.
- ALWAYS add Info.plist + AndroidManifest entries before writing plugin call code.
- NEVER use Permissions.query() for Capacitor plugins, use the plugin's own
  requestPermissions() method where available.
- Test permission flows on a real device, not simulator (camera) or emulator (GPS).

The permissions ordering rule is critical for Claude. When generating a feature that uses the Camera plugin, Claude tends to write the JavaScript call first and mention the Info.plist entry as an afterthought or omit it entirely. The rule "add Info.plist before writing the call" forces a different generation order that matches the implementation order a developer would follow: configure the platform, then write the code.

Live reload and simulator workflow

Capacitor's live reload mode runs the Vite dev server on your machine and points the native WebView at it over the local network. This means changes to web source files appear on the simulator or device without rebuilding and redeploying the app binary. The plugin bridge still functions in live reload mode, so you can test camera and geolocation interactions as part of the standard development loop.

Add a development workflow section to CLAUDE.md:

## Development workflow

### Web-only development (fastest iteration)
npm run dev
# Opens browser at localhost:5173
# No Capacitor bridge available, plugin calls will fail
# Use for: UI, routing, state management, API integration

### Simulator with live reload (standard native dev loop)
npm run build && npx cap sync    # First time, or after adding a plugin
npx cap run ios --livereload --external
# Vite dev server starts + iOS Simulator opens
# File changes trigger HMR in the simulator
# Plugin calls work: camera, filesystem, etc.
# Requires: Mac, Xcode installed, iOS Simulator

# Android equivalent:
npx cap run android --livereload --external
# Requires: Android Studio, emulator or physical device via ADB

### Production build (for App Store / Play Store)
npm run build          # Compile web app
npx cap sync           # Sync to native projects
npx cap open ios       # Open Xcode for archive + upload
npx cap open android   # Open Android Studio for signed APK/AAB

## Rules
- NEVER use --livereload flag in a build intended for submission
- ALWAYS run 'npm run build && npx cap sync' before opening Xcode or Android Studio
  for a release build, stale web assets are the most common release bug
- server.url in capacitor.config.ts must be absent or commented out in production config
- For physical device live reload: device and dev machine must be on the same WiFi network
- USB live reload (--external not needed): supported on Android via ADB reverse

The npm run build && npx cap sync before every production build rule addresses the second most common Capacitor bug after missing permissions. Developers iterate in live reload mode, then open Xcode and archive without rebuilding. The archived app contains the web bundle from the last explicit build, which may be hours or days old. The rule makes the rebuild explicit.

Build and deployment: iOS Xcode, Android Gradle

Shipping a Capacitor app to the App Store or Play Store involves Xcode or Android Studio for the final build. Claude can generate most of the configuration but needs clear rules about where automated tooling ends and manual configuration begins.

Add a deployment section to CLAUDE.md:

## iOS build and deployment

### Pre-build checklist (run every time)
1. npm run build
2. npx cap sync
3. npx cap open ios
4. Xcode: confirm Bundle Identifier matches capacitor.config.ts appId
5. Xcode: confirm Version and Build Number are correct
6. Xcode: confirm Signing & Capabilities has correct Team selected
7. Xcode: Product > Archive
8. Xcode Organizer: Distribute App > App Store Connect

### Required Xcode configuration (one-time, not managed by Capacitor)
- Signing certificate (Apple Developer Program)
- Provisioning profile (App Store distribution)
- App icons: use capacitor-assets (npx capacitor-assets generate) not manual Xcode asset catalog
- Capabilities: add any capabilities (Push Notifications, Sign in with Apple) in Xcode
  Signing & Capabilities tab, NEVER manually edit entitlements files

### CI/CD for iOS (GitHub Actions with fastlane)
- Store signing certificate + provisioning profile in GitHub Secrets (base64 encoded)
- Use fastlane match for certificate management (not manual certificate export)
- Build with: fastlane gym --scheme "App" --export-method app-store
- Upload with: fastlane deliver or pilot

## Android build and deployment

### Pre-build checklist
1. npm run build
2. npx cap sync
3. npx cap open android (or direct gradle build for CI)
4. Android Studio: Build > Generate Signed Bundle/APK
5. Choose Android App Bundle (AAB) for Play Store (not APK)

### Signing configuration (android/app/build.gradle)
android {
  signingConfigs {
    release {
      storeFile file(System.getenv('KEYSTORE_PATH') ?: 'release.keystore')
      storePassword System.getenv('KEYSTORE_PASSWORD')
      keyAlias System.getenv('KEY_ALIAS')
      keyPassword System.getenv('KEY_PASSWORD')
    }
  }
  buildTypes {
    release {
      signingConfig signingConfigs.release
      minifyEnabled false    // true only if you've tested ProGuard rules
    }
  }
}

### CI/CD for Android (GitHub Actions)
- Store keystore in GitHub Secrets (base64 encoded)
- Build: ./gradlew bundleRelease
- Sign: using signing config above with env vars set in CI
- Upload: google-github-actions/upload-cloud-storage or fastlane supply

## Rules
- NEVER commit keystore files or signing credentials to source control
- NEVER set minifyEnabled true without verifying ProGuard rules preserve plugin classes
- App Store submission: ALWAYS remove server.url from capacitor.config.ts before archiving
- Android AAB is required for Play Store (APK is for direct install / internal testing only)

The signing configuration section is where Claude produces the most variation. Without a template, Claude generates different signing approaches in different sessions: sometimes hardcoded credentials (a security risk), sometimes a local.properties reference (not portable), sometimes no signing config at all. The environment variable approach shown here is the correct pattern for CI/CD and is reproducible across machines and team members.

Common gotchas and manual review checklist

Claude Code generates correct Capacitor code reliably when the CLAUDE.md template is in place. Four areas still warrant manual review.

The cap sync status after adding dependencies. When you ask Claude to add a feature that requires a new plugin, check that it includes npx cap sync in the installation steps. If the session is long and context has accumulated, Claude sometimes generates the npm install without the sync. Verify the sync step is present before running the generated commands.

The webDir setting vs. actual build output. If you change your build tooling (switching from Vite to another bundler, or enabling Next.js static export), the build output folder may change. Claude updates capacitor.config.ts for the new build tool but may not update webDir. A mismatch means Capacitor copies the wrong folder, or nothing, to the native shells. Confirm webDir matches ls dist/ && ls www/ after any build tool change.

The Info.plist string content. Apple App Review reads the permission description strings in Info.plist during review. Vague strings ("This app needs access") are grounds for rejection. Claude generates valid strings but not always specific ones. Review each *UsageDescription value and make it concrete about why the app needs the permission.

The live reload URL left in production config. If you use environment-based config switching in capacitor.config.ts and the environment detection fails, the production build ends up with server.url pointing to a local IP. The published app shows a blank white screen because that IP is unreachable. Run grep -r "server.url\|192.168\|localhost" capacitor.config.ts before every App Store submission.

For the patterns that apply across all mobile development workflows in Claude Code, Claude Code best practices covers the CLAUDE.md discipline and permission hook configuration that complement the Capacitor-specific rules here. The Swift-specific native layer, for cases where you need a Capacitor custom plugin with native Swift code, is in Claude Code with Swift.

Building hybrid apps that work on both platforms

A Capacitor CLAUDE.md anchors Claude to the patterns that make hybrid apps reliable: TypeScript config with type checking, explicit cap sync after every change, @capacitor/* plugins only, platform permission entries before plugin calls, and production builds that start from a clean npm run build.

The underlying principle is the same as any Claude Code integration. Capacitor sits at an intersection of web development and mobile native development. Claude is strong on both sides individually. The problems appear at the boundary: the sync step that the web developer forgets, the Info.plist entry that the native developer would never omit, the Cordova API that looks plausible in search results but is wrong for Capacitor 6. CLAUDE.md bridges that boundary by making the platform-specific rules explicit in every session.

Claudify includes a Capacitor CLAUDE.md template pre-configured with the plugin patterns, permission checklists, and CI/CD build configuration shown in this guide. The Claudify blog covers the same CLAUDE.md anchoring approach for React Native, Flutter, Expo, Swift, and every other major mobile and web framework.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame