Guide

web

A marketing page on a CDN, a dashboard behind a login and a store with indexed product pages ship to the browser in different ways. @kitr/web is the web platform: one line in kitrrc.ts says whether your app/ builds to prerendered HTML, a single-page app or a server-rendered app, and whether it is installable.

It is built on Vite and TanStack Start, with vite-plugin-pwa for installable apps. On top, kitr dev, kitr build and kitr start run each mode, kitr config edits the options and kitr doctor checks the setup. The hooks in @kitr/web/client run in the browser.

The same app can also ship as Mobile, Desktop (Electron) or Tauri; for an API with no UI use Server.

The examples build the Acme orders app at app.acme.com.

Install

Add a web platform named site:

kitr add web site

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

web is the platform type and site the name you address it by (kitr dev site). Without a name, kitr add web calls it site.

Everything kitr add changes

What kitr add web site does, in order:

  • Adds @kitr/web to packages in kitrrc.ts and installs it, running Router and React setup first when missing. Together they write app/app.ts, app/client.tsx, app/router.tsx and the starter routes.
  • Installs @tanstack/react-start, and vite, @vitejs/plugin-react and @tanstack/router-plugin as dev dependencies. With --pwa it adds vite-plugin-pwa (dev). With --mode ssr it adds srvx.
  • Adds () => import('@kitr/web/provider') to providers in kitrrc.ts.
  • Writes site: web({...}) into platforms in kitrrc.ts, with the flags you passed as options, and adds import { web } from '@kitr/web'.
  • Sets the dev, build and start scripts in package.json to kitr dev, kitr build and kitr start.
  • With --pwa, writes public/icons/README.md, a note naming the two icons the manifest points at. --no-scaffold skips this step.
  • Adds a ## kitr: platform site section to AGENTS.md. It tells agents how kitr dev, kitr build and kitr start behave for the chosen mode, and with pwa to render an update banner once.

Platform options are flags on the same command; a flag without a value means true:

kitr add web site --mode ssr --pwa --port 3000

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

Flag Default Effect
--mode spa static, spa or ssr.
--port 3000 Port for kitr dev site and kitr start site.
--host Interface to bind.
--pwa false Make the app installable. Installs vite-plugin-pwa.
--base / Public base path.
--open false Open the browser on kitr dev.
--no-scaffold Register the platform without writing files.
--no-install Edit package.json without running the package manager.
--dry-run Print what would change.

Every kit except server already has a web platform named site: blank, app and desktop use spa; fullstack, saas, site and realtime use ssr; mobile uses spa with pwa: true. kitr new takes the same options:

kitr new acme-orders --kit app --mode ssr --pwa --port 3000

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

kitr platforms remove site takes the platform out of kitrrc.ts and leaves @kitr/web and the files under app/ in place.

Configure

@kitr/web has no config file. Its options live on the web() call in kitrrc.ts:

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

export default defineKitrConfig({
  packages: ['@kitr/react', '@kitr/router', '@kitr/web'],
  providers: [() => import('@kitr/web/provider')],
  platforms: {
    site: web({ mode: 'ssr', port: 3000, pwa: true }),
  },
})
Option Default Description
mode 'spa' static prerenders pages, spa renders in the browser, ssr renders on the server per request.
port 3000 Dev and production port. Must be a number between 0 and 65535.
host Interface to bind. Vite's default (localhost) in dev, 0.0.0.0 for kitr start.
pwa false true makes the app installable: a web manifest and a service worker. An object sets the manifest (see below).
base '/' Public base path, for apps served under a sub-path.
open false Open the browser on kitr dev.

When pwa is an object, it takes these options:

Option Default Description
pwa.name the app name Full name in the install dialog; the app name comes from package.json (acme-orders becomes Acme Orders).
pwa.shortName pwa.name, then the app name Name under the home-screen icon.
pwa.description Manifest description.
pwa.themeColor '#0f172a' Browser UI color.
pwa.backgroundColor '#ffffff' Splash screen background.
pwa.display 'standalone' standalone, fullscreen, minimal-ui or browser.
pwa.startUrl '/' Page the installed app opens.
pwa.icons /icons/icon-192.png and /icons/icon-512.png Manifest icons, { src, sizes, type?, purpose? }. The 512 icon is any maskable.
pwa.registerType 'prompt' prompt waits for you to call update() from useServiceWorker(). autoUpdate activates a new worker as soon as it installs.
pwa.devEnabled false Run the service worker under kitr dev too.
pwa.plugin Raw vite-plugin-pwa options, merged last.

