Mobile

Turns a Nuxt app into an iOS and Android app with native tab bars, deep links and sign-in.

Mobile turns a Nuxt app into an iOS and Android app. You list your tabs once, and the app gets a native tab bar on iOS, a web tab bar elsewhere, page titles, safe areas, haptics and deep links. Use it for a Capacitor app that ships as a static build, with no server of its own. To start a new app, copy the Mobile starter instead of setting this up by hand.

The package is @frigear-nu/mobile. It builds on nuxt-capacitor, which writes capacitor.config.json from your Nuxt config. Users sign in through Auth in client mode.

What you get

  • Tabs (on by default): one tab list for the native iOS tab bar, the web tab bar and the page titles. Other modules can add tabs.
  • Layout (on by default): the mobile layout, with a header, the page and a tab bar.
  • Composables and helpers (on by default): useCapacitor(), useNative() for haptics, sheets, dialogs and sharing, and useMobileChrome().
  • Splash screen (opt-in, splashScreen: true): from the native launch image to the first page. Replace it with your own component. See Splash screen.
  • Native behaviour (on by default): safe areas, the iOS type scale, the Android back button and deep links.
  • Sign-in (opt-in): through Auth in client mode. See Sign-in.
  • CLI: frigear-mobile adds the iOS and Android projects and checks your plugins.
ServerClientMobile
Installed for you:@frigear-nu/module-kit, nuxt-capacitor, and @frigear-nu/auth when authentication is configured.You add:@nuxt/ui (4 or later), @capacitor/core and the Capacitor plugins your features use.Optional:@frigear-nu/ui (its useConfirmDialog is the browser fallback for native dialogs) and @frigear-nu/menu (the mobile.tabs menu adds tabs).

Quick setup

Install Mobile and Capacitor

Install the plugins in the app itself, not only in a package it depends on. Capacitor links the native code of the app's own dependencies only. frigear-mobile doctor lists the missing ones.
pnpm add @frigear-nu/mobile @nuxt/ui @capacitor/core @capacitor/app @capacitor/ios
pnpm add -D @capacitor/cli
# optional, for the features that use them:
pnpm add @capgo/capacitor-native-navigation @capacitor/haptics @capacitor/action-sheet @capacitor/dialog \
  @capacitor/share @capacitor/status-bar @capacitor/keyboard @capacitor/splash-screen
# with sign-in, and guards:
pnpm add @frigear-nu/authorization @capacitor/browser @capacitor/preferences @aparajita/capacitor-secure-storage

Add it to nuxt.config.ts

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui', '@frigear-nu/mobile', '@frigear-nu/authorization'],
  css: ['~/assets/css/main.css'],
  mobile: {
    appId: 'nu.frigear.bills', // capacitor appId and, in lowercase, the OAuth redirect scheme
    appName: 'Bills',
    tabs: [
      { id: 'home', to: '/', label: 'Upcoming', icon: 'i-lucide-calendar-clock', sfSymbol: 'calendar.badge.clock' },
      { id: 'bills', to: '/bills', label: 'Bills', icon: 'i-lucide-receipt-text', sfSymbol: 'list.bullet.rectangle' },
      { id: 'account', to: '/account', label: 'Account', icon: 'i-lucide-circle-user-round', sfSymbol: 'person.crop.circle', requiresAuth: true },
    ],
  },
  // Optional sign-in, in client mode. See Sign-in below.
  authentication: {
    mode: 'client',
    client: { issuer: 'https://frigear.nu', clientId: 'bills', scopes: ['openid', 'profile', 'offline_access'] },
  },
  // Guards are the app's (@frigear-nu/authorization): signed in everywhere. The login and callback pages stay open.
  authorization: { redirectTo: '/login' },
  routeRules: {
    '/**': { authenticated: true },
  },
})

The module sets these nuxt-capacitor defaults, and yours win: capacitor.autoSync: false, capacitor.config.appId and appName, and the Keyboard plugin's resize: 'native'.

Wrap the app

app/app.vue
<template>
  <UApp :toaster="useMobileToaster()">
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </UApp>
</template>

Build and run it

