Guide

updater

You ship a fix, but Ada keeps a tab on app.acme.com open all week and never reloads it. Installed apps are worse: they run the old version until someone thinks to update. @kitr/updater notices when a new version is ready, tells people with a banner, and updates the app in place when they click it.

It is one check() and apply() flow with one status, whatever does the updating. In the browser the driver watches the service worker that the Web platform's pwa option builds with vite-plugin-pwa; applying activates the new worker and reloads. Desktop and mobile shells plug in their own updater as a driver. kitr adds the status machine, periodic checks, "remind me later", a React hook, a ready-made banner and a memory driver for tests.

It runs in the browser only. On the server the provider binds nothing. To keep data fresh between versions, use Query; this package is about the app's own code.

This guide follows Acme's orders app: version 2.4.0 ships, Ada sees "Version 2.4.0 is ready", and one click moves her to it without losing her place.

Install

kitr add updater

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

What kitr add does:

  • Installs @kitr/updater and adds it to packages in kitrrc.ts.
  • Adds () => import('@kitr/updater/provider') to providers in kitrrc.ts.
  • Writes config/updater.ts (shown under Configure).
  • Writes app/components/update-banner.tsx, a banner that stays hidden until an update is ready (shown under Show your first update banner).
  • Adds a ## kitr: updater section to AGENTS.md: useUpdate() from @kitr/updater/react drives the banner, which is mounted once in the root layout; status runs idle, checking, available, downloading, ready; the web driver watches the service worker and desktop and mobile packages register theirs.

It adds no environment variables or scripts. kitr add updater asks no questions, and no kit includes it. To preview the changes:

kitr add updater --dry-run

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

kitr remove updater removes the provider, the packages entry, the dependency, the AGENTS.md section and config/updater.ts. It leaves app/components/update-banner.tsx because you may have edited it, and lists the files that still import @kitr/updater.

Configure

kitr add writes this file:

// config/updater.ts
import { defineConfig } from '@kitr/updater'

export default defineConfig({
  checkOnBoot: true,
  interval: 60 * 60 * 1000, // look again every hour while the app is open
  auto: false, // true applies as soon as an update is ready, without asking
})
Option Default Description
checkOnBoot true Check once as the app starts.
interval none Check again every this many milliseconds while the app is open. Without it the app checks at start only. The generated file sets one hour.
auto false Apply as soon as an update is found, without asking.
driver registered, else the service-worker driver An UpdaterDriver that replaces the one the service would pick. For tests and custom shells.

The service picks its driver in this order: driver from the config, a driver a provider registered under updater, then the service-worker driver in a browser. The defaults in the table are what you get without a config file; the file kitr add writes only adds the hourly interval.

For the browser to have something to update, turn on the service worker in the web platform. registerType: 'prompt' is the default and is what the updater expects: the new worker waits until you apply it.

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

export default defineKitrConfig({
  // ...
  platforms: {
    site: web({ mode: 'spa', pwa: true }),
  },
})

Show your first update banner

kitr add writes this component:

// app/components/update-banner.tsx
import { useUpdate } from '@kitr/updater/react'

/** Mount once in the root layout. Hidden until the updater has something ready. */
export function UpdateBanner() {
  const { available, update, status, progress, apply, dismiss } = useUpdate()
  if (!available) return null
  return (
    <div role="status" className="fixed inset-x-0 bottom-0 flex items-center justify-center gap-3 bg-foreground p-3 text-sm text-background">
      <span>Version {update?.version} is ready.</span>
      <button type="button" className="rounded-md bg-background px-3 py-1 text-foreground" onClick={apply} disabled={status === 'downloading'}>
        {status === 'downloading' ? `Updating ${Math.round(progress * 100)}%` : 'Restart to update'}
      </button>
      <button type="button" className="underline" onClick={dismiss}>
        Later
      </button>
    </div>
  )
}

Mount it once, in the root route:

// app/document.tsx
import { HeadContent, Outlet, Scripts } from '@kitr/router'
import { UpdateBanner } from '#app/components/update-banner'

export function RootDocument() {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <Outlet />
        <UpdateBanner />
        <Scripts />
      </body>
    </html>
  )
}

Deploy a new build while Ada has the app open. Within the hour (or at her next visit) a bar appears at the bottom: "Version new is ready. Restart to update · Later". She clicks Restart to update, the page reloads on the new build, and the bar is gone. The service-worker driver does not know version numbers, so it reports new until you give it one; see Show version numbers on the web.

The service only exists in the browser. During server rendering useUpdate() reports idle with nothing available, so the banner renders nothing there and appears once the browser finds an update.

