Guide · Ship to phones and desktops

Build a phone app

Ship an iPhone and Android app from the same React code as your web app, with native navigation, sign-in that survives restarts, the share sheet and deep links.

Acme's finance team chases unpaid invoices from wherever they are, and a web page in a phone browser does not cut it: no icon on the home screen, a back gesture that reloads the list, and a sign-in that is gone every time iOS clears the browser's storage. You will build Acme Billing, an iPhone and Android app for the invoices API from Build an API server without React. It has a tab bar, an invoice list that stays where you left it when you open an invoice, a sheet for new invoices, sign-in with a token kept in native storage, the system share sheet and acme:// links that open an invoice.

The app is the same React code that runs in a browser. kitr wraps it in a Capacitor shell, and while it runs inside the shell, the kitr packages that have a native version switch to it: storage moves to native preferences, the platform hooks get the hardware back button and the app's lifecycle, and links to other sites open in the system browser.

Preview The mobile platform is a preview. @kitr/mobile and @kitr/platform are not on npm yet because their native shells are not built in CI. The commands below need --preview and a kitr checkout, as the next section shows. The API may still change before the release.

Before you start

You need Node.js 22 or newer, pnpm, and a native toolchain for the phone you test on: Xcode for iOS (macOS only) or Android Studio for Android, set up as the Capacitor environment guide describes. You also need the invoices API from Build an API server without React running on port 3001.

Until the mobile packages are published, create the project inside a kitr checkout, where --preview links the workspace packages:

git clone https://github.com/kitrjs/kitr.git && cd kitr
pnpm install && pnpm build
node packages/kitr/bin/kitr.mjs new examples/acme-billing --kit mobile --preview \
  --targets ios,android --no-install --no-git --registry $PWD/registry/dist

Then point kitr and every @kitr/* dependency in examples/acme-billing/package.json at workspace:* and run pnpm install at the root. Once the packages ship, this is kitr new acme-billing --kit mobile.

Look at what the kit made

The mobile kit is the app kit (React, routing, query, forms, auth, theme) with two platforms:

// kitrrc.ts
platforms: {
  site: web({ mode: 'spa', pwa: true }),
  phone: mobile(),
},

site is the same app as an installable web app, and phone is the Capacitor shell. Both build the same app/ folder, so a page you write runs in both. The kit also wrote platforms/phone/capacitor.config.ts, which is where the native projects will live, and registered @kitr/mobile/provider and @kitr/platform/provider, which pick the native drivers when the app runs in the shell.

Name the app before the first native build, because cap add copies the id into the Xcode and Android Studio projects:

cd examples/acme-billing
kitr config phone appId com.acme.billing
kitr config phone appName "Acme Billing"
kitr config phone scheme acme

capacitor.config.ts is written once, so set appId and appName in platforms/phone/capacitor.config.ts too. scheme: 'acme' registers acme:// links in the iOS Info.plist and the Android manifest on the next build.

Point the app at the API

A phone is a different machine from the one running the API, so localhost on the phone is the phone itself. Use your computer's address on the local network for development, and the real API in production:

# .env
VITE_API_URL=http://192.168.1.20:3001/api

The browser in the shell sends requests from its own origin: capacitor://localhost on iOS and https://localhost on Android, or the dev server's address while kitr dev phone runs with live reload. Add them to the API's cors() origins, next to the web app:

// acme-billing-api/server/routes.ts
cors({
  origin: ['https://app.acme.com', 'capacitor://localhost', 'https://localhost', 'http://192.168.1.20:3100'],
}),

Sign in with a token

A phone app has no cookie jar it can rely on across restarts, so it signs in with a bearer token, which the API from the server guide already accepts. Give the API a route that trades an email and password for a token, and one that returns the user behind a token:

// acme-billing-api/server/controllers/session_controller.ts
import type { HttpContext } from 'kitr/api'
import { KitrError } from 'kitr/app'
import { requireUser } from 'kitr/auth/server'
import { z } from 'zod'
import { type ApiUser, signInWith } from '#server/auth'

export default class SessionController {
  static validation = { store: { body: z.object({ email: z.email(), password: z.string().min(1) }) } }

  async store({ body }: HttpContext<{}, {}, { email: string; password: string }>) {
    const token = await signInWith(body.email, body.password)
    if (!token) throw KitrError.unauthorized('Wrong email or password')
    return { token }
  }

  async show(ctx: HttpContext) {
    return requireUser<ApiUser>(ctx)
  }
}

signInWith goes next to the token table that authMiddleware already reads, and the two routes go on either side of the auth group:

// acme-billing-api/server/auth.ts
// ...ApiUser, tokens and auth as in the server guide

/** Swap for your users table and hashed passwords, as in the sign-in guide. */
const passwords = new Map([['[email protected]', 'paper-lantern-42']])

