ionic-svelte & Capacitor: Build Native Mobile Features Fast



Building Native Mobile Features with Capacitor and ionic-svelte

Cross-platform mobile development · Capacitor · ionic-svelte · SvelteKit · Native APIs

There’s a persistent myth in the JavaScript ecosystem that building a real, native-feeling mobile app requires either React Native or a full surrender to Swift and Kotlin.
That myth doesn’t survive contact with ionic-svelte and Capacitor.
Together, they give you a remarkably capable stack for cross-platform Svelte mobile development — one where you write Svelte components, ship to iOS and Android, and access real device hardware without touching a line of platform-native code.

This guide is for developers who know Svelte, are comfortable with the terminal, and want a direct path to a working app with camera access, geolocation, and properly handled native permissions. No fluff, no “just clone this starter” hand-waving — actual code, actual reasoning, actual gotchas.

Why ionic-svelte + Capacitor Is a Serious Stack

ionic-svelte is a community-maintained library that ports the full suite of Ionic UI components to Svelte. Think <ion-button>, <ion-card>, <ion-tabs> — the entire polished, accessible, platform-adaptive component set that Ionic users know — but driven by Svelte’s reactivity instead of Angular or React. The components behave identically to their counterparts in the official Ionic framework: they adapt their visual style to iOS or Android automatically, respond to Svelte stores, and integrate with Svelte’s lifecycle hooks without friction.

Capacitor, maintained by the Ionic team, is the bridge between your web code and the native device layer. It is not Cordova. It does not wrap your app in an aging WebView with a plugin ecosystem frozen in 2017. Capacitor uses modern WebView APIs, ships with first-party plugins maintained by the same team, exposes a clean TypeScript interface to native functionality, and — critically — lets you drop into native code when you need to without abandoning your web stack. For Svelte mobile app native development, it is the most pragmatic option available today.

The combination is genuinely underrated. You get Svelte’s compile-time reactivity (zero virtual DOM overhead, small bundles), Ionic’s battle-tested mobile UI, and Capacitor’s reliable native bridge — all in one project. If you’ve been building SvelteKit mobile development workflows and wondering how far they can stretch, the answer is: far enough to ship to the App Store and Google Play.

Project Setup: ionic-svelte + Capacitor from Zero

Setting up ionic-svelte Capacitor correctly from the start saves hours of debugging later. The scaffolding is straightforward, but the configuration details matter — particularly the webDir setting in Capacitor’s config, which must match your build output directory exactly.

Start with the official ionic-svelte starter or scaffold a Vite-based Svelte project and layer in the dependencies manually. The manual approach gives you more control and is worth understanding:

# 1. Scaffold a Svelte + Vite project
npm create vite@latest my-ionic-app -- --template svelte-ts
cd my-ionic-app
npm install

# 2. Install ionic-svelte and Ionic core
npm install ionic-svelte
npm install @ionic/core

# 3. Install Capacitor core and CLI
npm install @capacitor/core
npm install -D @capacitor/cli

# 4. Initialise Capacitor
npx cap init "MyApp" "com.example.myapp" --web-dir dist

# 5. Add target platforms
npx cap add android
npx cap add ios

In your capacitor.config.ts, verify that webDir points to dist (Vite’s default output). If you’re using SvelteKit with Capacitor, you’ll need the @sveltejs/adapter-static and set webDir to build or whichever directory the static adapter targets. A common silent failure is Capacitor syncing a stale or empty build directory — always run npm run build before npx cap sync.

Now wire up ionic-svelte in your main.ts. You need to initialise Ionic before mounting your Svelte app — the order matters because Ionic registers its custom elements against the DOM, and Svelte needs those elements to exist when components render:

// main.ts
import { setupIonicSvelte } from 'ionic-svelte';
import 'ionic-svelte/components/all'; // or import selectively
import '@ionic/core/css/ionic.bundle.css';
import App from './App.svelte';

setupIonicSvelte();

const app = new App({
  target: document.getElementById('app')!,
});

