Reference

Platforms

Web, server, desktop and mobile platforms: options, commands and layout.

Where an app runs is a platform: a named entry in kitrrc.ts built by a factory from a platform package. The contract is definePlatform in @kitr/core; each package owns its options, its scaffold, its dev server and its build.

// kitrrc.ts
import { defineKitrConfig } from '@kitr/kitr/app'
import { web } from '@kitr/web'
import { server } from '@kitr/server'
import { desktop } from '@kitr/desktop'

export default defineKitrConfig({
  platforms: {
    site: web({ mode: 'ssr', pwa: true }),
    api: server({ runtime: 'node', port: 3001 }),
    app: desktop({ title: 'My App' }),
  },
  packages: [...],
  providers: [...],
})

Names are yours. One project can hold several platforms of the same type (admin: web({...}), www: web({...})), and every command addresses them by name.

Type Package Factory What it builds
web @kitr/web web() A browser app: static, single-page or server-rendered.
server @kitr/server server() An API server on Node, Bun, Deno or Cloudflare Workers.
desktop @kitr/desktop desktop() A desktop app in Electron, which ships its own browser.
tauri @kitr/tauri tauri() A small desktop app in Tauri, on the system's webview.
mobile @kitr/mobile mobile() An iOS and Android app in Capacitor.

Commands

Command What it does
kitr dev [names...] Run every platform in development, or only the named ones: kitr dev site.
kitr build [names...] Build for production into dist/<name>/.
kitr start [names...] Serve the builds.
kitr add <type> <name> Add a platform and the packages it needs: kitr add web admin --mode spa.
kitr platforms List the platforms and their options. Subcommands: types, scaffold <name>, remove <name>.
kitr config <name> [option] [value] Read or set one option: kitr config site port 4000. --unset removes it.

Override any option at run time with an environment variable or a flag: KITR_SITE_PORT=4000 kitr dev site or kitr dev site --port 4000.

kitr doctor also checks each platform's tools, such as the Rust toolchain for Tauri.

Options

Every platform takes without, whatever its type:

Option Default Description
without [] Packages left out of this build: their provider and their config/<name>.ts. See Leave a package out.

web (@kitr/web)

Option Default Description
mode spa static: prerender every route to HTML. spa: one shell, client routing. ssr: render on request through TanStack Start.
port 3000 Dev and production port.
host Bind address (localhost in dev, 0.0.0.0 in production).
pwa false Make the app installable: true, or { name, shortName, themeColor }.
base / Public base path.
open false Open the browser on kitr dev.

Prerendering, the sitemap and robots.txt come from config/seo.ts (kitr add seo).

server (@kitr/server)

Option Default Description
runtime node node, bun, deno or workers (Cloudflare, via wrangler.toml).
port 3001
host
entry server/main.ts The file that starts the server.

desktop (@kitr/desktop, Electron)

Option Default Description
port 3200 Dev server the window loads.
title, width, height, minWidth, minHeight Window.
appId Bundle identifier.
devtools true Open devtools in dev.

The Electron code lives in platforms/<name>/. Electron is the recommended desktop shell: it ships its own browser, so every user sees what you tested. See desktop.

tauri (@kitr/tauri)

Same options as desktop. The Rust project lives in platforms/<name>/src-tauri and needs the Rust toolchain. Downloads are small, but the system webview can render differently between machines. Pick desktop or tauri, not both. See tauri.

mobile (@kitr/mobile)

Option Default Description
targets ios,android Which native projects to create. --target ios picks one at run time.
appId, appName Bundle identifier and display name.
scheme Custom URL scheme for deep links.
port 3100 Dev server for live reload on the device.

Hooks like usePlatform() and useBackButton() work the same in the browser and on the phone. See mobile.

Where the code runs: runtime, platform and form factor

Three separate questions, three APIs:

Question API Values
Which host am I in? detectRuntime(), useRuntime() web, electron, tauri, capacitor
What kind of place is it? platform.type, platform.os, platform.native, usePlatform() web, desktop, mobile, server; macos, windows, linux, ios, android
How big is the screen and what is the input? useFormFactor() from @kitr/platform/react width (compact, medium, expanded), input (touch, pointer), orientation

What each environment answers. Electron and Tauri are both type: 'desktop'; only the runtime tells them apart.