nuxt generate                  # the static SPA in .output/public
npx frigear-mobile native      # npx cap add ios / android, URL schemes in Info.plist / AndroidManifest.xml
npx frigear-mobile doctor      # lists missing plugins and URL schemes
npx cap sync && npx cap run ios

A script for the loop: "ios:run": "frigear-mobile native --if-missing && nuxt generate && cap sync ios && cap run ios".

What you should see:frigear-mobile doctor reports no missing plugins, and the app opens in the iOS Simulator.

Options

Options go under the mobile key.

OptionTypeDefaultPurpose
appIdstringcapacitor.config.appIdThe reverse-DNS app id. It also gives the redirect and deep-link scheme.
appNamestringcapacitor.config.appName, else appIdcapacitor.config.appName and the fallback page title.
tabsMobileTabDefinition[][]See Tabs.
authbooleanon when Auth runs in client modeTabs with requiresAuth show only while signed in.
spabooleantrueSets ssr: false, because Capacitor serves a static build.
layoutstring | false'mobile'The name of the shell layout. false for none.
nativeChrome'ios' | 'all' | false'ios'Where the navbar and tab bar are native: iOS, all platforms or never.
keepTabsAlivebooleantrueEach tab's own page stays as it was left between tab switches. See Tab switching.
tabTransitionDurationnumber180The crossfade between tabs with the native tab bar, in ms. 0 for none.
native.statusBarbooleantrueThe status bar text follows the colour mode.
native.keyboardAccessoryBarbooleantrueiOS: the Done bar above the keyboard.
native.switchHapticsbooleantrueA tick when a role="switch" is tapped.
native.backButtonbooleantrueHandles the Android back button.
appearance.iosTypeScalebooleantrueiOS text sizes (17pt body).
appearance.tapHighlightbooleantrueNo grey tap flash, and :active states on iOS.
appearance.toasterOffsetbooleantrueuseMobileToaster() puts toasts at the top, below the navbar.
splashScreenboolean | string | SplashScreenOptionsfalseThe opt-in splash screen: true, a component name, or options.
deepLinks.schemesstring[] | false[appId]Custom schemes routed into the app. false for none.
deepLinks.hostsstring[][]The https hosts of universal links and app links.
dev.lanHostboolean | stringtrueFor MOBILE_LAN builds: detect the LAN address, or fix it with a string.
ios.allowLocalNetworkingbooleantrueSets NSAllowsLocalNetworking in Info.plist, for plain http to the LAN.
doctorbooleantrueWarns about native projects and missing plugins when dev or build starts.

appId. In lowercase, with _ turned into -, it becomes the OAuth redirect and deep-link scheme. Without one, the build warns and uses com.example.app.

auth. It follows Auth's sign-in state. It does not decide which pages need a user.

layout. The layout is also registered as default when the app has none. A layout of the app with the same name wins.

nativeChrome. Native chrome needs @capgo/capacitor-native-navigation. Without it, the web chrome is used.

dev.lanHost. See Device development.

App

The app side of Mobile: sign-in, tabs, page titles, and the composables and components.

Sign-in

When the app configures authentication, Mobile installs Auth and fills in client.native.scheme from appId. For MOBILE_LAN builds it also fills in client.dev.rewriteLoopback. It never sets the mode: the app sets authentication.mode: 'client' itself.

