mobile
Your customers want the app on their phone, with an icon on the home screen, the Android back button that works 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, and switches the kitr packages that have a native driver (the platform hooks and storage) to it while the app runs on a phone.
It is built on Capacitor: your client build runs in the system WebView, and Capacitor plugins reach native APIs. kitr adds the platform on top: kitr dev runs the app on a simulator with live reload, kitr build builds the bundle and syncs it into the Xcode and Android Studio projects, and @kitr/mobile/provider registers the native drivers.
The provider and @kitr/mobile/client run in the app, in the WebView and in a plain browser alike. The platform hooks (kitr dev, kitr build) run in the CLI on your machine. For an installable web app with no store listing, the PWA option of the Web platform is enough. For desktop windows, use Desktop (Electron) or its alternative, Tauri.
The examples on this page ship the Acme orders app to phones: Ada's field team checks orders and invoices on iPhone and Android, 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 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). kitr add mobile with no name uses the usual name for the type and prints No name given: calling the mobile platform "phone".
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) 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.
Platform options are flags on the same command. Option names are the flag names, in camelCase:
kitr add mobile phone --targets ios,android --appId com.acme.orders --appName "Acme Orders"First time? Run npm i -g 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. Start a new project from it, picking the targets:
kitr new acme-orders --kit mobile --targets iosFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
kitr platforms remove phone takes the platform out of kitrrc.ts. It leaves platforms/phone/, the Capacitor packages and the provider in place; delete them yourself when you no longer ship to phones.
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/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. The default lower-cases the app name and drops everything but letters and digits: acme-orders gives com.example.acmeorders. |
appName |
the package name | Name shown on the home screen. acme-orders becomes Acme Orders. |
scheme |
Custom URL scheme for deep links (acme opens acme://...). kitr build and kitr dev register it in the iOS Info.plist and the Android manifest; kitr doctor warns when a native project lacks it. |
|
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 next to the native projects, from the options above. 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 of this platform, so kitr build phone and cap sync agree on where the bundle is. The file is yours after it is 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. |
The other platform types each have their own guide and options table: Web, Server, Desktop (Electron) and Tauri. Platforms lists them side by side.
Run the app on a phone
You need Xcode for iOS (macOS only) or Android Studio for Android, as the Capacitor environment setup describes. Then add the platform and build once, which creates the native projects:
kitr add mobile phone --appId com.acme.orders --appName "Acme Orders"
kitr build phoneFirst time? Run npm i -g 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 iOS simulator opens Acme Orders and shows "Running on ios in the native app". The same page in a browser at kitr dev site 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, and each command addresses one of them 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 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, so the web build of the same bundle keeps the browser behaviour. 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 targets and no flag, it runsioson macOS andandroidelsewhere, and says so. - Creates the native project with
cap add <target>ifplatforms/phone/<target>does not exist yet. - Starts Vite on your machine's LAN address and the platform's port, so the device can reach it.
- Waits for the dev server, then runs
cap run <target> --no-syncwithCAP_SERVER_URLset to that address.
To pick a connected device or a specific 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 once. Inside a project, the global kitr runs your project's own version.
When the LAN address kitr picks is the wrong one (a VPN or a second network card), set the host and port yourself:
kitr dev phone --target android --host 192.168.1.20 --port 3300First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
The phone and your machine must be on the same network. cleartext: true in the Capacitor config lets Android load the http:// dev server.
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 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 web bundle and plugin list into them.
The mobile platform has no production server, so kitr start phone prints phone: the mobile platform has no start step and returns.
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 when you add one; kitr doctor reports it when it is missing. A --target the platform does not list fails with phone has no android target (targets: ios).
Set the bundle id and app name
appId and appName are written into capacitor.config.ts when the platform is added. Set them 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 once. Inside a project, the global kitr runs your project's own version.
capacitor.config.ts is written once and never overwritten, and kitr platforms scaffold phone skips files that exist. After changing the options, edit appId and appName in platforms/phone/capacitor.config.ts too, or delete the file and run kitr platforms scaffold phone to write it again. Once the native projects exist, change the bundle id in Xcode and in android/app/build.gradle as well.
Add Capacitor plugin config
Plugin settings go in the capacitor option. They are merged last into the generated capacitor.config.ts:
// kitrrc.ts
import { defineKitrConfig } from '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
}
}
},
}
Like appId, capacitor is read when the file is written. For an existing project, edit capacitor.config.ts directly.
Check whether the app runs in the shell
Most code should ask Platform (usePlatform(), platform.is('ios')). For the few places that need Capacitor itself, @kitr/mobile/client reads the Capacitor runtime directly:
// 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
These read the global Capacitor object the native shell injects, so they are correct before any import resolves. 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 into the native projects, 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 everywhere else, without calling fn. Import the plugin inside fn so the browser bundle never evaluates it. Without a fallback it returns undefined on the web.
kitr build phone runs cap sync, which registers the new plugin in the native projects. A plugin you installed but did not sync makes isPluginAvailable('Share') return false.
Handle the Android back button
On Android, useBackButton() from @kitr/platform/react receives the hardware back button with canGoBack from the WebView history:
// 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
}
Capacitor turns off the default back behaviour as soon as a listener is registered, so the handler must go back or exit itself, as above. platform.exit() closes the app on Android. Wrap the handler in useCallback: the hook subscribes again whenever the function changes. In a browser and on iOS the handler never runs.
Open deep links
To open acme://orders/1042 in the app, set scheme: 'acme' on the platform in kitrrc.ts. The next kitr build or kitr dev adds it to CFBundleURLSchemes in the iOS Info.plist and as an intent-filter on the main activity in the Android manifest, once; edits you make to those files stay. https links (https://app.acme.com/orders/1042) need Associated Domains on iOS and an App Links intent-filter on Android, which you add yourself: the Capacitor deep links guide has both. 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 mobile driver listens to Capacitor's appUrlOpen event, which fires when the OS hands the app a URL while it runs. The second argument is the router path: the scheme and host removed, then any deepLinkPrefixes from config/platform.ts. Platform documents the prefixes.
React to the app going to the background
useAppState() is true while the app is in the foreground. Inside the shell it follows Capacitor's appStateChange; 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 again.
Respect the notch and home indicator
useSafeArea() returns the insets in pixels, read from the CSS env(safe-area-inset-*) values. iOS reports them only when the viewport opts in, so keep viewport-fit=cover in the root route's viewport meta:
// 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 on the first render, then update on mount and on every resize. For layout alone, the CSS env() values work without the hook: pb-[env(safe-area-inset-bottom)].
Keep data in native storage
Inside the shell, @kitr/mobile/provider registers a storage driver on @capacitor/preferences. Every local item from Storage then lives in native 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: '' })
Nothing changes in the code that reads or writes the item. The driver loads every key at boot, so reads stay synchronous, and writes go to Preferences in the background. session items stay in memory.
The storage provider looks for a native driver again when the app boots, after every provider has registered, so the order of @kitr/mobile/provider and @kitr/storage/provider in kitrrc.ts does not matter.
Open links in the system browser
Links to other sites should leave the app. 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
@kitr/mobile adds no generators. The mobile platform answers these commands:
| Command | What it does |
|---|---|
kitr add mobile [name] |
Adds a mobile platform (named phone when you leave the name out): installs Capacitor, writes platforms.<name>, runs the router, platform and storage recipes, writes platforms/<name>/capacitor.config.ts. |
kitr dev <name> |
Creates the native project if needed, starts Vite on the LAN and runs cap run <target> --no-sync against it. Flags: --target, --device, --host, --port. |
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: pnpm exec cap open android, pnpm exec cap run ios --list, pnpm exec cap doctor.
kitr doctor checks each mobile platform and reports:
- an error for each missing package:
@capacitor/core,@capacitor/cli,@capacitor/app,@capacitor/preferencesand@capacitor/<target>. Fix:pnpm add <package>. - an error when
platforms/phone/capacitor.config.tsis missing. Fix:kitr platforms scaffold phone. - an info line for each target whose native project does not exist yet. Fix:
kitr build phone, which runscap add.
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 in Node or happy-dom, where there is no Capacitor runtime. The helpers in @kitr/mobile/client read the global Capacitor object, so a test sets a fake one to play the native shell and deletes it afterwards.
This test checks the platform options, that the app picks the native driver inside the shell, and that shareOrder() from "Call a Capacitor plugin" falls back in the browser:
// 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()
})
})
The fake shell is enough for the drivers kitr registers, because they reach the Capacitor plugins only when a listener or method is used. 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 or device with kitr dev phone.
Next steps
- API reference
- Platform: app state, back button, deep links and safe areas on every platform
- Storage: typed items that move to native preferences on a phone
- Web: the browser build of the same app, installable as a PWA
- Platforms: every platform type and the commands they share
- Capacitor documentation