export async function signInWith(email: string, password: string) {
  if (passwords.get(email) !== password) return null
  for (const [token, user] of tokens) if (user.email === email) return token
  return null
}
// acme-billing-api/server/routes.ts
r.post('/sign-in', [SessionController, 'store'], { name: 'session.store' })
  .group({ middleware: [auth] }, (secured) =>
    secured.get('/me', [SessionController, 'show'], { name: 'session.show' }).resource('invoices', InvoicesController),
  )

curl -X POST localhost:3001/api/sign-in -H 'content-type: application/json' -d '{"email":"[email protected]","password":"paper-lantern-42"}' answers {"token":"tok_ada"}. Add sign-in, end to end shows a real users table with scrypt hashes.

On the phone, keep the token in a Storage item. Inside the shell, kitr stores local items in Capacitor's native preferences, which iOS does not clear the way it can clear a web view's localStorage:

// app/storage.ts
import { defineStorage } from 'kitr/storage'

/** The API token. On a phone this lives in native preferences, which iOS does not clear. */
export const apiToken = defineStorage<string | null>('api-token', { default: null })

/** The last list filter, so the app reopens where Ada left it. */
export const invoiceFilter = defineStorage<'open' | 'paid' | 'all'>('invoice-filter', { default: 'open' })

Then hand that item to the token adapter in config/auth.ts, and let the HTTP client send the token with every request:

// config/auth.ts
import { defineConfig, tokenAdapter } from 'kitr/auth'
import { abilities, type User } from '#app/abilities'
import { env } from '#app/env'
import { apiToken } from '#app/storage'

const adapter = tokenAdapter<User>({
  abilities,
  storage: { get: () => apiToken.get(), set: (token) => apiToken.set(token) },
  fetchUser: async (token) => {
    const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } })
    return res.ok ? ((await res.json()) as User) : null
  },
  signIn: async (credentials) => {
    const res = await fetch(`${env.VITE_API_URL}/sign-in`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(credentials),
    })
    if (!res.ok) throw new Error('Wrong email or password')
    return (await res.json()) as { token: string }
  },
})

export default defineConfig({ adapter, signInPath: '/sign-in' })
// config/http.ts
import { defineConfig } from 'kitr/http'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  getToken: (container) => container.use('auth').getToken(),
})

When the app starts, the auth provider reads the stored token, calls /me and restores the session before the first page loads, so Ada opens the app and lands on her invoices without signing in again.

Describe the invoices

Put the API's endpoints in one file as queries and mutations. Pages and loaders read them through the query cache, so the list and the detail share data and a payment refreshes both:

// app/features/invoices/queries.ts
import { createKeys } from 'kitr/app'
import { httpMutation, httpQuery } from 'kitr/http'
import { mutationFrom, queryFrom } from 'kitr/query'
import { app } from '#app/app'

export interface Invoice {
  id: string
  customerEmail: string
  amountCents: number
  status: 'open' | 'paid'
}

/** A function, called per request: this file loads with the route table, before the app boots. */
const http = () => app.container.use('http')
export const invoiceKeys = createKeys('invoices')

export const invoicesQuery = queryFrom(
  httpQuery<void, Invoice[]>(http, { path: 'invoices', key: () => invoiceKeys.lists() }),
)

export const invoiceQuery = queryFrom(
  httpQuery<string, Invoice>(http, { path: (id) => `invoices/${id}`, key: (id) => invoiceKeys.detail(id) }),
)

export const payInvoice = mutationFrom(
  httpMutation<string, Invoice>(http, {
    path: (id) => `invoices/${id}/pay`,
    body: () => undefined,
    effects: { invalidate: [invoiceKeys.all] },
  }),
)