Follow the update status

useUpdate() returns the updater's state plus three actions:

Field Type Description
status UpdateStatus Where the flow is; see below.
available boolean true while status is available or ready. Show the banner on this.
update UpdateInfo | null { version, notes?, date?, size? } of the update found.
progress number 0 to 1 while downloading.
error Error | null Why the last check or apply failed.
currentVersion string | null The running version, when the driver knows it.
check () => Promise<UpdateInfo | null> Look now.
apply () => Promise<void> Download and install.
dismiss () => void Hide this version.

status moves through these values:

Status Meaning
idle Nothing has happened yet. Also what the first render sees.
checking A check is running.
available An update was found and not applied.
downloading apply() is running; progress moves.
ready Installed. The driver usually restarts or reloads the app at this point.
up-to-date The last check found nothing, or the person dismissed the update.
error The last check or apply failed; error says why.

A typical run is idle → checking → available → downloading → ready. A check during downloading or ready is skipped, so the hourly check never interrupts a download.

The hook reads the state with useSyncExternalStore, so a component re-renders on every change and nothing else.

Check for updates by hand

Add a "Check for updates" item to a settings page. check() resolves to the update, or null when the app is current:

// app/components/settings/check-for-updates.tsx
import { useUpdate } from '@kitr/updater/react'

export function CheckForUpdates() {
  const { status, check, currentVersion } = useUpdate()
  return (
    <section>
      <p>Acme Orders {currentVersion ?? ''}</p>
      <button type="button" onClick={check} disabled={status === 'checking'}>
        {status === 'checking' ? 'Checking…' : 'Check for updates'}
      </button>
      {status === 'up-to-date' && <p>You're on the latest version.</p>}
    </section>
  )
}

When something is found, status becomes available and the banner from the root layout shows it, so this page doesn't need its own "Update" button. A check never throws: a failure sets status: 'error' and error, and resolves to null.

Apply an update

apply() downloads (where the driver needs to) and installs, moving progress from 0 to 1 on the way. Call it as many times as you like: while an apply is running or done, more calls do nothing, so a double click installs once.

// app/components/settings/update-now.tsx
import { useUpdate } from '@kitr/updater/react'

export function UpdateNow() {
  const { status, update, progress, apply } = useUpdate()
  if (status !== 'available' && status !== 'downloading') return null
  return (
    <div>
      <p>Version {update?.version} is ready.</p>
      {update?.notes && <p>{update.notes}</p>}
      <progress value={progress} max={1} />
      <button type="button" onClick={apply} disabled={status === 'downloading'}>
        Install now
      </button>
    </div>
  )
}

What "install" means depends on the driver. The service-worker driver tells the waiting worker to take over, waits for it (up to two seconds) and reloads the page. A desktop driver typically downloads the installer and restarts the app. With no update found, apply() does nothing.

Applying reloads or restarts the app, so anything not saved is lost. Don't call apply() while someone is halfway through a form; let them choose the moment.

Let people postpone an update

dismiss() hides the current update until a different version appears. It sets status to up-to-date and update to null:

// app/components/settings/remind-me-later.tsx
import { useUpdate } from '@kitr/updater/react'

export function RemindMeLater() {
  const { update, dismiss } = useUpdate()
  if (!update) return null
  return (
    <button type="button" onClick={dismiss}>
      Remind me about {update.version} later
    </button>
  )
}

After Ada dismisses 2.4.0, the hourly check still finds 2.4.0 but keeps the banner hidden. When 2.4.1 ships, it appears again. The dismissal lives in memory, so a reload shows 2.4.0 again at the next check.

Apply updates without asking

For a kiosk, a wall display or a dashboard nobody touches, set auto: true. Every update found by a check, or announced by the driver, is applied at once:

// config/updater.ts
import { defineConfig } from '@kitr/updater'

export default defineConfig({
  checkOnBoot: true,
  interval: 15 * 60 * 1000,
  auto: true,
})

On the web that means the page reloads by itself when a new build is deployed. Keep auto: false for anything people type into.

Show version numbers on the web

A service worker does not know your version numbers, so the default web driver reports update.version as 'new' and currentVersion as '0.0.0'. Give it your version and it reports '2.3.0' as the current one and '2.3.0+' for the waiting build. The version you pass is the build that is running; the service worker can't tell what the new one is called.

Set the driver yourself in the config, from the version Vite builds in:

// config/updater.ts
import { createServiceWorkerUpdaterDriver, defineConfig } from '@kitr/updater'
import { env } from '#app/env'

export default defineConfig({
  driver: createServiceWorkerUpdaterDriver({ version: env.VITE_APP_VERSION }),
  checkOnBoot: true,
  interval: 60 * 60 * 1000,
  auto: false,
})

Declare VITE_APP_VERSION in app/env.ts and set it in your build. Offline adds the same variable when you install it. For a real version string in the banner, serve a small JSON file with each deploy and write a driver that reads it, as shown under Extend.

The service-worker driver needs a registered worker. Without one, check() resolves to null and the status is up-to-date.

Handle a failed update

Neither check() nor apply() throws. A failure sets status: 'error' and keeps the reason in error:

// app/components/settings/update-error.tsx
import { useUpdate } from '@kitr/updater/react'

export function UpdateError() {
  const { status, error, check } = useUpdate()
  if (status !== 'error') return null
  return (
    <p role="alert">
      Couldn't update: {error?.message}.{' '}
      <button type="button" onClick={check}>
        Try again
      </button>
    </p>
  )
}

A later check() clears error. A non-Error rejection from the driver, such as Promise.reject('offline'), becomes new Error('offline').

Use it outside React

Outside components, read the service from the app's container. useUpdater(app) from @kitr/updater does the same:

// app/lib/settings/updates-on-focus.ts
import type { KitrApp } from 'kitr/app'
import { useUpdater } from '@kitr/updater'

export function checkWhenFocused(app: KitrApp) {
  const updater = useUpdater(app) // same as app.container.use('updater')
  const onFocus = () => void updater.check()
  window.addEventListener('focus', onFocus)
  const off = updater.subscribe(() => {
    if (updater.state.status === 'available') console.info('Update', updater.state.update?.version)
  })
  return () => {
    window.removeEventListener('focus', onFocus)
    off()
  }
}

updater.state is a fresh object after every change; subscribe(listener) calls listener with no arguments after each one and returns an unsubscribe function. updater.stop() ends the interval and the driver's listener; the provider calls it when the app shuts down. To build a service with no app, call createUpdater(config). When the Devtools provider is installed, the service logs available, applied and failed checks and applies under updater.

Where updates come from

Where it runs Driver What apply() does
Browser with pwa on createServiceWorkerUpdaterDriver() Activates the waiting worker and reloads the page.
Browser without a service worker The same driver Nothing to apply: every check is up-to-date.
Desktop and mobile shells The service-worker driver, unless a provider registers one Same as the browser.
Server None; the provider binds nothing Not available.

Today Desktop (Electron), Tauri and Mobile register only platform and storage drivers, not updater. To update an installed app, register a driver for your shell's updater, as shown under Extend.

Testing

createMemoryUpdaterDriver({ version, next }) stands in for a real updater:

Member Description
next The update the next check() finds, or null. Set it in the options or assign it later.
applied Every update that apply() installed.
announce(info) Simulates the driver finding an update on its own, as a service worker does.
currentVersion() Resolves to version, default '1.0.0'.

apply() reports progress 0.5 then 1, records next in applied and clears next.

The provider only binds the updater in the browser environment, so create the test app with environment: 'client':

// app/components/update-banner.test.tsx
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createMemoryUpdaterDriver } from '@kitr/updater'
import { updaterProvider } from '@kitr/updater/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { describe, expect, it } from 'vitest'
import { UpdateBanner } from './update-banner'

