Ship one app to the web, phones and desktops
One kitr project that builds a web app, an iPhone and Android app and a desktop app from one route table, with per-platform pages, layouts and behaviour where they differ.
Build a phone app and Build a desktop app made two projects that share four files by copy and paste. That works until the invoice type changes and one copy doesn't. Acme Billing is one product, so this guide makes it one project: a web platform for app.acme.com, a mobile platform for the stores and a desktop platform for the back office, all built from the same app/ folder and the same route table.
Most of the app should not know where it runs. Pages, queries, forms and storage are written once. kitr gives you a small set of tools for the places that do differ, and each works at a different time:
| Need | Tool | Decided |
|---|---|---|
| A page that exists on some platforms only | platforms on the page |
when the route tree is built |
| A different implementation per platform | forPlatform() from kitr/platform |
once, from the build's platform |
| A different implementation per operating system | perPlatform() from kitr/platform |
at run time |
| A piece of UI on some platforms or screen sizes | <Only>, <Except>, useFormFactor() |
at render |
| Code that must not be in a bundle at all | import.meta.env.KITR_PLATFORM |
by the bundler |
Preview The mobile and desktop platforms are previews.
@kitr/mobile,@kitr/desktopand@kitr/platformare not on npm yet. The commands below need--previewand a kitr checkout. The API may still change before the release.
Before you start
Work through Build a phone app first. This guide starts from its project, acme-billing, which already has two platforms, site and phone, and adds the desktop. You need the same toolchains as the two platform guides: Xcode or Android Studio for the phone, and nothing extra for Electron. The invoices API from Build an API server without React runs on port 3001.
Add the desktop platform
A platform is a named entry in kitrrc.ts. Add a desktop platform called desk to the phone project:
cd examples/acme-billing
kitr add desktop desk --preview --title "Acme Billing" --appId com.acme.billing
// kitrrc.ts
platforms: {
site: web({ mode: 'spa', pwa: true }),
phone: mobile({ appId: 'com.acme.billing', appName: 'Acme Billing', scheme: 'acme' }),
desk: desktop({ title: 'Acme Billing', appId: 'com.acme.billing' }),
},
kitr add installed Electron, registered @kitr/desktop/provider next to the mobile one, and wrote platforms/desk/ and app/ipc.ts. Each provider registers its native drivers only inside its own shell, so the phone bundle runs the Capacitor drivers, the desktop window runs the Electron ones, and the browser runs neither. Move the export command and the menu from the desktop guide into platforms/desk/ipc.ts and platforms/desk/main.ts; nothing else from that project is needed, because the data layer is already here.
Then type the platform names:
kitr typesFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
kitr types writes .kitr/platforms.d.ts from kitrrc.ts. From now on 'site' | 'phone' | 'desk' is a union type for platform names, so platforms: ['sitee'] on a page or <Only platform="phon"> is a compile error that suggests the right name:
// .kitr/platforms.d.ts (generated)
declare global {
interface ImportMetaEnv {
readonly KITR_PLATFORM: 'desk' | 'phone' | 'site'
readonly KITR_PLATFORM_TYPE: 'desktop' | 'mobile' | 'web'
}
}
Run all three
kitr dev with no name starts every platform side by side, each on its own port:
kitr dev
# site: http://localhost:3000
# phone: http://192.168.1.20:3100, the ios app loads it live
# desk: http://localhost:3200, the Electron window loads it
Name one or two to start only those: kitr dev site desk. Each build sets KITR_PLATFORM to its name, which is how the rest of this guide tells them apart. A change to a page shows up in the browser, the simulator and the window at once.
Keep a page to some platforms
The web app should have a download page linking to the stores and installers. In the phone and desktop apps that page makes no sense. platforms on the page lists the builds that ship it:
// app/routes.ts
export const routes = defineRoutes([router], (r) =>
r
.root({ head, component: RootDocument })
.page('/download', { platforms: ['site'], lazy: () => import('./pages/download') })
.page('/sign-in', { /* ...as in the phone guide */ })
.layout('_tabs', { beforeLoad: requireAuth(), lazy: () => import('./pages/tabs') }, (tabs) =>
tabs, // ...the invoices and account pages, on every platform
),
)
The other builds leave /download out of their route tree, so in the phone app it is a 404 like any unknown path. Its chunk is still in their bundles, unused; the section on keeping code out of a bundle covers code that must not ship at all. <Link to="/download"> still type-checks everywhere, because the type of the table is the same on every platform; render such a link only where the page exists, as the next sections show. A layout whose pages are all left out is left out too.
When the guard should depend on more than the build, such as the operating system or whether the app runs in a native shell, use onlyPlatform() from kitr/router as the page's beforeLoad instead. Its keys can be platform names, types, operating systems or native, and it can redirect instead of rendering not-found: beforeLoad: onlyPlatform(['site', 'desk'], { to: '/' }) keeps a page off phones and sends them home.
Pick an implementation per platform
Sharing an invoice opens the share sheet on a phone and copies the link everywhere else. forPlatform() picks the implementation once, from the platform the bundle was built for, so components call shareInvoice() without asking where they are:
// app/features/invoices/share-invoice.ts
import { forPlatform } from 'kitr/platform'
import type { Invoice } from './queries'
const linkTo = (invoice: Invoice) => `https://app.acme.com/invoices/${invoice.id}`
/** Picked once, from the build: the phone opens the share sheet, every other build copies the link. */
export const shareInvoice = forPlatform<(invoice: Invoice) => Promise<'shared' | 'copied'>>({
phone: async (invoice) => {
const { Share } = await import('@capacitor/share')
await Share.share({ title: `Invoice ${invoice.id}`, url: linkTo(invoice) })
return 'shared'
},
default: async (invoice) => {
await navigator.clipboard.writeText(linkTo(invoice))
return 'copied'
},
})
forPlatform() looks up the build's platform name first, then its type, then default. Import a native plugin inside its branch, as above, so the web and desktop builds never evaluate it. Because it needs no app instance, forPlatform() also works at module scope and in config files: Auth uses it to pick a cookie session for the web build and a bearer token for the shells.
When the answer depends on the device rather than the build, such as iOS against Android inside the same phone build, use perPlatform() at run time with the platform service:
// app/features/invoices/payment-link.ts
import { perPlatform } from 'kitr/platform'
import { app } from '#app/app'
export function paymentHint() {
return perPlatform(app.container.use('platform'), {
ios: () => 'Pay with Apple Pay from the link',
android: () => 'Pay with Google Pay from the link',
default: () => 'Pay by card from the link',
})
}
The most specific key wins: the platform name, then the operating system, then the type, then native, then default.
Show pieces on some platforms
<Only> renders its children where its props match. The Account page links to the download page on the web build only, and the iPhone build gets a hint the others don't:
// app/components/account-extras.tsx
import { Only } from 'kitr/platform/react'
import { Link } from 'kitr/router'
export function AccountExtras() {
return (
<>
<Only platform="site">
<Link to="/download">Get the phone and desktop apps</Link>
</Only>
<Only native os="ios">
<p>Turn on Face ID in Settings to skip the password.</p>
</Only>
</>
)
}
Props are platform names, types (web, mobile, desktop), operating systems, native, and the form-factor axes width, input and orientation; several props must all match. <Except> is the inverse, and usePlatformMatch() returns the same answer for code that isn't JSX.
Fit the layout to the window
A phone wants a tab bar at the bottom. A desktop window, or a browser on a laptop, has room for a sidebar. That is a question of screen size, not platform: an iPad runs the phone build, and a narrow desktop window should behave like a phone. useFormFactor() reads the width, the input and the orientation, and re-renders when they change. The tabs layout from the phone guide becomes:
// app/pages/tabs.tsx
import { Only, useFormFactor } from 'kitr/platform/react'
import { StackTabs } from 'kitr/router/stack'
import { DeepLinks } from '#app/components/deep-links'
import { MenuCommands } from '#app/components/menu-commands'
const tabs = [
{ id: 'invoices', to: '/', label: 'Invoices', match: ['/', '/invoices'] },
{ id: 'account', to: '/account', label: 'Account' },
]
export default function TabsLayout() {
const { width } = useFormFactor()
const sidebar = width !== 'compact'
return (
<div className={sidebar ? 'grid h-dvh grid-cols-[14rem_1fr]' : 'h-dvh'}>
<Only type="mobile">
<DeepLinks />
</Only>
<Only platform="desk">
<MenuCommands />
</Only>
<StackTabs tabs={tabs} placement={sidebar ? 'top' : 'bottom'} className={sidebar ? 'col-start-2' : undefined}>
{({ tabs, current, select }) => (
<nav
aria-label="Tabs"
className={
sidebar
? 'fixed inset-y-0 left-0 flex w-56 flex-col gap-1 border-r border-border p-3'
: '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={sidebar ? 'rounded-md px-3 py-2 text-left aria-[current=page]:bg-muted' : 'flex-1 py-3 text-sm aria-[current=page]:font-semibold'}
onClick={() => select(tab)}
>
{tab.label}
</button>
))}
</nav>
)}
</StackTabs>
</div>
)
}
width is compact below 768 pixels, medium up to 1024 and expanded above. Before the window is measured, on the server and in the first render, the hook guesses from the platform: desktop shells start expanded, everything else compact, so the first paint matches what the server sent. The deep-link handler only matters inside the phone shell and the menu handler only in the desktop window, so each is wrapped in <Only>.
Keep code out of a bundle
forPlatform() and <Only> choose from values that are fixed per build, but the code for every branch is still in every bundle. For code that must not ship at all, such as a receipt-printer tool that only the back office uses, compare import.meta.env.KITR_PLATFORM directly. The kitr Vite plugin replaces it with the build's platform name, so the bundler folds the comparison to a constant and drops the branch, with its imports, where it is false:
// app/components/desk-only-tools.tsx
import { lazy, Suspense } from 'react'
// A constant in each build: the site and phone bundles fold this to null and drop the import.
const ReceiptPrinter = import.meta.env.KITR_PLATFORM === 'desk' ? lazy(() => import('./receipt-printer')) : null
export function DeskOnlyTools() {
if (!ReceiptPrinter) return null
return (
<Suspense fallback={null}>
<ReceiptPrinter />
</Suspense>
)
}
After kitr build, dist/desk/client has a receipt-printer chunk and dist/site/client has none. Keep the import inside the condition: a lazy(() => import(...)) at the top of the file is a chunk in every build, even when nothing renders it. The same goes for pages: platforms takes a page out of a build's route tree, but its chunk is still emitted, so put code that must not ship behind KITR_PLATFORM.
Render public pages on the server
The web platform is a single-page app here, which suits a signed-in tool. If the web build also carries public pages that search engines should read, switch it to mode: 'ssr' and decide per page. ssrRoute() from SEO takes a table that resolves by platform name, then type, then default, so the web build renders the download page on the server while the shells, which have no server, render it in the window:
// app/routes.ts
import { ssrRoute } from 'kitr/seo'
r.page('/download', {
platforms: ['site'],
...ssrRoute({ site: true, default: false }),
lazy: () => import('./pages/download'),
})
Keep ssrRoute(false) on the signed-in layout in that case: its pages read the token from storage, which only exists in the browser.
Test the route table per platform
createKitrRouter() takes the platform to build the tree for, so one test checks which pages each app ships:
// test/platforms.test.ts
import { createKitrRouter, createMemoryHistory } from 'kitr/router'
import { createTestApp } from 'kitr/testing'
import { describe, expect, it } from 'vitest'
import { routes } from '#app/routes'
async function pathsFor(platform: 'site' | 'phone' | 'desk') {
const app = await createTestApp({})
const router = createKitrRouter({ routes, app, platform, history: createMemoryHistory() })
return Object.keys(router.routesByPath)
}
describe('one route table, three apps', () => {
it('ships the download page on the web only', async () => {
expect(await pathsFor('site')).toContain('/download')
expect(await pathsFor('phone')).not.toContain('/download')
expect(await pathsFor('desk')).not.toContain('/download')
})
it('ships the invoices everywhere', async () => {
for (const platform of ['site', 'phone', 'desk'] as const)
expect(await pathsFor(platform)).toContain('/invoices/$id')
})
})
The tests from the two platform guides keep working unchanged: the fake Capacitor global and the fake Electron objects each select one shell's drivers.
Build everything
kitr build with no name builds every platform, one after another, and stops at the first failure:
VITE_API_URL=https://api.acme.com/api kitr build
The web app lands in dist/site/client, the phone bundle in dist/phone/client, synced into the Xcode and Android Studio projects, and the desktop installers in dist/desk/release. Each has its own folder, so the web deploy, the store uploads and the installer release are separate jobs in CI. kitr build site builds one, and kitr doctor checks all three toolchains in one run.
Split into apps when they drift
One project is right while the three apps are the same product. When they grow apart, such as a marketing site with its own release schedule or a phone app maintained by another team, split them into separate kitr projects in one repository, as Keep several apps in one repository shows, and share the data layer as a workspace package. A kitr.workspace.ts at the root lets the CLI run across them:
// kitr.workspace.ts
import { defineWorkspace } from 'kitr'
export default defineWorkspace({
apps: { web: 'apps/web', phone: 'apps/phone', desk: 'apps/desk' },
})
Next steps
- Platform: every hook,
perPlatform(),forPlatform()and the form factor in detail. - Router: page
platforms,onlyPlatform(), stacks and tabs. - Platforms: every platform type and the commands they share.
- Keep several apps in one repository: when one project becomes several.