An unknown mode fails when kitrrc.ts loads:

[kitr] Invalid options for web platform:
  mode: mode must be one of static, spa, ssr

A bad port fails the same way with port: port must be a number between 0 and 65535.

Variable Used for
KITR_SITE_<OPTION> Override one option for one run: KITR_SITE_PORT=4000 kitr dev site.
PORT The port kitr start listens on when no --port flag is given. Wins over the port option.
HOST The interface kitr start binds when no --host flag is given.
KITR_PLATFORM Set by the CLI to the platform name; the Vite plugin uses it to pick the platform to build.

Ship your first page

In a project with a web platform (kitr new acme-orders), replace the home page with the order list:

// app/pages/home.tsx
const orders = [
  { id: 'ord_1042', customer: 'Ada', total: 129 },
  { id: 'ord_1043', customer: 'Grace', total: 84 },
]

export default function OrdersPage() {
  return (
    <main>
      <h1>Acme orders</h1>
      <ul>
        {orders.map((order) => (
          <li key={order.id}>
            {order.id}: {order.customer}, ${order.total}
          </li>
        ))}
      </ul>
    </main>
  )
}

Run it:

kitr dev site

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

Vite starts on http://localhost:3000/ and you see the two orders; edits reload the page. The page sits on the route table in app/routes.ts; see Router for links, loaders and layouts. kitr dev with no name starts every platform at once.

When it looks right, build it and serve the build:

kitr build site
kitr start site
# ✔ site: serving dist/site on http://localhost:3000/

Choose a rendering mode

All three modes build the same app/; switching is a one-word change. Pick by where you host and what search engines need to see.

mode What you get Host on Use it for
static Every route prerendered to an HTML file at build time. Any static host or CDN. Marketing pages, docs, a blog.
spa One HTML shell; routes render in the browser. Any static host. Apps behind a login, where pages need no indexing.
ssr Each request rendered on the server, then hydrated. A Node-like host that runs kitr start. Stores and apps whose pages must be indexed with live data.

Change the mode in place:

kitr config site mode ssr
# ✔ site.mode = "ssr"

kitr config validates the value before it writes kitrrc.ts. When you move to ssr, run kitr add web site --mode ssr again or install srvx yourself: kitr start needs it.

Loaders run where the page renders: in the browser for spa, once at build time for static (so data must exist when you build or be fetched on the client), and on the server for the first request, then in the browser, for ssr.

Render on the server

In ssr mode every page renders on the server. To keep one page client-only, spread ssrRoute(false) from SEO into its link:

// app/routes.ts
import { ssrRoute } from '@kitr/seo'

r.page('/orders/$orderId/edit', { ...ssrRoute(false), lazy: () => import('./pages/edit-order') })
// app/pages/edit-order.tsx
import { getRouteApi } from '@kitr/router'

const route = getRouteApi('/orders/$orderId/edit')

export default function EditOrder() {
  const { orderId } = route.useParams()
  return <h1>Edit {orderId}</h1>
}

ssrRoute('data-only') runs the loader on the server and renders the component in the browser. With a Server platform in the project, an ssr web platform also answers its routes through app/server.ts, so one process serves pages and the API.

Prerender pages

A static platform prerenders / and every page linked from it. To add unlinked pages, or prerender a few pages of an ssr app, list them in config/seo.ts (written by kitr add seo):

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

export default defineConfig({
  site: { name: 'Acme', url: 'https://app.acme.com' },
  prerender: { routes: ['/pricing', '/changelog'], crawlLinks: true },
})

kitr build site writes one HTML file per page under dist/site/client, and site.url turns on sitemap.xml.

A page that does not render is reported in one line and left out of the sitemap. A crawled link only warns; a route listed in routes (and / on a static platform) fails the build:

kitr build site
# [prerender] 404 at /llms-full.txt, linked from /docs/agents
# [prerender] 404 at /changelog
# error during build: prerender failed for /changelog: routes listed in config/seo.ts must render; crawled links only warn.