export const createInvoice = mutationFrom(
  httpMutation<{ customerEmail: string; amountCents: number }, Invoice>(http, {
    path: 'invoices',
    effects: { invalidate: [invoiceKeys.lists()] },
  }),
)

export const formatAmount = (cents: number) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100)

http is a function that returns the client, and httpQuery calls it on each request. app/routes.ts imports this file for its loaders, and the route table loads before the app boots, when the container has no http binding yet. body: () => undefined sends the payment request without a body.

Lay out the pages as a native stack

On a phone, opening an invoice pushes it over the list. The list stays mounted underneath, scrolled to the same row, and a swipe from the left edge on iOS or the back button on Android reveals it again. The route table says which pages exist; presentation and stackParent say how they move:

// app/routes.ts
import { defineRoutes } from 'kitr/app'
import { requireAuth } from 'kitr/router'
import router from 'kitr/router/routes'
import { invoiceQuery, invoicesQuery } from '#app/features/invoices/queries'
import { head, RootDocument } from './document'

export const routes = defineRoutes([router], (r) =>
  r
    .root({ head, component: RootDocument })
    .page('/sign-in', {
      validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
        redirect: typeof search.redirect === 'string' ? search.redirect : undefined,
      }),
      lazy: () => import('./pages/sign-in'),
    })
    .layout('_tabs', { beforeLoad: requireAuth(), lazy: () => import('./pages/tabs') }, (tabs) =>
      tabs
        .page('/', {
          loader: ({ context }) => context.queryClient.ensureQueryData(invoicesQuery.options()),
          lazy: () => import('./pages/invoices'),
        })
        .page('/invoices/new', { presentation: 'cover', lazy: () => import('./pages/new-invoice') })
        .page('/invoices/$id', {
          stackParent: '/',
          loader: ({ context, params }) => context.queryClient.ensureQueryData(invoiceQuery.options(params.id)),
          lazy: () => import('./pages/invoice'),
        })
        .page('/account', { lazy: () => import('./pages/account') }),
    ),
)

export type AppRoutes = typeof routes.$types

requireAuth() on the layout sends a signed-out visitor to /sign-in. presentation: 'cover' makes the new-invoice form rise over the whole screen and dismiss with a downward drag. stackParent: '/' is for cold starts: when a link opens /invoices/inv_2041 directly, the list is put under it, so the back gesture has somewhere to go.

The layout renders <StackTabs>, which keeps one stack per tab. Leave the Invoices tab two pages deep, check Account, come back, and Invoices is still two pages deep:

// app/pages/tabs.tsx
import { StackTabs } from 'kitr/router/stack'
import { DeepLinks } from '#app/components/deep-links'

const tabs = [
  { id: 'invoices', to: '/', label: 'Invoices', match: ['/', '/invoices'] },
  { id: 'account', to: '/account', label: 'Account' },
]

export default function TabsLayout() {
  return (
    <div className="h-dvh">
      <DeepLinks />
      <StackTabs tabs={tabs}>
        {({ tabs, current, select }) => (
          <nav aria-label="Tabs" className="flex border-t border-border bg-background pb-[env(safe-area-inset-bottom)]">
            {tabs.map((tab) => (
              <button
                type="button"
                key={tab.id}
                aria-current={current?.id === tab.id ? 'page' : undefined}
                className="flex-1 py-3 text-sm aria-[current=page]:font-semibold"
                onClick={() => select(tab)}
              >
                {tab.label}
              </button>
            ))}
          </nav>
        )}
      </StackTabs>
    </div>
  )
}

The stack fills its parent, so the wrapper gets the full dynamic viewport height (h-dvh). The tab bar pads itself by env(safe-area-inset-bottom) so it clears the home indicator, which works because the kit's document sets viewport-fit=cover. Tapping the current tab again pops it to its root, and on Android the back button at the root of Account goes to Invoices before it leaves the app.

Write the pages

The list reads the cache the loader filled, so it renders without a spinner. The filter is a storage item, so the app reopens on the same filter:

