You have a Vue app that works in the browser and someone wants it on their phone, from the store, with an icon. You do not need to learn Swift or Kotlin for that. Capacitor, maintained by the Ionic team, builds a native iOS and Android project around a web view (the phone's browser engine, embedded in an app) that loads your built app from the device. It also gives you a JavaScript API to the camera, haptics, the file system and whatever else a plugin covers.
You will end with your Vite-built app running on a phone, calling the camera, and reloading as you edit, plus a list of what the App Store and Google Play ask of you. The commands are for Capacitor 7.4, the current release, with Vue 3.5 and Vite 7.
How Capacitor wraps a web app
A Capacitor app has three parts:
- Your web app, built to static files.
- A native project per platform,
ios/andandroid/, which you own and can open in Xcode or Android Studio. - A bridge: a web view in each native project that loads your files from the device and lets JavaScript call native code through plugins.
Nothing about your Vue code changes. Routing, Pinia, your components: all the same. What changes is where the files come from (the app bundle, not a server) and what APIs exist (Camera.getPhoto() next to fetch).
The trade-off to know going in: the UI is still HTML and CSS, rendered by WebKit on iOS and by the Android WebView, so it looks like your web app rather than a native control kit. For most apps that is exactly right.
Requirements: Node, Xcode and Android Studio
The environment setup page for Capacitor 7 lists what you need, and the 7.0 upgrade guide gives the version floors:
- Node.js 20 or newer. Vite 7 wants 20.19 or 22.12 and above, so install the current LTS and both are happy.
- For iOS: a Mac, Xcode 16.0 or newer with its command line tools (
xcode-select --install), and CocoaPods, which the page recommends installing through Homebrew. Apps target iOS 14 and up. - For Android: Android Studio Ladybug (2024.2.1) or newer, which ships the JDK 21 that Capacitor 7 needs, and at least one Android SDK platform at API 23 or higher. Capacitor 7 projects compile against API 35.
You can build and run the Android app from any operating system. The iOS app needs the Mac; there is no way around Apple's toolchain.
Install Capacitor and create the native projects
In the root of the Vue project:
npm install @capacitor/core
npm install -D @capacitor/cli
npx cap init "Habits" school.vuejs.habits --web-dir dist
The two arguments are the app's display name and its ID in reverse-domain form. The ID becomes the bundle identifier on iOS and the package name on Android, and it is painful to change later, so pick the real one now. --web-dir dist tells Capacitor where Vite writes the build. The command creates capacitor.config.ts (or capacitor.config.json if the project has no TypeScript installed):
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'school.vuejs.habits',
appName: 'Habits',
webDir: 'dist',
}
export default config
Then add the platforms you want. Each one is a package plus a generated native project:
npm install @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios
You now have android/ and ios/ folders. Commit them. They are source, not build output: you will edit them when a plugin needs a permission string, and the stores will need signing configuration in them.
One requirement on your index.html: it must have a <head> element, because the bridge injects its script there. Vite's template has one.
Run the app on a simulator or a phone
The loop you will repeat is build, sync, run:
npm run build
npx cap sync
npx cap run ios
npm run buildwritesdist/.npx cap synccopiesdist/into both native projects and installs any native dependencies your plugins need (it runspod installon the iOS side).npx cap run iosbuilds the native app and deploys it to a target. With no target it asks you to choose from the simulators and connected devices;npx cap run ios --listprints them so you can pass--targetnext time.
cap run runs a sync of its own first (--no-sync skips it), so step 2 can be left out when you deploy with cap run. Keep it when you build from the IDE instead: cap open, below, copies nothing. Swap ios for android and the same commands drive an emulator or a phone with USB debugging on.
When you want the IDE, for signing, logs or a device that refuses to appear, open the project there instead:
npx cap open ios
npx cap open android
That opens the .xcworkspace in Xcode or the project in Android Studio, and you press Run. The first iOS run on a physical device will ask you to sign in with an Apple ID in Xcode and to trust the developer certificate on the phone; a free account is enough for development.
Add a camera button with a web fallback
Plugins are npm packages with a native side. The Camera plugin is the usual first one:
npm install @capacitor/camera
npx cap sync
iOS requires a reason string for each capability. Add three keys to ios/App/App/Info.plist: NSCameraUsageDescription, NSPhotoLibraryUsageDescription and NSPhotoLibraryAddUsageDescription, each with a sentence the user will see in the permission prompt. Android needs no extra permission for taking a photo unless you save it to the gallery.
The component below takes a photo on a device and picks one from the file system in a browser:
<script setup lang="ts">
import { ref } from 'vue'
import { Capacitor } from '@capacitor/core'
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
const photoUrl = ref<string | null>(null)
const error = ref<string | null>(null)
const isNative = Capacitor.isNativePlatform()
async function takePhoto() {
error.value = null
try {
const photo = await Camera.getPhoto({
quality: 80,
resultType: CameraResultType.Uri,
source: isNative ? CameraSource.Camera : CameraSource.Photos,
})
photoUrl.value = photo.webPath ?? null
} catch (e) {
error.value = e instanceof Error ? e.message : 'Could not get a photo'
}
}
</script>
<template>
<button type="button" @click="takePhoto">
{{ isNative ? 'Take a photo' : 'Choose a photo' }}
</button>
<img v-if="photoUrl" :src="photoUrl" alt="Your photo" />
<p v-if="error" role="alert">{{ error }}</p>
</template>
What the lines do:
Capacitor.isNativePlatform()istrueinside the iOS or Android app andfalsein a browser.Capacitor.getPlatform()gives you'ios','android'or'web'when you need the distinction.- On a device,
CameraSource.Cameraopens the system camera, andresultType: CameraResultType.Urireturns awebPaththe web view can load straight into animg. - In a browser,
CameraSource.Photosopens a file picker andwebPathis ablob:URL for the chosen file.CameraSource.Camerawould first look for the camera modal from PWA Elements, a separate package, and without it log an error and fall back to a file input. ChoosingPhotosin the browser skips the error and opens the picker straight away. - The
catchmatters: cancelling the camera rejects the promise with a message rather than returning nothing, and your UI should not treat that as a crash.
Because the plugin ships this web implementation, the component also works under the normal vite dev server. Develop the layout in the browser, test the camera on the device.
Use live reload on the device
Building and syncing after every change is slow. The live reload guide shows the alternative: point the native app's web view at your Vite dev server instead of the bundled files. Start Vite so it listens on your network address, not just localhost:
npm run dev -- --host
Vite prints a Network: URL such as http://192.168.1.20:5173. Pass its address and port to cap run:
npx cap run android --live-reload --host 192.168.1.20 --port 5173
The phone must be on the same Wi-Fi as your computer. Now edits hot-reload inside the native app exactly as they do in the browser, and plugin calls still go to the real device. Keep the --port flag: without it the CLI assumes port 3000, not Vite's 5173.
The flags write a temporary server.url into the native project's copy of the config, and cap run restores that copy when you stop it with Ctrl+C. You could write the same thing into capacitor.config.ts as server: { url: 'http://192.168.1.20:5173', cleartext: true }, where cleartext allows plain HTTP. But then it is easy to commit by mistake, and a committed server.url ships an app that loads from a laptop that is not there. Keep it on the command line.
Publish to the App Store and Google Play
A release build is the same npm run build and npx cap sync, followed by the platform's own release process, which the iOS and Android deployment pages describe as no different from any other native app's:
- Icons and splash screens. The stores reject an app without a full icon set. The
@capacitor/assetspackage generates every size for both platforms withnpx @capacitor/assets generate, from source images in anassets/folder: an icon of at least 1024 by 1024 pixels and a splash image of at least 2732 by 2732. - iOS: an Apple Developer Program membership, an archive built in Xcode with your distribution certificate, uploaded to App Store Connect, tested through TestFlight, then submitted for review.
- Android: a Google Play Console account and an upload key in a keystore file. Build a release bundle (
.aab) from Android Studio, or withnpx cap build androidand your keystore, and upload it to the Play Console. Google keeps the key that signs the app for users (Play App Signing), so a lost upload key can be reset.
Plan a day for the first submission on each store, most of it spent on forms, screenshots and privacy declarations rather than code. After that, each release is a build, a sync and an upload.