Set prerender: { routes, crawlLinks: true, failOnError: true } to fail the build on broken crawled links too, for example in CI.

Run more than one web platform

One project can hold several web platforms, each with its own port, mode and dist/<name> folder:

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

export default defineKitrConfig({
  packages: ['@kitr/react', '@kitr/router', '@kitr/web'],
  providers: [() => import('@kitr/web/provider')],
  platforms: {
    www: web({ mode: 'static', port: 3000 }),
    admin: web({ mode: 'spa', port: 3002 }),
  },
})

Add the second one from the command line:

kitr add web admin --mode spa --port 3002
kitr dev www admin

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

Both build the same app/; to render something in one only, see "Branch on the platform at build time".

Change options from the command line

kitr config reads and writes platform options in kitrrc.ts:

kitr config site
# site (web)
#   mode         ssr            static (prerendered HTML), spa (client-rendered shell) or ssr (server-rendered) [static|spa|ssr]
#   port         3000           Dev and production port
#   host         unset          Host to bind (default: localhost in dev, 0.0.0.0 in production)
#   pwa          true           Installable app with a manifest and service worker
#   base         /              Public base path
#   open         false          Open the browser on dev
#
#   Override at runtime with KITR_SITE_<OPTION>=... or `kitr dev site --<option> value`.
kitr config site port 4000
kitr config site pwa --unset

To change an option for one run only, pass it as a flag or an env variable:

kitr dev site --port 4000 --open
KITR_SITE_PORT=4000 kitr dev site

Open the dev server to your network

To try the app on a phone on the same Wi-Fi, bind every interface:

kitr dev site --host 0.0.0.0

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

Vite prints a Network: URL such as http://192.168.1.20:3000/; open it on the phone. Set host: '0.0.0.0' in kitrrc.ts to make it the default. If port 3000 is taken, kitr dev fails rather than moving on; pick another with --port.

Serve the app under a sub-path

When the app lives at acme.com/orders/ instead of its own domain, set base:

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

export default defineKitrConfig({
  packages: ['@kitr/react', '@kitr/router', '@kitr/web'],
  providers: [() => import('@kitr/web/provider')],
  platforms: {
    site: web({ mode: 'spa', base: '/orders/' }),
  },
})

Asset URLs then start with /orders/, and kitr start serves the build under that path. A base in vite.config.ts wins over the platform's.

Make the app installable

Turn on pwa and the build writes a web manifest and a service worker:

kitr config site pwa true
pnpm add -D vite-plugin-pwa

kitr add web site --pwa installs vite-plugin-pwa; if you turn pwa on later with kitr config, install it yourself (kitr doctor reports it when missing). The manifest uses the app name from package.json; to set it yourself, pass an object:

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

export default defineKitrConfig({
  packages: ['@kitr/react', '@kitr/router', '@kitr/web'],
  providers: [() => import('@kitr/web/provider')],
  platforms: {
    site: web({
      mode: 'spa',
      pwa: { name: 'Acme Orders', shortName: 'Orders', themeColor: '#0b5fff' },
    }),
  },
})

Put the two icons the manifest points at in public/icons/: icon-192.png (192x192) and icon-512.png (512x512, logo inside the maskable safe zone). pwaManifest() shows the manifest a set of options produces:

// scripts/print-manifest.ts
import { pwaManifest, web } from '@kitr/web'

const site = web({ pwa: { name: 'Acme Orders', shortName: 'Orders', themeColor: '#0b5fff' } })
console.log(pwaManifest(site.options, 'Acme'))
// => {
//   name: 'Acme Orders',
//   short_name: 'Orders',
//   description: undefined,
//   theme_color: '#0b5fff',
//   background_color: '#ffffff',
//   display: 'standalone',
//   start_url: '/',
//   icons: [
//     { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
//     { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
//   ],
// }

kitr build emits the service worker. It precaches the client build and serves the app shell for navigations, except under /api/, /ws and /_serverFn/, which always reach the network.

Show your own install button

Browsers fire beforeinstallprompt once. useInstallPrompt() from @kitr/web/client keeps that event so your own button can show the dialog:

// app/components/install-button.tsx
import { useInstallPrompt } from '@kitr/web/client'