async function setup(next: { version: string } | null) {
  const driver = createMemoryUpdaterDriver({ version: '2.3.0', next })
  const app = await createTestApp({
    environment: 'client',
    providers: [updaterProvider],
    config: { updater: { driver } },
  })
  return { app, driver }
}

describe('update banner', () => {
  it('shows the new version and installs it', async () => {
    const { app, driver } = await setup({ version: '2.4.0' })
    renderWithKitr(<UpdateBanner />, { app })
    await screen.findByText('Version 2.4.0 is ready.')
    fireEvent.click(screen.getByRole('button', { name: 'Restart to update' }))
    await waitFor(() => expect(driver.applied).toEqual([{ version: '2.4.0' }]))
    expect(app.container.use('updater').state).toMatchObject({ status: 'ready', progress: 1 })
  })

  it('stays hidden after Later until a newer version ships', async () => {
    const { app, driver } = await setup({ version: '2.4.0' })
    renderWithKitr(<UpdateBanner />, { app })
    fireEvent.click(await screen.findByRole('button', { name: 'Later' }))
    expect(screen.queryByRole('status')).toBeNull()
    driver.announce({ version: '2.4.0' })
    expect(screen.queryByRole('status')).toBeNull()
    driver.announce({ version: '2.4.1' })
    await screen.findByText('Version 2.4.1 is ready.')
  })

  it('renders nothing when the app is current', async () => {
    const { app } = await setup(null)
    renderWithKitr(<UpdateBanner />, { app })
    await waitFor(() => expect(app.container.use('updater').state.status).toBe('up-to-date'))
    expect(screen.queryByRole('status')).toBeNull()
  })
})