// app/pages/invoices.tsx
import { useSuspenseQuery } from 'kitr/query'
import { Link } from 'kitr/router'
import { useStorage } from 'kitr/storage/react'
import { formatAmount, invoicesQuery } from '#app/features/invoices/queries'
import { invoiceFilter } from '#app/storage'

export default function InvoicesPage() {
  const { data: invoices } = useSuspenseQuery(invoicesQuery.options())
  const [filter, setFilter] = useStorage(invoiceFilter)
  const shown = filter === 'all' ? invoices : invoices.filter((invoice) => invoice.status === filter)

  return (
    <main className="p-4 pt-[calc(env(safe-area-inset-top)+16px)]">
      <header className="mb-4 flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Invoices</h1>
        <Link to="/invoices/new" className="text-sm font-medium">New</Link>
      </header>
      <div role="tablist" className="mb-4 flex gap-2">
        {(['open', 'paid', 'all'] as const).map((value) => (
          <button type="button" role="tab" key={value} aria-selected={filter === value} onClick={() => setFilter(value)}>
            {value}
          </button>
        ))}
      </div>
      <ul className="divide-y divide-border">
        {shown.map((invoice) => (
          <li key={invoice.id}>
            <Link to="/invoices/$id" params={{ id: invoice.id }} className="flex justify-between py-3">
              <span>{invoice.customerEmail}</span>
              <span>{formatAmount(invoice.amountCents)}</span>
            </Link>
          </li>
        ))}
      </ul>
    </main>
  )
}

The detail page has a back button only when there is a page to go back to, a payment button and a share button. useStack() tells the page where it stands in the stack:

// app/pages/invoice.tsx
import { useMutation, useSuspenseQuery } from 'kitr/query'
import { getRouteApi } from 'kitr/router'
import { useStack } from 'kitr/router/stack'
import { formatAmount, invoiceQuery, payInvoice } from '#app/features/invoices/queries'
import { shareInvoice } from '#app/features/invoices/share-invoice'

const route = getRouteApi('/_tabs/invoices/$id')

export default function InvoicePage() {
  const { id } = route.useParams()
  const { data: invoice } = useSuspenseQuery(invoiceQuery.options(id))
  const pay = useMutation(payInvoice)
  const stack = useStack()

  return (
    <main className="p-4 pt-[calc(env(safe-area-inset-top)+16px)]">
      {stack.canPop ? <button type="button" onClick={stack.pop}>Invoices</button> : null}
      <h1 className="text-2xl font-semibold">{formatAmount(invoice.amountCents)}</h1>
      <p>{invoice.customerEmail} · {invoice.status}</p>
      <div className="mt-6 flex gap-3">
        {invoice.status === 'open' ? (
          <button type="button" disabled={pay.isPending} onClick={() => pay.mutate(invoice.id)}>
            Mark as paid
          </button>
        ) : null}
        <button type="button" onClick={() => void shareInvoice(invoice)}>Share</button>
      </div>
    </main>
  )
}

"Mark as paid" posts to /api/invoices/inv_2041/pay, and invalidate: [invoiceKeys.all] refetches the detail and the list, so the invoice moves out of the Open filter on the page underneath. The new-invoice page is a form whose Cancel button calls stack.dismiss() and whose Save button creates the invoice, then replaces the sheet with the new invoice's page by calling navigate({ to: '/invoices/$id', params: { id: invoice.id }, replace: true }).

The sign-in page is the one from Auth: useAuth().signIn({ email, password }), then navigate({ to: redirect ?? '/', replace: true }). The Account page shows useUser()'s email and the version from platform.appVersion(), which reads CFBundleShortVersionString on iOS and versionName on Android, and signs out with useAuth().signOut().

Open the share sheet

Capacitor plugins are ordinary dependencies. Install the Share plugin and wrap the call in nativeOnly(), which runs it inside the shell and returns the fallback in a browser without loading the plugin:

pnpm add @capacitor/share
// app/features/invoices/share-invoice.ts
import { nativeOnly } from 'kitr/mobile/client'
import type { Invoice } from './queries'