export default app;
💡 Performance tip: Instead of importing all components, import only what you use — e.g., import 'ionic-svelte/components/IonButton'. This keeps your bundle lean, which matters for mobile load times on mid-range devices.

Accessing Native Device APIs: Camera and Geolocation

This is where things get interesting. Capacitor plugins Svelte integration follows a consistent pattern: install the plugin, check permissions, request permissions if needed, call the API, handle the result reactively. Once you understand this pattern for one plugin, you understand it for all of them. Let’s walk through both @capacitor/camera and @capacitor/geolocation in a single component so you can see how they coexist.

Install both plugins first:

npm install @capacitor/camera @capacitor/geolocation
npx cap sync

The npx cap sync step is non-negotiable. It copies the plugin native code into your Android and iOS projects. Skipping it means the JavaScript side of the plugin will initialise but the native side won’t exist, which produces errors that look deceptively like JavaScript bugs.

Here is a complete ionic-svelte camera example with geolocation support, written as a single .svelte component. Notice how Svelte’s reactivity handles state cleanly — no setState, no dispatch, just stores and assignments:

<!-- NativeFeatures.svelte -->
<script lang="ts">
  import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
  import { Geolocation } from '@capacitor/geolocation';
  import { onMount } from 'svelte';

  // --- State ---
  let photoDataUrl: string | null = null;
  let location: { lat: number; lng: number } | null = null;
  let cameraPermission: string = 'prompt';
  let geoPermission: string = 'prompt';
  let error: string | null = null;
  let loading = false;

  // --- Permission check on mount ---
  onMount(async () => {
    try {
      const camStatus = await Camera.checkPermissions();
      cameraPermission = camStatus.camera;

      const geoStatus = await Geolocation.checkPermissions();
      geoPermission = geoStatus.location;
    } catch (e) {
      // On web/browser, permission APIs may not exist — handle gracefully
      console.warn('Permission check not available in this environment:', e);
    }
  });

  // --- Camera ---
  async function takePhoto() {
    error = null;
    loading = true;
    try {
      if (cameraPermission !== 'granted') {
        const requested = await Camera.requestPermissions({ permissions: ['camera'] });
        cameraPermission = requested.camera;
      }

      if (cameraPermission !== 'granted') {
        error = 'Camera permission denied. Please enable it in device settings.';
        return;
      }

      const photo = await Camera.getPhoto({
        resultType: CameraResultType.DataUrl,
        source: CameraSource.Camera,
        quality: 85,
        allowEditing: false,
      });

      photoDataUrl = photo.dataUrl ?? null;
    } catch (e: any) {
      error = e?.message ?? 'Camera error occurred.';
    } finally {
      loading = false;
    }
  }

  // --- Geolocation ---
  async function getLocation() {
    error = null;
    loading = true;
    try {
      if (geoPermission !== 'granted') {
        const requested = await Geolocation.requestPermissions();
        geoPermission = requested.location;
      }

      if (geoPermission !== 'granted') {
        error = 'Location permission denied. Please enable it in device settings.';
        return;
      }

      const coords = await Geolocation.getCurrentPosition({
        enableHighAccuracy: true,
        timeout: 10000,
      });

      location = {
        lat: coords.coords.latitude,
        lng: coords.coords.longitude,
      };
    } catch (e: any) {
      error = e?.message ?? 'Geolocation error occurred.';
    } finally {
      loading = false;
    }
  }
</script>