The provider checks at boot, so the banner finds 2.4.0 without a click. For code that takes the service directly, createUpdater({ driver }) needs no app; call updater.stop() at the end of a test that sets interval.

Extend

A driver implements UpdaterDriver:

interface UpdaterDriver {
  name: string
  check(): Promise<UpdateInfo | null> // null when current
  apply(onProgress?: (fraction: number) => void): Promise<void> // fraction 0..1
  onAvailable?(listener: (info: UpdateInfo) => void): () => void // for updates found in the background
  currentVersion?(): Promise<string>
}

The smallest useful driver for the web reads a version.json that each deploy writes next to index.html (for example { "version": "2.4.0", "notes": "Faster invoice search" }) and reloads to apply:

// app/updater/version-file-driver.ts
import type { UpdateInfo, UpdaterDriver } from '@kitr/updater'

export function versionFileDriver(running: string): UpdaterDriver {
  return {
    name: 'version-file',
    async check() {
      const response = await fetch('/version.json', { cache: 'no-store' })
      if (!response.ok) throw new Error(`version.json: HTTP ${response.status}`)
      const latest = (await response.json()) as UpdateInfo
      return latest.version !== running ? latest : null
    },
    async apply(onProgress) {
      onProgress?.(1)
      location.reload()
    },
    currentVersion: async () => running,
  }
}
// config/updater.ts
import { defineConfig } from '@kitr/updater'
import { env } from '#app/env'
import { versionFileDriver } from '#app/updater/version-file-driver'

export default defineConfig({
  driver: versionFileDriver(env.VITE_APP_VERSION),
  checkOnBoot: true,
  interval: 60 * 60 * 1000,
  auto: false,
})

The banner now reads "Version 2.4.0 is ready." For an app without a service worker, this is the whole update flow.

For the Electron shell, run electron-updater in the main process and reach it over the IPC bridge the desktop platform sets up. Install it with pnpm add electron-updater, then handle three channels in main:

// platforms/app/main.ts
import { app, BrowserWindow, ipcMain } from 'electron'
import { autoUpdater } from 'electron-updater'
import { handleIpc } from '@kitr/desktop/main'

autoUpdater.autoDownload = false

handleIpc<{
  'updater.check': () => { version: string; notes?: string } | null
  'updater.apply': () => void
}>(ipcMain, {
  'updater.check': async () => {
    const result = await autoUpdater.checkForUpdates()
    const info = result?.updateInfo
    if (!info || info.version === app.getVersion()) return null
    return { version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined }
  },
  'updater.apply': async () => {
    await autoUpdater.downloadUpdate()
    autoUpdater.quitAndInstall()
  },
})

autoUpdater.on('download-progress', (p) => {
  BrowserWindow.getAllWindows()[0]?.webContents.send('updater.progress', p.percent / 100)
})
// ...
// app/providers/electron-updater-provider.ts
import { defineProvider } from 'kitr/app'
import { createIpcClient } from '@kitr/desktop/client'
import type { UpdaterDriver } from '@kitr/updater'

type UpdaterIpc = {
  'updater.check': () => { version: string; notes?: string } | null
  'updater.apply': () => void
}
const ipc = createIpcClient<UpdaterIpc, { 'updater.progress': (fraction: number) => void }>()

function electronUpdater(): UpdaterDriver {
  return {
    name: 'electron-updater',
    check: () => ipc.invoke('updater.check'),
    async apply(onProgress) {
      const off = ipc.on('updater.progress', (fraction) => onProgress?.(fraction))
      try {
        await ipc.invoke('updater.apply')
      } finally {
        off()
      }
    },
  }
}

export default defineProvider({
  name: 'electron-updater',
  register(app) {
    if (ipc.available) app.drivers.register('updater', electronUpdater(), { platform: 'desktop' })
  },
})

Create the provider with kitr make:provider electron-updater, which also adds () => import('#app/providers/electron-updater-provider') to providers in kitrrc.ts. Inside the Electron app, the banner now shows the release version, the button shows download progress, and the app restarts on the new build. In the web build ipc.available is false, so the service-worker driver still applies.

A Tauri app does the same with @tauri-apps/plugin-updater: check() resolves to an update or null, and the update's downloadAndInstall() reports progress before you relaunch. The UpdaterDriver and UpdateInfo types are in the API reference.

Next steps