mobile
Your customers want the app on their phone, with a home-screen icon, a working Android back button and data that survives the OS clearing its caches. @kitr/mobile is the iOS and Android platform: it wraps the same app/ you ship to the browser in a native shell.
It is built on Capacitor: your client build runs in the system WebView and Capacitor plugins reach native APIs. kitr dev runs the app on a simulator with live reload, and kitr build syncs the bundle into the Xcode and Android Studio projects.
For an installable web app with no store listing, the PWA option of the Web platform is enough. For desktop windows, use Desktop or Tauri.
The examples ship the Acme orders app to phones: Ada's field team checks orders and invoices with the same code the browser runs at app.acme.com.
Install
Add a mobile platform named phone:
kitr add mobile phoneFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
mobile is the platform type and phone is the name you address it by (kitr dev phone).
Everything kitr add changes
What kitr add mobile phone does, in order:
- Adds
@kitr/mobiletopackagesinkitrrc.tsand installs it. The mobile package requires Router (and React before it), Platform, which writesconfig/platform.ts, and Storage, which writesconfig/storage.tsandapp/storage.ts, sokitr addruns their setup first when they are missing. - Installs
@capacitor/core,@capacitor/appand@capacitor/preferences,@capacitor/clias a dev dependency, and one package per target:@capacitor/iosand@capacitor/android. - Adds
() => import('@kitr/mobile/provider')toprovidersinkitrrc.ts. - Writes
phone: mobile({...})intoplatformsinkitrrc.ts, with the flags you passed as options, and addsimport { mobile } from '@kitr/mobile'. - Sets the
dev,buildandstartscripts inpackage.jsontokitr dev,kitr buildandkitr start. - Writes
platforms/phone/capacitor.config.ts(shown under Configure),platforms/phone/package.json(generated; see Sync the plugins) andplatforms/phone/.gitignore, which ignoresios/App/Pods,ios/App/build,android/build,android/app/buildandandroid/.gradle. - Adds a
## kitr: platform phonesection toAGENTS.md: how to run and build, never to sniff user agents, and to wrap other Capacitor plugins innativeOnly().
It does not create the ios/ and android/ projects; the first kitr build phone or kitr dev phone runs cap add for each target.
kitr platforms remove phone takes the platform out of kitrrc.ts. It leaves platforms/phone/, the Capacitor packages and the provider for you to delete.
Platform options are flags on the same command:
kitr add mobile phone --targets ios,android --appId com.acme.orders --appName "Acme Orders"First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
| Flag | Default | Effect |
|---|---|---|
--targets |
ios,android |
Comma-separated native projects to create. |
--appId |
com.example.<app> |
Reverse-DNS bundle identifier. |
--appName |
the package name | Name under the home-screen icon. |
--scheme |
Custom URL scheme for deep links. | |
--port |
3100 |
Dev server port the device loads from. |
--no-scaffold |
Register the platform without writing platforms/phone/. |
|
--no-install |
Edit package.json without running the package manager. |
|
--dry-run |
Print what would change. |
The mobile kit already has a phone platform next to a site web platform with pwa: true:
kitr new acme-orders --kit mobile --targets iosFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Configure
@kitr/mobile has no config file in config/; its options live on the mobile() call in kitrrc.ts:
// kitrrc.ts
import { defineKitrConfig } from '@kitr/kitr/app'
import { mobile } from '@kitr/mobile'
import { web } from '@kitr/web'
export default defineKitrConfig({
packages: ['@kitr/react', '@kitr/router', '@kitr/platform', '@kitr/storage', '@kitr/web', '@kitr/mobile'],
providers: [
() => import('@kitr/mobile/provider'),
() => import('@kitr/web/provider'),
() => import('@kitr/platform/provider'),
() => import('@kitr/storage/provider'),
],
platforms: {
site: web({ mode: 'spa', pwa: true }),
phone: mobile({ appId: 'com.acme.orders', appName: 'Acme Orders' }),
},
})
| Option | Default | Description |
|---|---|---|
targets |
['ios', 'android'] |
Native projects to create and build. Each must be ios or android. |
appId |
com.example.<app> |
Reverse-DNS bundle identifier. acme-orders gives com.example.acmeorders. |
appName |
the package name | Name shown on the home screen. acme-orders becomes Acme Orders. |
scheme |
URL scheme for deep links: acme opens acme://.... |
|
port |
3100 |
Port of the dev server the device loads with kitr dev phone. |
capacitor |
Extra Capacitor config, such as plugins, merged last into capacitor.config.ts. |
An unknown target fails when kitrrc.ts loads:
[kitr] Invalid options for mobile platform:
targets: targets must be ios or android, got windows
The platform writes the Capacitor config from these options. With appId: 'com.acme.orders' and appName: 'Acme Orders' it is:
// platforms/phone/capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli'
/**
* Written by kitr from `phone: mobile({...})` in kitrrc.ts. Native projects live next to this file;
* `kitr dev phone` sets `server.url` for live reload, `kitr build phone` syncs the built bundle.
*/
const config: CapacitorConfig = {
appId: 'com.acme.orders',
appName: 'Acme Orders',
webDir: '../../dist/phone/client',
ios: { path: 'ios' },
android: { path: 'android' },
...(process.env.CAP_SERVER_URL ? { server: { url: process.env.CAP_SERVER_URL, cleartext: true } } : {}),
}
export default config
webDir points at the client build, so kitr build phone and cap sync agree on where the bundle is. The file is yours once written; kitr never overwrites it.
| Variable | Used for |
|---|---|
CAP_SERVER_URL |
Set by kitr dev phone to the dev server URL. capacitor.config.ts turns it into server.url so the app loads from your machine. Unset in builds. |
KITR_PHONE_<OPTION> |
Overrides one option for one run of the CLI: KITR_PHONE_PORT=3300 kitr dev phone, KITR_PHONE_TARGETS=android kitr build phone. |
KITR_PLATFORM |
Set to phone by kitr dev and kitr build, so the Vite plugin builds this platform into dist/phone. |
Platforms lists every platform type side by side.
Run the app on a phone
You need Xcode for iOS (macOS only) or Android Studio for Android; see the Capacitor environment setup. Then add the platform and build once to create the native projects:
kitr add mobile phone --appId com.acme.orders --appName "Acme Orders"
kitr build phoneFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Show where the app runs on the home page:
// app/routes.ts
r.page('/', { lazy: () => import('./pages/index') })
// app/pages/index.tsx
import { usePlatform } from '@kitr/platform/react'
export default function OrdersHome() {
const platform = usePlatform()
return (
<main>
<h1>Acme orders</h1>
<p>
Running on {platform.os}
{platform.native ? ' in the native app' : ' in the browser'}.
</p>
</main>
)
}
Start it on a simulator:
kitr dev phone --target ios
# phone: dev server on http://192.168.1.20:3100, the ios app loads it live
The simulator opens Acme Orders and shows "Running on ios in the native app"; in a browser it says "in the browser".
Ship the same app to the web and the phone
Both platforms build the same app/. Keep a web platform next to the mobile one; each command addresses one or both:
kitr dev site # the browser on port 3000
kitr dev phone # the simulator on port 3100
kitr build # every platform, one after anotherFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Inside the shell, @kitr/mobile/provider registers the native drivers; in a plain browser it registers nothing. Components ask Platform instead of checking where they run.
Run on a simulator or device
kitr dev phone does four things:
- Picks the target:
--target iosor--target android. With both and no flag, it runsioson macOS andandroidelsewhere. - Creates the native project with
cap add <target>if it does not exist yet. - Starts Vite on your machine's LAN address, so the device can reach it.
- Runs
cap run <target> --no-syncwithCAP_SERVER_URLset to that address.
To pick a connected device or simulator, pass its id with --device; npx cap run ios --list from platforms/phone prints the ids:
kitr dev phone --target android --device emulator-5554First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Wrong LAN address
With a VPN or a second network card, kitr may pick the wrong address. Set the host and port yourself:
kitr dev phone --target android --host 192.168.1.20 --port 3300First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Build and open the native projects
kitr build phone runs vite build into dist/phone, creates any missing native project, then runs cap sync in platforms/phone:
kitr build phone
# ✔ phone: synced dist/phone/client into platforms/phone/{ios,android}. Open with `pnpm exec cap open ios` from platforms/phone.
Pass --open to open each project in Xcode or Android Studio after the sync, and --target to build one:
kitr build phone --target ios --openFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
From the IDE you run on devices, set signing and produce the archive for the stores. The projects in platforms/phone/ios and platforms/phone/android are yours: edit Info.plist, AndroidManifest.xml, icons and splash screens there. kitr build only syncs the bundle and plugin list into them.
Pick the targets
Ship to one store first by listing one target:
kitr config phone targets ios
# ✔ phone.targets = ["ios"]
To add Android later, set both targets, install the package and build:
kitr config phone targets ios,android
pnpm add @capacitor/android
kitr build phone --target android
kitr config only edits kitrrc.ts; install @capacitor/<target> yourself. kitr doctor reports it when missing.
Set the bundle id and app name
Set appId and appName before the first kitr build, because cap add copies them into the native projects:
kitr config phone appId com.acme.orders
kitr config phone appName "Acme Orders"First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Add Capacitor plugin config
Plugin settings go in the capacitor option, merged last into capacitor.config.ts:
// kitrrc.ts
import { defineKitrConfig } from '@kitr/kitr/app'
import { mobile } from '@kitr/mobile'
export default defineKitrConfig({
packages: ['@kitr/react', '@kitr/router', '@kitr/platform', '@kitr/storage', '@kitr/mobile'],
providers: [() => import('@kitr/mobile/provider'), () => import('@kitr/platform/provider')],
platforms: {
phone: mobile({
appId: 'com.acme.orders',
capacitor: { plugins: { SplashScreen: { launchShowDuration: 0 } } },
}),
},
})
The generated file then ends with:
// platforms/phone/capacitor.config.ts
// ...
...(process.env.CAP_SERVER_URL ? { server: { url: process.env.CAP_SERVER_URL, cleartext: true } } : {}),
...{
"plugins": {
"SplashScreen": {
"launchShowDuration": 0
}
}
},
}
capacitor is read when the file is written; for an existing project, edit capacitor.config.ts directly.
Sync the plugins
Capacitor finds plugins in the package.json of the folder it runs in, so kitr regenerates platforms/phone/package.json from the app's dependencies before each cap command. A plugin you install with pnpm add @capacitor/camera appears on the next build.
cap sync prints what it found:
kitr build phone
# [info] Found 12 Capacitor plugins for android:
# @capacitor/[email protected]
# @capacitor/[email protected]
# ...
The device packages (Native, Notifications, Files, Secure storage and Updater) use these plugins; kitr add <package> installs the ones it needs. If you added one before the mobile platform, run it again.
Check whether the app runs in the shell
Most code should ask Platform. Where you need Capacitor itself, @kitr/mobile/client reads its runtime:
// app/lib/orders/where.ts
import { capacitorPlatform, isCapacitor, isPluginAvailable } from '@kitr/mobile/client'
capacitorPlatform()
// => 'ios' on an iPhone, 'android' on Android, 'web' in a browser
isCapacitor()
// => true inside the iOS or Android shell
isPluginAvailable('App')
// => true when the native side registered the plugin
In a browser and on the server they return 'web' and false.
Call a Capacitor plugin
Other Capacitor plugins are plain dependencies. Install one, sync it, and wrap the call in nativeOnly() so the web build keeps working:
pnpm add @capacitor/share
kitr build phone
// app/lib/orders/share-order.ts
import { nativeOnly } from '@kitr/mobile/client'
export async function shareOrder(order: { id: string; customer: string }) {
const url = `https://app.acme.com/orders/${order.id}`
const shared = await nativeOnly(async () => {
const { Share } = await import('@capacitor/share')
await Share.share({ title: `Order ${order.id}`, text: `${order.customer}'s order`, url })
return 'shared' as const
}, 'copy' as const)
if (shared === 'copy') await navigator.clipboard.writeText(url)
return shared
}
// => 'shared' in the shell, 'copy' in a browser
nativeOnly(fn, fallback) runs fn inside the shell and returns fallback elsewhere. Import the plugin inside fn so the browser bundle never evaluates it.
Handle the Android back button
On Android, useBackButton() from @kitr/platform/react receives the hardware back button with canGoBack:
// app/components/back-button.tsx
import { usePlatform, useBackButton } from '@kitr/platform/react'
import { useCallback } from 'react'
export function BackButtonHandler({ onCloseSheet }: { onCloseSheet: () => boolean }) {
const platform = usePlatform()
useBackButton(
useCallback(
({ canGoBack }) => {
if (onCloseSheet()) return
if (canGoBack) window.history.back()
else void platform.exit()
},
[onCloseSheet, platform],
),
)
return null
}
The handler never runs in a browser or on iOS.
Open deep links
To open acme://orders/1042 in the app, set scheme: 'acme' on the platform in kitrrc.ts. https links need extra setup; see the Capacitor deep links guide. Then hand each URL to the router:
// app/components/deep-links.tsx
import { useNavigate } from '@tanstack/react-router'
import { useDeepLink } from '@kitr/platform/react'
import { useCallback } from 'react'
export function DeepLinks() {
const navigate = useNavigate()
useDeepLink(useCallback((_url, path) => void navigate({ to: path }), [navigate]))
return null
}
// acme://orders/1042 => /orders/1042
// https://app.acme.com/orders/1042?tab=invoices => /orders/1042?tab=invoices
The second argument is the router path, with the scheme and host removed and any deepLinkPrefixes from config/platform.ts applied; see Platform.
React to the app going to the background
useAppState() is true while the app is in the foreground (in a browser it follows page visibility):
// app/components/orders/live-orders.tsx
import { useAppState } from '@kitr/platform/react'
export function LiveOrdersBadge() {
const active = useAppState()
return <span>{active ? 'Live' : 'Paused'}</span>
}
Pause polling and sockets while it is false, and refetch when it turns true.
Respect the notch and home indicator
useSafeArea() returns the insets in pixels from the CSS env(safe-area-inset-*) values:
// app/components/bottom-bar.tsx
import { useSafeArea } from '@kitr/platform/react'
export function BottomBar() {
const insets = useSafeArea()
return <nav style={{ paddingBottom: 12 + insets.bottom }}>Orders · Invoices · Teams</nav>
}
// insets on an iPhone with a notch, for example => { top: 47, right: 0, bottom: 34, left: 0 }
The insets are zero on the server and first render, then update on mount and resize. For layout alone, CSS works without the hook: pb-[env(safe-area-inset-bottom)].
Keep data in native storage
Inside the shell, every local item from Storage lives in @capacitor/preferences, which iOS does not clear the way it can clear WebView localStorage:
// app/storage.ts
import { defineStorage } from '@kitr/storage'
export const lastOrderId = defineStorage('lastOrderId', { default: '' })
Your code does not change. Reads stay synchronous and writes go to Preferences in the background. session items stay in memory.
Open links in the system browser
platform.openExternal() opens the system browser inside the shell, and a new tab in a browser:
// app/components/invoice-link.tsx
import { usePlatform } from '@kitr/platform/react'
export function InvoiceLink({ invoiceId }: { invoiceId: string }) {
const platform = usePlatform()
const url = `https://billing.acme.com/invoices/${invoiceId}`
return (
<button type="button" onClick={() => void platform.openExternal(url)}>
Open invoice {invoiceId}
</button>
)
}
Show the app version
platform.appVersion() resolves to the version from the native project (CFBundleShortVersionString on iOS, versionName on Android). In a browser it resolves to '0.0.0':
// app/components/about-version.tsx
import { usePlatform } from '@kitr/platform/react'
import { useEffect, useState } from 'react'
export function AboutVersion() {
const platform = usePlatform()
const [version, setVersion] = useState('')
useEffect(() => {
void platform.appVersion().then(setVersion)
}, [platform])
return <p>Acme Orders {version}</p>
}
// with versionName 1.4.0 => Acme Orders 1.4.0
Commands
The mobile platform answers these commands:
| Command | What it does |
|---|---|
kitr add mobile [name] |
Add a mobile platform, named phone by default. |
kitr dev <name> |
Run the app on a phone or simulator with live reload: kitr dev phone --target ios. |
kitr build <name> |
Runs vite build, cap add for missing targets and cap sync. Flags: --target, --open. |
kitr config <name> [option] [value] |
Reads or sets an option in kitrrc.ts. --unset removes one. |
kitr platforms |
Lists every platform with its options. |
kitr platforms scaffold <name> |
Writes capacitor.config.ts and .gitignore when they are missing. Existing files are kept. |
kitr platforms remove <name> |
Takes the platform out of kitrrc.ts. |
Capacitor's own CLI works from platforms/phone for everything else, such as pnpm exec cap doctor.
kitr doctor reports each missing Capacitor package (fix: pnpm add <package>), a missing capacitor.config.ts (fix: kitr platforms scaffold phone) and any target without a native project yet (fix: kitr build phone).
Errors
| Message | When | What to do |
|---|---|---|
[kitr] Invalid options for mobile platform: targets: targets must be ios or android, got windows |
targets lists something other than ios or android. |
Use ios, android or both. |
phone has no ios target (targets: android) |
--target names a target the platform does not list. |
Add it to targets or drop the flag. |
mobile has no option "<flag>" (options: targets, appId, appName, scheme, port) |
kitr add mobile got an unknown flag. |
Use one of the listed options; names are camelCase. |
No platform named "<name>" in kitrrc.ts (have: site, phone) |
A command got an unknown platform name. | Use a name from kitr platforms. |
<path>/cap run android --no-sync exited with code 1 |
Capacitor could not build or launch the app. kitr logs it and keeps the dev server running. | Read Capacitor's output above it; pnpm exec cap doctor in platforms/phone checks the toolchain. |
phone: the mobile platform has no start step |
kitr start ran for a mobile platform. Not an error. |
Ship through Xcode or Android Studio. |
Testing
Unit tests run where there is no Capacitor runtime. The helpers in @kitr/mobile/client read the global Capacitor object, so a test sets a fake one and deletes it afterwards. This checks the options, the native driver and the browser fallback:
// test/mobile.test.ts
import { mobileProvider } from '@kitr/mobile/provider'
import { platformProvider } from '@kitr/platform/provider'
import { createTestApp } from '@kitr/testing'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { shareOrder } from '#app/lib/orders/share-order'
import rc from '../kitrrc'
function fakeShell(os: 'ios' | 'android') {
;(globalThis as any).Capacitor = {
getPlatform: () => os,
isNativePlatform: () => true,
isPluginAvailable: () => true,
}
}
afterEach(() => {
delete (globalThis as any).Capacitor
})
describe('the phone platform', () => {
it('ships Acme Orders to both stores', () => {
expect(rc.platforms!.phone!.options).toMatchObject({
appId: 'com.acme.orders',
targets: ['ios', 'android'],
})
})
it('uses the native driver inside the shell', async () => {
fakeShell('android')
const app = await createTestApp({ providers: [mobileProvider, platformProvider] })
const platform = app.container.use('platform')
expect(platform.type).toBe('mobile')
expect(platform.native).toBe(true)
expect(platform.is('android')).toBe(true)
})
it('keeps the browser behaviour without the shell', async () => {
const app = await createTestApp({ providers: [mobileProvider, platformProvider] })
expect(app.container.use('platform').native).toBe(false)
})
it('copies the order link in a browser', async () => {
const writeText = vi.fn(async () => {})
vi.stubGlobal('navigator', { clipboard: { writeText } })
expect(await shareOrder({ id: 'ord_1042', customer: 'Ada' })).toBe('copy')
expect(writeText).toHaveBeenCalledWith('https://app.acme.com/orders/ord_1042')
vi.unstubAllGlobals()
})
})
To test code that calls a plugin, mock it with vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } })). Test the real native build on a simulator with kitr dev phone.