export async function shareInvoice(invoice: Invoice) {
  const url = `https://app.acme.com/invoices/${invoice.id}`
  const shared = await nativeOnly(async () => {
    const { Share } = await import('@capacitor/share')
    await Share.share({ title: `Invoice ${invoice.id}`, text: `Invoice for ${invoice.customerEmail}`, url })
    return 'shared' as const
  }, 'copied' as const)
  if (shared === 'copied') await navigator.clipboard.writeText(url)
  return shared
}

On an iPhone, Share opens the system sheet with Messages, Mail and Slack. On the web build of the same page it copies the link. The plugin is registered in the native projects by the next kitr build phone, which runs cap sync.

acme://invoices/inv_2041 in a message or an email should open that invoice. The scheme option registered acme:// with both operating systems. Hand every URL the OS gives the app to the router:

// app/components/deep-links.tsx
import { useDeepLink } from 'kitr/platform/react'
import { useNavigate } from 'kitr/router'
import { useCallback } from 'react'

/** acme://invoices/inv_2041 and https://app.acme.com/invoices/inv_2041 open the invoice. */
export function DeepLinks() {
  const navigate = useNavigate()
  useDeepLink(useCallback((_url, path) => void navigate({ to: path }), [navigate]))
  return null
}

The second argument is the router path with the scheme and host removed: acme://invoices/inv_2041 becomes /invoices/inv_2041. https://app.acme.com/... links need Associated Domains on iOS and an App Links intent filter on Android, which the Capacitor deep links guide walks through. When the link starts the app, stackParent puts the list under the invoice.

Run it on a simulator

Build once to create the native projects, then start the iOS simulator with live reload:

kitr build phone
kitr dev phone --target ios
# phone: dev server on http://192.168.1.20:3100, the ios app loads it live

kitr dev phone starts Vite on your machine's network address, then runs cap run ios with the app pointed at it. Edit a page and the simulator updates in place. Use --target android for the Android emulator, and --device <id> for a phone on a cable. The same app runs in a desktop browser with kitr dev site, where the back button is the browser's, the share button copies the link and storage is localStorage.

Test it

Unit tests run in Node, where there is no Capacitor. A fake Capacitor global plays the shell, and vi.doMock stands in for the plugin:

// test/phone.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 { shareInvoice } from '#app/features/invoices/share-invoice'

function fakeShell(os: 'ios' | 'android') {
  ;(globalThis as any).Capacitor = { getPlatform: () => os, isNativePlatform: () => true, isPluginAvailable: () => true }
}

afterEach(() => {
  delete (globalThis as any).Capacitor
  vi.unstubAllGlobals()
})

const invoice = { id: 'inv_2041', customerEmail: '[email protected]', amountCents: 12000, status: 'open' as const }

describe('Acme Billing on a phone', () => {
  it('uses the native drivers inside the shell', async () => {
    fakeShell('ios')
    const app = await createTestApp({ providers: [mobileProvider, platformProvider] })
    expect(app.container.use('platform').is('ios')).toBe(true)
  })

  it('copies the invoice link in a browser', async () => {
    const writeText = vi.fn(async () => {})
    vi.stubGlobal('navigator', { clipboard: { writeText } })
    expect(await shareInvoice(invoice)).toBe('copied')
    expect(writeText).toHaveBeenCalledWith('https://app.acme.com/invoices/inv_2041')
  })

  it('opens the share sheet in the shell', async () => {
    fakeShell('android')
    const share = vi.fn(async () => ({}))
    vi.doMock('@capacitor/share', () => ({ Share: { share } }))
    expect(await shareInvoice(invoice)).toBe('shared')
  })
})

Test gestures, the keyboard and the real plugins on a simulator or a device. Test your app covers component and page tests, which work the same here.

Ship to the stores

kitr build phone builds the client into dist/phone/client and syncs it into the native projects. --open opens each one in its IDE:

VITE_API_URL=https://api.acme.com/api kitr build phone --target ios --open

From Xcode and Android Studio you set signing, the version and build number, icons and splash screens, then archive for TestFlight and the Play Console. The projects in platforms/phone/ios and platforms/phone/android are yours to edit and commit; kitr build only copies the web bundle and the plugin list into them. Run kitr doctor before a release: it reports a missing Capacitor package, a missing capacitor.config.ts, or a native project that does not register the acme scheme yet.

Next steps