Mobile apps with Capacitor
Wrap a built Vue or Nuxt app in native Android and iOS projects, run it on a device, call device APIs through plugins, and keep content clear of the system bars.
What you'll learn
When you finish, you can do these.- 1Add Capacitor to a Vue app and run it on a phone
- 2Call a device feature, like sharing, through a plugin
- 3Ship a Nuxt app as plain files the phone can load
- 4Keep your page clear of the clock and gesture bar
Picture the notes app this track builds, a small Vue app for writing notes. It runs in the browser, yet people look for it in Google Play and the App Store. They also want a Share button that opens the phone's own share sheet, the panel of apps to send a note to. A website on its own cannot go into those stores. Capacitor fixes that without a rewrite: it wraps your built Vue app in real Android and iOS apps.
The Code tab holds that app's files, from Capacitor's settings to the Share button, ShareButton.vue. By the end you can run it on a phone and share a note from it. You need one idea from Installable web apps (PWA): start with a web app people install from the browser, and add a native shell, like Capacitor, only when you need the stores or a device feature.
A native shell around your build
Capacitor wraps your build, the HTML, CSS and JavaScript files npm run build writes to dist/, in native projects. Each app they build is a full-screen web view, a browser with no address bar, that loads those files from inside the app, so your Vue code runs unchanged, with no server.
When the app calls a plugin, such as Share.share() for the share sheet, the call crosses a bridge to native code, Kotlin on Android or Swift on iOS, and the answer comes back as a promise.
Android Studio builds the Android app; Xcode, on a Mac, builds the iOS one. In a project made with create-vue, Vue's official starter, these commands add Capacitor and both platforms:
npm i @capacitor/core
npm i -D @capacitor/cli
npx cap init Notes school.vuejs.notes --web-dir dist
npm i @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios
npx cap init writes capacitor.config.ts, the settings file in the Code tab: the app's name, its id (a domain name written backwards, unique in the stores) and webDir, the folder of your build.
Order matters: npx cap add android copies its project from the @capacitor/android package, and without it the command stops with Could not find the android platform. The android/ and ios/ folders the add commands create are real native projects, so commit them.
After every change, three steps follow: build the web app, copy it into the native projects, build and install the native app. Two commands do it all, and the android script in package.json runs both:
npm run build
npx cap run android
npx cap run starts with npx cap sync, which copies dist/ across and installs the native half of any new plugin. Then it asks for a device: a phone on a cable, or an emulator, a phone simulated on your computer. It never builds your web app, and Android Studio's Run button never syncs: miss a step and the phone shows yesterday's screen.
Device APIs through plugins
Capacitor ships each device API, a function that reaches a phone feature, as a plugin you install. For the notes app's Share button, add @capacitor/share and run npx cap sync. In the Code tab, shareNote(title, text) in src/composables/useShare.ts calls the plugin: it checks whether it can share, then shares:
const { value } = await Share.canShare()
if (!value) {
await navigator.clipboard.writeText(text)
return 'copied'
}
try {
await Share.share({ title, text, dialogTitle: 'Share note' })
return 'shared'
} catch {
return 'cancelled'
}
On a phone, Share.share() opens the system share sheet. In a browser the plugin calls navigator.share, the browser's own share function, and where there is none it throws Share API not available in this browser.
So canShare() asks first, and where sharing is missing the note goes to the clipboard: the same code works in the store app and on your website. Closing the sheet without picking an app throws Share canceled, which the catch turns into a quiet 'cancelled'.
ShareButton.vue calls shareNote() on a tap. In a desktop browser without navigator.share, a click copies the note and shows Copied to the clipboard.
Sharing needs no permission, but the camera and location do. On iOS, each needs a usage description, the reason iOS shows when the app asks, in ios/App/App/Info.plist: NSCameraUsageDescription for the camera. On Android, location also needs <uses-permission> lines in android/app/src/main/AndroidManifest.xml. The app then asks at run time with each plugin's requestPermissions(). Leave out the camera's description and iOS closes the app the moment the camera opens.
Ship a Nuxt app in the shell
The same notes app written in Nuxt, the framework built on Vue, needs the settings in the Code tab's nuxt.config.ts. Nuxt usually renders pages on a server, and the phone has none, so the app must be plain files with an index.html. Set ssr: false to turn server rendering off, and build with nuxt generate, which writes that client-only app to .output/public. Point Capacitor's webDir there:
webDir: '.output/public',
The sync then copies that folder into the app. Server routes (server/api/) stay behind: on the website, Nitro, Nuxt's own server, answers $fetch('/api/notes'), but inside the app nothing does. Call your API on the web by its full address, kept in runtimeConfig.public, the settings the browser can read.
What goes wrong
The first mistake is a habit: a Nuxt app's android script that builds with nuxt build, Nuxt's usual command.
"android": "nuxt build && cap run android"
cap run syncs first, and the sync stops with The web assets directory (./.output/public) must contain an index.html file. A normal Nuxt build renders pages on a server, so that folder holds scripts and images but no page. Generate instead:
"android": "nuxt generate && cap run android"
The sync now finds index.html, and the app opens.
The second mistake shows only on a phone. The Code tab's index.html puts viewport-fit=cover in its viewport tag so the app fills the whole screen, under the phone's system bars: the clock at the top and the gesture bar at the bottom, the strip you swipe to go home. A body rule with only a margin makes no room for them:
body {
margin: 0;
}
On a recent Android phone or an iPhone, the title sits under the clock and the bottom buttons under the gesture bar. Pad the page with the safe-area insets, the space the bars take at each edge. On Android, Capacitor's SystemBars plugin sets the --safe-area-inset-* variables; elsewhere env(), the browser's own value, fills in:
body {
margin: 0;
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
}
The title now starts below the clock, and the buttons end above the gesture bar.
What to remember
- Capacitor puts your built web app inside native Android and iOS projects. Install
@capacitor/androidbeforenpx cap add android, and commit the folders it creates. - The loop is build, sync, run.
npx cap runsyncs but never builds your web app. - Plugins carry calls to device features. Ask
Share.canShare()before sharing in a browser, and declare permissions inInfo.plistandAndroidManifest.xmlbefore you request them. - A Nuxt app needs
ssr: false,nuxt generateandwebDir: '.output/public'. - An app that fills the screen with
viewport-fit=coverpads its page with the safe-area insets.
In your project
Do these in a real project. Each one says how to confirm it worked.- Add Capacitor and the Android platform to a create-vue project, then run
npm run buildandnpx cap run androidand pick an emulator. The app opens on the emulator, and its icon appears in the app list. - Copy
useShare.tsandShareButton.vuefrom the Code tab, install@capacitor/share, build, run and tap Share: the Android share sheet slides up. Then runnpm run dev: in a desktop browser withoutnavigator.share(type it in the console to check), the button shows Copied to the clipboard. - Change a heading, run
npm run build, and press Run in Android Studio. The emulator still shows the old heading. Runnpx cap sync, press Run again, and the new heading appears.