<ion-content class="ion-padding">
  <ion-card>
    <ion-card-header>
      <ion-card-title>Native Device Features</ion-card-title>
    </ion-card-header>

    <ion-card-content>
      {#if error}
        <ion-text color="danger"><p>⚠ {error}</p></ion-text>
      {/if}

      <!-- Camera section -->
      <ion-button expand="block" on:click={takePhoto} disabled={loading}>
        {loading ? 'Working...' : '📷 Take Photo'}
      </ion-button>

      {#if photoDataUrl}
        <img src={photoDataUrl} alt="Captured photo" style="width:100%; margin-top:1rem; border-radius:8px;" />
      {/if}

      <!-- Geolocation section -->
      <ion-button expand="block" color="secondary" on:click={getLocation} disabled={loading} style="margin-top:1rem">
        {loading ? 'Working...' : '📍 Get Location'}
      </ion-button>

      {#if location}
        <ion-item>
          <ion-label>
            <h3>Current Position</h3>
            <p>Lat: {location.lat.toFixed(6)}</p>
            <p>Lng: {location.lng.toFixed(6)}</p>
          </ion-label>
        </ion-item>
      {/if}
    </ion-card-content>
  </ion-card>
</ion-content>

A few things worth noting in this code: the CameraResultType.DataUrl option returns a base64-encoded image string, which you can bind directly to an <img> src — convenient for previewing without saving to disk. If you need to upload the image, prefer CameraResultType.Uri and use the Filesystem plugin to read the bytes. Also, the loading boolean driving the disabled state on both buttons prevents double-taps and race conditions — a small touch that matters on mobile where tap feedback is slower than a desktop click.

Handling Native Permissions the Right Way

Native permissions in Svelte apps are where a lot of developers cut corners and pay for it in App Store reviews. Both Apple and Google have tightened their permission guidelines considerably: you must explain why you need a permission before requesting it, you must not request permissions at app launch unless strictly necessary, and you must handle the “denied” state without breaking the app. Capacitor’s permission model maps cleanly to these requirements if you use it correctly.

The pattern is always: check → explain → request → handle. The checkPermissions() method returns one of four states: granted, denied, prompt, or prompt-with-rationale. The prompt-with-rationale state is Android-specific and indicates the user has previously denied the permission — Android convention is that you should show an explanation before re-requesting. You can handle this in Svelte with a reactive flag that triggers an <ion-alert> or a simple inline explanation block before the request fires.

Platform-specific permission declarations are also required in native configuration files, and forgetting them is a very common cause of “permission request never fires on device” bugs. For camera geolocation Capacitor use, add the following:

  • Android — in android/app/src/main/AndroidManifest.xml: add <uses-permission android:name="android.permission.CAMERA" /> and <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  • iOS — in ios/App/App/Info.plist: add NSCameraUsageDescription and NSLocationWhenInUseUsageDescription keys with human-readable strings explaining the purpose

The iOS strings appear verbatim in the system permission dialog, so write them as if a non-technical user will read them — because they will. “Required for app functionality” will get your app rejected. “Used to capture photos you share with your team” will not.

Mobile UI with Ionic Components in Svelte

One of ionic-svelte’s most practical contributions is giving you access to Ionic components in Svelte that are genuinely ready for production mobile UIs. These are not HTML elements with CSS cosmetics — they are Web Components that implement platform-specific interaction patterns: swipe gestures, haptic feedback triggers, safe-area insets, keyboard avoidance, and scroll momentum that feels native on both iOS and Android.

Structuring your app with <ion-app>, <ion-header>, <ion-toolbar>, and <ion-content> is not optional ceremony — it’s what activates the platform detection and safe-area handling that Ionic builds in. An <ion-content> element, for example, automatically accounts for the iPhone notch, the Android navigation bar, and the software keyboard, adjusting the scrollable area on the fly. Trying to replicate this with CSS alone is a project in itself.

Svelte’s event handling with Ionic components uses the standard on:click / on:ionChange syntax, but be aware that Ionic custom events like ionChange, ionInput, and ionSlideDidChange require the on: prefix with the full Ionic event name. Svelte’s compiler won’t warn you if you misspell an Ionic event — it will just silently not fire. The Ionic component documentation lists all events for each component and is the authoritative reference.

Building, Syncing, and Running on Device

The development loop for Svelte mobile app native projects is slightly different from pure web development, and streamlining it early prevents a lot of friction. The core loop is: edit Svelte code → build → sync Capacitor → run on device or emulator. Live reload is available and worth setting up — it means you don’t have to re-sync and rebuild for every UI change.

Enable live reload by adding a server block to your capacitor.config.ts pointing to your Vite dev server’s local network address. This tells the native WebView to load from your dev server instead of the bundled files, so hot module replacement works as expected. Native API calls still route through the Capacitor bridge as normal — live reload only affects the web layer:

// capacitor.config.ts (development only — remove before production build)
import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.myapp',
  appName: 'MyApp',
  webDir: 'dist',
  server: {
    url: 'http://192.168.1.100:5173', // your local network IP + Vite port
    cleartext: true,
  },
};

export default config;

For the production build, remove the server block, run npm run build, then npx cap sync, then open the native project in Xcode or Android Studio with npx cap open ios or npx cap open android. From there, standard native build and signing processes apply. Capacitor doesn’t abstract away the native build toolchain — you’re expected to use Xcode and Android Studio for final builds, which is actually a strength: you get full access to native debugging, profiling, and signing without fighting a build abstraction layer.

If you’re targeting only Android during initial development, the npx cap run android command can deploy directly to a connected device or emulator without opening Android Studio, which speeds up the iteration cycle considerably. The equivalent npx cap run ios requires a Mac with Xcode installed.

Extending with Additional Capacitor Plugins

Camera and geolocation are the entry points, but the native device APIs Svelte ecosystem through Capacitor extends well beyond them. The official plugin suite covers filesystem access, push notifications, local notifications, haptic feedback, network status, share sheets, biometric authentication, clipboard access, app state tracking (foreground/background), and more. Every plugin follows the same installation and permission pattern you’ve already seen — install, sync, check permissions, call the API.

Community plugins fill gaps the official suite doesn’t cover. The @capacitor-community namespace on npm hosts plugins for Bluetooth, NFC, SQLite, background tasks, and a growing list of device integrations. Quality varies — check GitHub stars, recent commits, and open issue counts before depending on a community plugin in production. The official Capacitor plugin documentation is also worth reading for the sections on writing your own native plugin, which is more approachable than it sounds if you’re comfortable with Swift or Kotlin basics.

One practical pattern for Capacitor plugins Svelte integration: create a lib/native/ directory in your project and write thin wrapper modules for each plugin you use. These wrappers handle the permission check-and-request cycle, catch platform-specific errors, and export a clean async function your components call. This keeps native-layer complexity out of your UI components and makes it easy to mock the wrappers in a browser environment during development — since most Capacitor plugins throw or return empty results when called in a browser without a native context.

Frequently Asked Questions

How do I integrate Capacitor with an ionic-svelte project?

Install ionic-svelte, @ionic/core, @capacitor/core, and @capacitor/cli via npm. Run npx cap init with your app ID and set webDir to your build output directory (dist for Vite, build for SvelteKit with the static adapter). Add your target platforms with npx cap add android and/or npx cap add ios. After every build, run npx cap sync to copy updated web assets and plugin code into the native projects. Initialise ionic-svelte in main.ts before mounting your Svelte app to ensure Ionic’s Web Components are registered before rendering.

How do I access the camera and geolocation using Capacitor in Svelte?

Install @capacitor/camera and @capacitor/geolocation, then run npx cap sync. In your Svelte component, import Camera from @capacitor/camera and Geolocation from @capacitor/geolocation. Use Camera.getPhoto() with CameraResultType.DataUrl to capture an image and bind the returned dataUrl directly to an <img> element. Use Geolocation.getCurrentPosition() to retrieve coordinates. Always check and request permissions before calling either API using checkPermissions() and requestPermissions() on the respective plugin.

How do I handle native permissions in a Capacitor Svelte app?

Each Capacitor plugin that requires native permissions exposes checkPermissions() and requestPermissions() methods. Call checkPermissions() on onMount to determine current permission state (granted, denied, prompt, prompt-with-rationale). Only call requestPermissions() when the user initiates a relevant action — not at app launch. If the permission is denied, direct the user to device settings rather than re-requesting. On iOS, declare permission usage strings in Info.plist; on Android, declare permissions in AndroidManifest.xml. Without these declarations, permission dialogs will not appear on device regardless of your JavaScript code.