Environment runtime type native os useFormFactor() before it can measure
Browser web web false from the user agent compact, touch
Server rendering web server false server compact, touch
Electron (desktop()) electron desktop true macos, windows, linux expanded, pointer
Tauri (tauri()) tauri desktop true macos, windows, linux expanded, pointer
Capacitor (mobile()) capacitor mobile true ios, android compact, touch

detectRuntime() only reads globals, so it is safe to call anywhere, including on the server.

Device packages on each runtime

The five device packages (native, notifications, files, secure-storage, updater) pick a driver for the runtime they run in. When a native runtime has no driver, they throw E_DRIVER_UNAVAILABLE instead of quietly running browser code. Set fallbackToWeb: true in the package's config to use the browser driver anyway.

Package Browser Electron (desktop()) Tauri (tauri())
secure-storage AES-GCM in IndexedDB-keyed storage (secure: false) safeStorage, ciphertext in userData (secure: true) Stronghold snapshot, password in the OS keyring (secure: true)
notifications Notification API, timers Electron Notification in main, badge, click events plugin-notification; scheduling by timer; taps on mobile only
files File System Access or <input type="file"> dialog and fs/promises on dialog-chosen paths, path set plugin-dialog and plugin-fs, path set
updater Service worker electron-updater plugin-updater and plugin-process
native Web APIs per capability Clipboard, Touch ID, system (theme, power, online, openExternal) Clipboard, system (theme, online, openExternal)

kitr add <package> installs the plugins each of your platforms needs. On Capacitor:

Package Capacitor driver Plugins Caveats
native clipboard, share, haptics, geolocation @capacitor/clipboard, share, haptics, geolocation camera and biometrics are supported: false; sharing File objects is not possible
notifications OS-scheduled local notifications, Android channels; capacitorPush() for FCM and APNs @capacitor/local-notifications, push-notifications Push needs Firebase config on Android and the Push capability on iOS
files System picker; save() goes through the cache and the share sheet; capacitorFileStore() @capawesome/capacitor-file-picker, @capacitor/filesystem, @capacitor/share No "save as" dialog on mobile
secure-storage Keychain and Keystore @aparajita/capacitor-secure-storage The Keychain survives an uninstall on iOS
updater Play in-app updates on Android, the App Store page on iOS @capawesome/capacitor-app-update Play offers updates only to builds installed from Play

A missing plugin throws E_DRIVER_UNAVAILABLE with the plugin to install.

One project, every platform

examples/everywhere is one project with a website, an Electron app and a Capacitor app:

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

It is the app kit plus kitr add desktop app, kitr add mobile phone, kitr add secure-storage and kitr add notifications. Add the platforms first, so the device packages see Capacitor and install its plugins.

Command What it does
kitr dev Runs every platform. Each line of output starts with [site], [app] or [phone]. Each platform has its own port.
kitr dev app Runs one.
kitr build site Builds one. kitr build app also packs the Electron app. kitr build phone also runs cap sync.

Branch on the shell in code:

import { detectRuntime } from '@kitr/platform'

if (detectRuntime() === 'electron') {
  // Electron only. In components, use useRuntime().
}
if (import.meta.env.KITR_PLATFORM === 'phone') {
  // Only in the build of the platform named phone. Other builds drop this branch.
}

Use the runtime for what the shell can do. Use KITR_PLATFORM for what to ship. To add your own state or a different driver, see Extending kitr.

Layout

app/                 # routes, components, features, channels: shared by every UI platform
server/
  main.ts            # the server platform entry (createServer + routes + realtime)
  routes.ts          # defineRoutes(...)
  controllers/
  realtime.ts        # createRealtimeServer({ channels })
config/              # one file per package (config/seo.ts, config/realtime.ts, config/theme.ts, ...)
  <platform>/        # files that replace a shared one for one platform's build
platforms/
  app/               # desktop: the Electron main/preload sources; tauri: src-tauri/
  phone/             # mobile: capacitor.config.ts, ios/, android/
dist/<name>/         # build output per platform
kitrrc.ts

One project, several apps

A site, an admin, an API, a phone app and a desktop app can be one project or several. Pick by what they share:

Use When
Platforms in one kitrrc.ts The apps share code, packages and their versions, and ship together.
A workspace (kitr.workspace.ts) The apps need different package versions, release dates or teams.