export function InstallButton() {
  const { canInstall, installed, install } = useInstallPrompt()
  if (installed) return <p>Acme Orders is on your home screen.</p>
  if (!canInstall) return null
  return (
    <button type="button" onClick={() => install()}>
      Install Acme Orders
    </button>
  )
}

useInstallPrompt() returns:

Field Description
canInstall true once the browser offered installation. false in Safari, which never fires the event, and after install.
installed true after the browser fires appinstalled.
install() Shows the browser dialog. Resolves to 'accepted', 'dismissed', or 'unavailable' when there is nothing to show.
const outcome = await install()
// => 'accepted'

On iOS, people install from Safari's Share menu, so show a short hint there instead of the button.

Offer an update when a new version ships

With the default registerType: 'prompt', a new service worker waits until you tell it to take over. Render one banner near the app root:

// app/components/pwa-update.tsx
import { useServiceWorker } from '@kitr/web/client'

export function PwaUpdate() {
  const { needRefresh, update } = useServiceWorker()
  if (!needRefresh) return null
  return (
    <div role="status">
      A new version of Acme Orders is ready.{' '}
      <button type="button" onClick={() => update()}>
        Reload
      </button>
    </div>
  )
}
// app/document.tsx
import { HeadContent, Outlet, Scripts } from '@kitr/router'
import { PwaUpdate } from '#app/components/pwa-update'

