Menu Bar Apps

On this page 8

A native menu bar app is a small window that hangs off a tray icon: an stx template for the popup, a few JSON endpoints for the buttons in it, and a tray menu. createMenuBarApp wires those to a Craft window so you write the three pieces and nothing else.

bun add @stacksjs/stx

A complete app

import { createMenuBarApp } from '@stacksjs/stx/menubar'

const app = createMenuBarApp({
  name: 'Timer',
  template: new URL('./timer.stx', import.meta.url).pathname,

  preferences: {
    durationMinutes: 25,
    launchAtLogin: false,
  },

  context: prefs => ({
    duration: prefs.get('durationMinutes'),
    running: isRunning(),
  }),

  routes: {
    'POST /api/start': (request, prefs) => {
      start(prefs.get('durationMinutes'))
      return { running: true }
    },
    'POST /api/stop': () => {
      stop()
      return { running: false }
    },
  },

  menu: prefs => [
    { label: isRunning() ? 'Stop' : 'Start', action: 'toggle', shortcut: 'Cmd+Shift+T' },
    { type: 'separator' },
    { label: 'Launch at Login', type: 'checkbox', checked: prefs.get('launchAtLogin'), action: 'toggleLaunch' },
    { label: 'Quit', action: 'quit', shortcut: 'Cmd+Q' },
  ],

  launchAtLogin: 'launchAtLogin',
})

await app.start()

That is the whole app. There is no server to configure, no port to pick, no preference file to read or write.

The pieces

Template

template points at a .stx file rendered on every request, so the popup always opens against current state. Whatever context returns is in scope:

<div class="timer">
  <p>{{ running ? 'Running' : 'Idle' }}</p>
  <p>{{ duration }} minutes</p>
</div>

Routes

Routes are keyed by "<METHOD> <path>". A key without a method is a GET. Handlers receive the request and the preference store, and return anything JSON-serializable — or a Response if you want full control:

routes: {
  '/api/status': () => ({ running: isRunning() }),
  'POST /api/duration': async (request, prefs) => {
    const { minutes } = await request.json()
    prefs.set('durationMinutes', minutes)
    return prefs.getAll()
  },
}

Three endpoints exist without you declaring them, because every menu bar app needs them. Declare the same route to override any of them.

RouteReturns
GET /api/preferencesAll preference values
POST /api/preferencesApplies a patch, ignoring unknown keys, and returns the result
GET /api/menuThe current tray menu

Preferences

preferences supplies defaults; the store persists to ~/Library/Application Support/<name>/ and is passed to context, routes and menu. POST /api/preferences only writes keys present in the defaults, so a stray request cannot add fields of its own.

Name a boolean preference in launchAtLogin and the login item tracks it, including when the user toggles it while the app is running.

Window

Menu bar defaults — 320×640, tray icon, no Dock icon, hidden titlebar, floating, dark — are applied first, and window overrides any of them:

window: { width: 360, height: 520, darkMode: false }

Shipping it

A shipped app is compiled to a single binary, so the .stx file is no longer on disk to read. Embed it at build time and pass it as templateSource — the app then renders identically whether run from source or from a bundle:

import templateSource from './timer.stx' with { type: 'text' }

createMenuBarApp({
  name: 'Timer',
  template: new URL('./timer.stx', import.meta.url).pathname,
  templateSource,
  // …
})

Package it with Craft, which sets LSUIElement so it ships without a Dock icon:

import { packageApp } from 'craft-native'

await packageApp({
  name: 'Timer',
  version: '1.0.0',
  binaryPath: './dist/timer',
  bundleId: 'com.example.timer',
  macos: {
    menuBarOnly: true,
    appStore: true,
    signIdentity: '3rd Party Mac Developer Application: You (TEAMID)',
    installerIdentity: '3rd Party Mac Developer Installer: You (TEAMID)',
    category: 'public.app-category.utilities',
  },
})

See Craft's packaging guide for signing, notarization and App Store submission.

Standalone template builds

When compiling an app to one binary, prepare its embedded templates before Bun.build. Client imports must be bundled while their source files exist; runtime rendering still supplies current preferences and server data.

import { menuBarTemplatePlugin } from '@stacksjs/stx/menubar-build'

const templates = await menuBarTemplatePlugin({ templates: ['./src/popup.stx'] })
await Bun.build({
  entrypoints: ['./app.ts'],
  target: 'bun',
  compile: { outfile: './dist/MyApp' },
  plugins: [templates],
})

The app imports the template with with { type: 'text' } and passes it as templateSource to createMenuBarApp. List every embedded .stx template. Client TypeScript and its transitive imports are prepared by stx, including handlers used only in template expressions. Missing imports stop the build. Pass server values through rendered data attributes so they remain runtime data instead of build-time JavaScript. Native access stays behind useNativeDesktop(); optional native menu bar grouping is provided by Craft's menubar API and must be selected explicitly.