With Auth in server mode the build fails. A Capacitor app cannot send a same-origin session cookie to the API.
  • Nothing is protected by default. Which pages need a user is the app's choice, with Authorization rules such as '/**': { authenticated: true }, or '/staff/**': { permission: 'bills:manage' } for a page that needs a permission. When Auth signs the app in but no rule requires a user, the build warns that every page is public.
  • Tabs default to requiresAuth: false. A tab with requiresAuth: true is hidden while signed out.

The guards only decide what the app shows. The API decides what is allowed.

What the user may do

The app has no server of its own, so useAuthorization() from Authorization runs in client mode. It loads the user's access from the issuer with the bearer access token:

  1. It calls GET /api/auth/me/access on the issuer. The answer lists the user's permissions, scopes and, with the profile scope, roles.
  2. When the issuer has no such endpoint (a 404, or the device is offline), it decodes the permissions claim of the access token instead. This is the case for an issuer such as https://frigear.nu. The claim is read without verifying it, so it only drives what the app shows.

The access is kept in useState, and it loads again on sign-in and sign-out. Menus, widgets and tabs filter again when it does.

app/pages/bills/[id].vue
<script setup lang="ts">
const { can } = useAuthorization()
</script>

<template>
  <UButton v-if="can('bills:update')" label="Edit bill" />
</template>

can() is synchronous and only decides what to show. The token carries only the permissions its scopes covered when it was issued, so ask for the scopes the app needs in client.scopes. The API still checks every call.

Tabs

A tab definition feeds the native tab bar, the web tab bar and the navbar title.

interface MobileTabDefinition {
  id: string // native tab id
  to: string // the tab's root page
  label: string
  labelKey?: string // i18n key
  title?: string // navbar title, default: label
  titleKey?: string
  icon: string // Nuxt UI icon of the web tab bar
  activeIcon?: string
  sfSymbol?: string // native iOS tab
  sfSymbolActive?: string
  androidIcon?: string // drawable, with nativeChrome: 'all'
  requiresAuth?: boolean // default: false (hidden while signed out when true)
  match?: string | string[] // default: `to` and `to/**` ('/' matches only itself)
  largeTitle?: boolean // default true
  order?: number // lower first; tabs without it follow in registration order
}

A module adds its tabs from setup(). A later tab with the same id replaces its fields, so a host can relabel one:

import { extendMobileTabs } from '@frigear-nu/mobile/utils'

extendMobileTabs(nuxt, { id: 'events', to: '/events', label: 'Events', icon: 'i-lucide-calendar', sfSymbol: 'calendar' })

@frigear-nu/mobile/utils also exports defineMobileTab, normalizeTabs, findActiveTab and matchesPath. With Menu, the items of the mobile.tabs menu are added as tabs and replace tabs with the same id. Tab fields such as sfSymbol go in the item's meta.

The build fails on an invalid id, or a tab without to, label or icon. It warns about more than five tabs, two tabs with the same to, a to that is not a page and, with native chrome, a tab without an sfSymbol.

Pages

A page sets its title and chrome in definePageMeta. The title comes from page meta, else the selected tab, else appName.

definePageMeta({
  mobile: {
    title: 'Bill', // or titleKey
    largeTitle: false,
    tabBar: false, // hide the tab bar here (true: show it on a page outside the tabs)
    navbar: false, // no navbar or header
    tab: 'bills', // select this tab
    back: true, // back button (default: on pages below a tab's root page)
  },
})

A page adds buttons to the navbar with actions, such as a settings gear on the home page. On iOS they are native navbar buttons (sfSymbol), elsewhere icon buttons in MobileHeader (icon). They show on that page only.

definePageMeta({
  mobile: {
    actions: [
      { id: 'settings', label: 'Settings', icon: 'i-lucide-settings', sfSymbol: 'gearshape', to: '/settings' },
    ],
  },
})
FieldWhat it is
idStable id.
label / labelKeyAccessible name, and the button text when the platform has no icon for it.
iconNuxt UI icon in MobileHeader, e.g. i-lucide-settings.
sfSymbolSF Symbol of the native iOS button, e.g. gearshape.
androidIconAndroid drawable of the native button (with nativeChrome: 'all').
placementright (default) or left. Left actions sit after the back button.
onSelectCalled on a tap.
toNavigated to on a tap, after onSelect.
disabledGreyed out and not tappable.

For actions that change, or that call into the page, use useMobileNavbarActions() in the page's <script setup> with a ref or a getter. The actions are added in setup and removed on unmount, and while a kept-alive page is in the background. They follow the definePageMeta ones; one with the same id replaces it. With spa: false they show once the page is set up in the browser, not in the server HTML: put actions that must be there in definePageMeta.

const saving = ref(false)
useMobileNavbarActions(() => [
  { id: 'save', label: 'Save', sfSymbol: 'checkmark', icon: 'i-lucide-check', disabled: saving.value, onSelect: save },
])

Composables and components

The composables and components below are auto-imported.

NameWhat it is
useCapacitor()platform (ios, android or web), isNative, isIos, isAndroid, has(plugin) and load(plugin).
useMobileTabs()tabs, label(tab) and title(tab).
useMobileChrome()state, native and suppress(), which hides the tab bar until you call the function it returns.
useMobileNavbarActions(actions)Navbar buttons of the calling page, from a ref or a getter. See Navbar actions.
useNative()See Native features.
onTabEnter(fn)Runs fn when the user comes back to a page kept alive. See Tab switching.
useMobileToaster()Props for <UApp :toaster>.
useMobileSplash()visible, hold(), waitFor(promise) and hide(): see Splash screen.
MobileHeaderThe web header, with the page's navbar actions and leading and trailing slots.
MobileTabBarThe web tab bar.
MobileSplashScreenThe default splash screen. Your own components/Mobile/SplashScreen.vue replaces it.
MobileFabA floating action button: label, icon, to, or items for an action sheet on a device.
mobile layoutHeader, page and tab bar. Your own layouts/mobile.vue replaces it.

Nuxt UI dialogs, slideovers and drawers hide the tab bar on their own; suppress() is for an overlay of your own. In a browser, MobileFab with items opens a dropdown instead of an action sheet.

The CSS variables --mobile-inset-top, --mobile-inset-bottom and --mobile-tabbar-height are the same for native and web chrome. <html> gets the classes mobile-platform-<platform>, mobile-chrome-native or mobile-chrome-web, and mobile-has-tabbar.

Native chrome

On iOS, with @capgo/capacitor-native-navigation installed, the navbar and the tab bar are native. In a browser and on Android the module draws MobileHeader and MobileTabBar instead. nativeChrome: 'all' uses the native ones on Android too, with androidIcon drawables.

Tab switching

Switching tabs works like a native tab bar. The page you leave stays as it was, and the page you open appears only once it is ready.

  • Tab pages stay alive. A tab's own page (its to) stays mounted, with its scroll position and data, when you switch to another tab. Pages below a tab still remount. A page opts out with definePageMeta({ keepalive: false }), and keepTabsAlive: false turns this off for the app.
  • Tab pages load up front. The code of every tab's page is preloaded once the app is ready.
  • No half-built pages with native chrome. A tap in the native tab bar covers the WebView with a snapshot of the old page. The new page renders under it, the navbar title changes, and then the snapshot crossfades away.

The crossfade waits for every async setup of the new page, up to one second. Await a tab page's first request, so the page is revealed with its data and not with a skeleton. Then refresh it in the background with onTabEnter, which runs each time the user comes back to the page, but not on the first visit:

<script setup lang="ts">
const { data: me, status, refresh } = await useApi<Me>('/api/me')
onTabEnter(() => refresh())
</script>

<template>
  <!-- A skeleton only while there is no data yet, not during a background refresh. -->
  <USkeleton v-if="!me && status === 'pending'" class="h-10 w-full" />
</template>
The crossfade uses the plugin's beginTransition, which the starters' version (8.16) has. A plugin version without it switches tabs without a crossfade.

Native features

useNative() wraps Capacitor plugins and falls back in a browser:

MethodPluginIn a browser
haptic('light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error' | 'selection')@capacitor/hapticsDoes nothing.
actionSheet({ actions })@capacitor/action-sheetReturns null: show a menu then.
confirm({ title, description, destructive })@capacitor/dialoguseConfirmDialog of @frigear-nu/ui, or a small modal.
share({ title, text, url })@capacitor/shareThe Web Share API, else the link is copied to the clipboard.

Hooks

Your app answers these runtime hooks with useNuxtApp().hook(...).

HookPayloadWhen
mobile:deep-link{ url, path, preventDefault }A custom scheme or universal link opened the app. OAuth callbacks never reach it.
mobile:back-button{ canGoBack, preventDefault }The Android back button was pressed, before the module acts.

mobile:deep-link. Change path to route elsewhere, or call preventDefault() to handle the link yourself. Deep links need @capacitor/app and a scheme in deepLinks.schemes or a host in deepLinks.hosts. The native command registers them in the native projects.

mobile:back-button. Call preventDefault() when you handled the button.

Module pages

PageWhat it coversFor
CLI and devicesThe frigear-mobile command, testing on a phone, Android and the release checklist.App developers
Splash screenThe opt-in splash screen from the native launch image to the first page: turn it on, replace it, keep it up.App developers

Next steps

Copyright © 2026