export function head() {
  return {
    meta: [
      { charSet: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' },
    ],
    links: [{ rel: 'manifest', href: '/manifest.webmanifest' }],
  }
}

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

useServiceWorker() registers /sw.js and returns:

Field Description
needRefresh true when a new worker is installed and waiting.
offlineReady true when the first worker is active and the app works offline.
update() Tells the waiting worker to activate, then reloads the page once it takes control.
registration The ServiceWorkerRegistration, or null before registration finishes.

To run code when an update arrives, pass onNeedRefresh, wrapped in useCallback so the worker is not registered again on every render:

// app/components/pwa-update-toast.tsx
import { useCallback } from 'react'
import { useServiceWorker } from '@kitr/web/client'

export function PwaUpdateToast({ notify }: { notify: (message: string) => void }) {
  const onNeedRefresh = useCallback(() => notify('Acme Orders has an update.'), [notify])
  useServiceWorker({ onNeedRefresh })
  return null
}

To skip the banner, set registerType: 'autoUpdate': the new worker activates as soon as it installs.

Test the service worker in development

The worker is off under kitr dev so stale caches never hide your edits. To debug install and offline behaviour, turn it on, and off again afterwards:

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

export default defineKitrConfig({
  packages: ['@kitr/react', '@kitr/router', '@kitr/web'],
  providers: [() => import('@kitr/web/provider')],
  platforms: {
    site: web({ mode: 'spa', pwa: { devEnabled: true } }),
  },
})

For a run closer to production, use kitr build site && kitr start site.

Pass options to vite-plugin-pwa

Anything kitr does not expose goes in pwa.plugin, merged last into the vite-plugin-pwa options:

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

export default defineKitrConfig({
  packages: ['@kitr/react', '@kitr/router', '@kitr/web'],
  providers: [() => import('@kitr/web/provider')],
  platforms: {
    site: web({
      mode: 'spa',
      pwa: {
        name: 'Acme Orders',
        plugin: { includeAssets: ['favicon.svg'], workbox: { maximumFileSizeToCacheInBytes: 5000000 } },
      },
    }),
  },
})

Use the PWA plugin in a custom Vite setup

The kitr() Vite plugin adds the PWA plugin when pwa is on. If you build without kitr(), add kitrPwa() from @kitr/web/vite yourself:

// vite.config.ts
import { defineConfig } from 'vite'
import { web } from '@kitr/web'
import { kitrPwa } from '@kitr/web/vite'

const site = web({ pwa: { name: 'Acme Orders' } })

export default defineConfig(async () => ({
  plugins: [await kitrPwa(site.options, 'Acme Orders')],
}))

kitrPwa() returns the vite-plugin-pwa plugins with the manifest, the /api/ denylist and the registerType behaviour, for the client build only.

Branch on the platform at build time

The kitr() Vite plugin defines two constants; use them to drop code from one platform's bundle:

// app/components/footer.tsx
export function Footer() {
  if (import.meta.env.KITR_PLATFORM === 'admin') return <footer>Acme admin</footer>
  return <footer>Acme · app.acme.com</footer>
}
Constant Value
import.meta.env.KITR_PLATFORM The platform name from kitrrc.ts, such as 'site' or 'admin'.
import.meta.env.KITR_PLATFORM_TYPE The platform type: 'web', 'mobile', 'desktop' or 'tauri'.

Detect the browser at run time

@kitr/web/provider registers the browser's platform driver: type web, the OS from the user agent, and standalone when installed. Read it through Platform:

// app/components/install-hint.tsx
import { usePlatform } from '@kitr/platform/react'

export function InstallHint() {
  const platform = usePlatform()
  if (platform.standalone) return null
  if (platform.is('ios')) return <p>Tap Share, then Add to Home Screen.</p>
  return null
}

During server rendering the OS is 'server'. Inside a native shell the Mobile, Desktop or Tauri driver wins.

Build for production

kitr build site runs vite build with KITR_PLATFORM=site and writes everything under dist/site:

Path Mode Contents
dist/site/client/ all JavaScript, CSS and assets.
dist/site/client/index.html spa The app shell.
dist/site/client/**/index.html static One HTML file per prerendered page.
dist/site/client/404.html static The root route's notFoundComponent, for paths no page matches.
dist/site/server/server.js ssr The TanStack Start server bundle.
dist/site/client/sw.js, manifest.webmanifest pwa The service worker and the manifest.

kitr build with no name builds every platform, one after another.

Serve the build

kitr start site serves the build with srvx on the platform's port, bound to 0.0.0.0:

kitr start site --port 8080
# ✔ site: serving dist/site on http://localhost:8080/
  • In spa mode it serves dist/site/client and answers unknown paths with the shell (index.html, _shell.html or 200.html) and status 200, so client routes survive a reload.
  • In static mode it serves dist/site/client and answers unknown paths with 404.html and status 404, as a static host does.
  • In ssr mode it serves static files first and hands every other request to the fetch export of dist/site/server/server.js. When crossws is installed, WebSocket upgrades work too.
  • Hashed files under assets/ are cached for a year (immutable); everything else gets no-cache, so a deploy reaches returning visitors on their next load.

On a host that sets PORT (Render, Fly.io, Railway), kitr start site uses it.

Deploy a static site or SPA to a CDN

A static or spa build needs no server. Upload dist/site/client to any static host:

kitr build site
npx wrangler pages deploy dist/site/client --project-name acme-orders

A static build writes 404.html from the root route's notFoundComponent. Netlify, Cloudflare Pages and GitHub Pages serve it with status 404 for unknown paths, with no setting. The page is never crawled and never in the sitemap:

// app/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import router from '@kitr/router/routes'
import { head, RootDocument } from './document'
import NotFound from './pages/not-found'

export const routes = defineRoutes([router], (r) =>
  r
    .root({ head, component: RootDocument, notFoundComponent: NotFound })
    .page('/', { lazy: () => import('./pages/home') }),
)

On your own nginx, point 404s at the file:

# /etc/nginx/conf.d/acme.conf
server {
  root /srv/acme/dist/site/client;
  error_page 404 /404.html;
  location / {
    try_files $uri $uri/index.html =404;
  }
}

Add Cache-Control: public, max-age=31536000, immutable on /assets/ to cache the hashed files for a year. sitemap.xml and robots.txt are already in dist/site/client when SEO is installed. Deploy a static site has the full config and a Dockerfile that serves it from nginx.

Commands

The web platform answers these commands:

Command What it does
kitr add web [name] Add a web platform, named site by default.
kitr css:types Type CSS Module class names. --check fails when stale.
kitr dev [names...] Runs vite dev --port <port> --strictPort, plus --host and --open when set.
kitr build [names...] Runs vite build into dist/<name>.
kitr start [names...] Serves dist/<name> with srvx: static files, the SPA fallback, or the ssr server bundle.
kitr config <name> [option] [value] Reads or sets an option in kitrrc.ts, validated first. --unset removes one.
kitr platforms Lists every platform with its options.
kitr platforms types Lists the platform types (web, desktop, tauri, mobile, server).
kitr platforms scaffold <name> Writes the platform's files that are missing (public/icons/README.md with pwa). Existing files are kept.
kitr platforms remove <name> Takes the platform out of kitrrc.ts.

kitr doctor checks each web platform and reports:

  • an error when vite is not installed. Fix: pnpm add -D vite.
  • an error when @tanstack/react-start is not installed.
  • an error when pwa is on but vite-plugin-pwa is not installed.
  • a warning when app/routes.ts is missing (no pages to render). Fix: kitr add router.
  • a warning when React is installed but kitrrc.ts lists no UI platform. Fix: kitr add web site.

Errors

Message When What to do
[kitr] Invalid options for web platform: mode: mode must be one of static, spa, ssr kitrrc.ts sets an unknown mode. Use static, spa or ssr.
[kitr] Invalid options for web platform: port: port must be a number between 0 and 65535 The port is out of range or a string. Pass a number.
web has no option "<flag>" (options: mode, port, host, pwa, base, open) kitr add web got an unknown flag. Use one of the listed options.
Platform name "<name>" must be a short identifier (site, api, app, phone...) The name has spaces or starts with a digit. Use letters, digits, - and _.
No platform named "<name>" in kitrrc.ts (have: site, api) kitr dev, build, start or config got an unknown name. Use a name from kitr platforms.
dist/site/client not found. Run kitr build site first. kitr start ran before a build. Run kitr build site.
dist/site/server/server.js not found. Run kitr build site first. ssr mode, but the last build was spa or static. Build again after changing the mode.
dist/site/server/server.js does not export a fetch handler The server bundle is not a TanStack Start build. Rebuild with the kitr() Vite plugin.
[@kitr/vite] KITR_PLATFORM=<name> is not a platform in kitrrc.ts (have: ...) Vite ran with a platform name that kitrrc.ts does not have. Run through kitr dev <name>, or fix the variable.
[@kitr/vite] site: pwa is on but vite-plugin-pwa could not load (...) A warning: the build continues without a service worker. pnpm add -D vite-plugin-pwa.
Port 3000 is already in use Another process has the port, and --strictPort refuses to move. Stop it or pass --port.

Testing

Platform options are plain values, so a test can load kitrrc.ts and catch a mode or port change nobody meant to make:

// test/platforms.test.ts
import { pwaManifest } from '@kitr/web'
import { describe, expect, it } from 'vitest'
import rc from '../kitrrc'

describe('the site platform', () => {
  it('is server-rendered and installable', () => {
    const site = rc.platforms!.site!
    expect(site.type).toBe('web')
    expect(site.options).toMatchObject({ mode: 'ssr', port: 3000 })
    expect(pwaManifest(site.options, 'Acme Orders')).toMatchObject({
      name: 'Acme Orders',
      display: 'standalone',
    })
  })
})

The hooks in @kitr/web/client read browser events, so a test fires them itself. This one renders the install button from above with a fake beforeinstallprompt event in happy-dom:

// test/install-button.test.tsx
import { act, fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { InstallButton } from '#app/components/install-button'

function installEvent(outcome: 'accepted' | 'dismissed') {
  return Object.assign(new Event('beforeinstallprompt', { cancelable: true }), {
    prompt: vi.fn(async () => {}),
    userChoice: Promise.resolve({ outcome }),
  })
}

describe('InstallButton', () => {
  it('stays hidden until the browser offers installation', () => {
    const { container } = render(<InstallButton />)
    expect(container.innerHTML).toBe('')
  })

  it('shows the browser dialog and confirms the install', async () => {
    render(<InstallButton />)
    const event = installEvent('accepted')
    act(() => void window.dispatchEvent(event))

    await act(async () => {
      fireEvent.click(screen.getByRole('button', { name: 'Install Acme Orders' }))
    })
    expect(event.prompt).toHaveBeenCalled()

    act(() => void window.dispatchEvent(new Event('appinstalled')))
    expect(screen.getByText('Acme Orders is on your home screen.')).toBeTruthy()
  })
})

useServiceWorker() does nothing without navigator.serviceWorker (as in happy-dom), so components render without a stub. Test the real worker against kitr build site && kitr start site in a browser.

Next steps