With platforms, each build can differ in three ways: its config files, the packages it leaves out, and its pages.

Config per platform

A file in config/<platform>/ replaces the shared file of the same name, for that platform's build only:

config/
  theme.ts         # every build
  flags.ts
  admin/
    theme.ts       # the admin build uses this instead of config/theme.ts
  phone/
    flags.ts

The file replaces the shared one. Nothing is merged. To change a few keys, spread the shared file:

// config/admin/theme.ts
import { defineConfig } from '@kitr/theme'
import base from '../theme'

export default defineConfig({ ...base, default: 'dark' })

The folder name must be a platform name; kitr doctor warns about any other folder in config/. kitr platforms lists the config files each platform uses and where each comes from. For a one-line difference, forPlatform() inside the shared file is enough.

Leave a package out

without takes packages out of one build. Name them as in packages, with or without @kitr/:

platforms: {
  www: web({ mode: 'ssr' }),
  admin: web({ mode: 'spa', port: 3002, without: ['analytics', 'consent'] }),
  phone: mobile({ without: ['analytics'] }),
},

That build has neither the package's provider nor its config/<name>.ts, so app.container.has('analytics') is false there. kitr config admin without analytics,consent sets it. A name that is not in packages is a kitr doctor error. A service only one platform needs is a provider that returns early unless app.platform is that platform.

Flags per platform

Keep one flags service and split the definitions into groups:

// app/flags/mobile.ts
export const mobileFlags = defineFlags({ 'offline-sync': { default: false } })

// app/flags/desktop.ts
export const desktopFlags = defineFlags({ 'menu-bar': { default: true } })

// app/flags.ts: shared code imports this one
export const flags = { ...mobileFlags, ...desktopFlags }

config/flags.ts lists flags, so the web and server builds see every flag. config/phone/flags.ts lists mobileFlags and config/desk/flags.ts lists desktopFlags. Each file can also use its own providers.

Theme per platform

config/admin/theme.ts spreads the shared theme and changes default, colors or tokens. Styles in app/styles/ stay shared. Two versions of a package in one project are not possible; that is a workspace.

Pages per platform

Need Tool
Some pages differ platforms: ['admin'] on the page or its group.
A whole different tree Two tables, app/routes.ts and app/admin/routes.ts. In app/router.tsx, import.meta.env.KITR_PLATFORM === 'admin' ? adminRoutes : routes drops the other tree from the bundle.
A page that must not render elsewhere, even when linked beforeLoad: onlyPlatform('admin') from @kitr/router.

The rest of the toolbox works per platform too: ssrRoute({ www: true, default: false }) in @kitr/seo, forPlatform() and perPlatform() in @kitr/platform, <Only>, <Except> and useFormFactor() in @kitr/platform/react, and the stacks in @kitr/router/stack. For apps in separate projects, see Workspaces.

Writing a platform package

import { definePlatform } from '@kitr/kitr/app'

export const kiosk = definePlatform<{ port?: number }>(
  {
    type: 'kiosk',
    description: 'Fullscreen Chromium on a Raspberry Pi',
    describe: { port: { type: 'number', default: 3300, description: 'Dev server port' } },
    requires: ['router', 'platform'],       // packages `kitr add kiosk <name>` adds first
    provider: '@acme/kitr-kiosk/provider',  // registered in kitrrc.ts providers
  },
  (options) => ({
    dependencies: { chromium: '^1.0.0' },
    async scaffold(ctx) { await ctx.write('kiosk.json', '{}') },
    async dev(ctx) { await ctx.exec('vite', ['--port', String(options.port)]) },
    async build(ctx) { await ctx.exec('vite', ['build', '--outDir', ctx.dir('dist')]) },
    async start(ctx) { await ctx.exec('chromium', ['--kiosk', `http://localhost:${options.port}`]) },
    async doctor(ctx) { return [] },
    agents: 'Run `kitr dev kiosk` and open the window on the Pi.',
  }),
)

ctx has the platform name, the project root, the options, the command-line flags and log, run and exec helpers.

A shell around the web build (like Electron, Tauri or Capacitor) can reuse the shared steps: shellPlatformHooks() from @kitr/platform/cli for dev, build and doctor, and createShellProvider() from @kitr/platform/shell for the runtime drivers. desktop, tauri and mobile are built this way.