# 3D API reference Source: https://kitr.dev/packages/3d/api Every export of `@kitr/3d`, `@kitr/3d/drei`, read from the published type definitions. The [guide](/packages/3d/guide) shows them in use. ## @kitr/3d Re-exports `@react-three/fiber` unchanged, plus the following. ### isWebGLAvailable ```ts function isWebGLAvailable(): boolean; ``` Can this browser draw WebGL? False on the server, in old or locked-down browsers, on machines with blocklisted GPU drivers and in test DOMs. Checked once, then cached. ### resetWebGLCheck ```ts function resetWebGLCheck(): void; ``` Forget the cached answer of `isWebGLAvailable()`. For tests that stub `getContext`. ### Scene ```ts function Scene({ children, fallback, loading, frameloop, pauseWhenHidden, reducedMotion, dpr, adaptive, onError, className, style, ...canvas }: SceneProps): import("react").JSX.Element; ``` A React Three Fiber `` that behaves well on a real page: it renders only in the browser (nothing during SSR), shows `fallback` when WebGL is missing or the scene throws, stops drawing while off screen or in the background, keeps still for visitors who ask for less motion, and trades resolution for frame rate on slow devices. ### toHexColor ```ts function toHexColor(value: string): string; ``` Turn any CSS colour (`oklch(55% 0.2 262)`, `color-mix(...)`, a named colour) into the `#rrggbb` three.js understands, by letting the browser paint it. Returns the input when there is no canvas to paint on. ### useModel ```ts function useModel(url: string, options?: UseModelOptions): Model; ``` Load a `.glb` or `.gltf` model, suspending until it is ready: drei's `useGLTF` with the Draco decoder served by the app instead of a CDN. Cached by URL, so two components showing the same model download it once. ### usePrefersReducedMotion ```ts function usePrefersReducedMotion(): boolean; ``` Has the visitor asked the operating system for less motion? Updates when they change it. ### useThemeColor ```ts function useThemeColor(variable: `--${string}`, fallback?: string): string; ``` The value of a CSS colour variable, ready for a three.js material: `useThemeColor('--kitr-color-primary')`. Re-reads when the theme changes (a `data-theme`, `class` or `style` change on ``, or the system colour scheme), so a scene follows dark mode with the rest of the page. Returns `fallback` on the server and when the variable is not set. ### DEFAULT_DECODER_PATH ```ts const DEFAULT_DECODER_PATH = "/draco/"; ``` Where `kitr 3d:decoders` puts the Draco decoder, served by the app itself. ### Model ```ts interface Model extends ObjectMap { /** The default scene, ready for ``. */ scene: Group; /** Every scene in the file. */ scenes: Group[]; /** Animation clips, for drei's `useAnimations(model.animations, ref)`. */ animations: AnimationClip[]; /** Cameras defined in the file. */ cameras: Camera[]; /** The file's `asset` block: generator, version, copyright. */ asset: { generator?: string; version?: string; copyright?: string; }; /** Custom data the exporter stored on the file. */ userData: Record; } ``` A loaded glTF: the scene graph, its animations and cameras, plus its `nodes` and `materials` by name. ### SceneProps ```ts interface SceneProps extends Omit { children?: ReactNode; /** * Shown instead of the scene when it cannot run: no WebGL, or an error while loading or * drawing (a missing model, a lost GPU). An image of the scene is the usual choice. Default: nothing. */ fallback?: ReactNode; /** 3D content shown while models and textures load, such as drei's `` with a spinner. Default: nothing. */ loading?: ReactNode; /** `'always'` draws every frame (default); `'demand'` draws only when something changes. */ frameloop?: 'always' | 'demand'; /** Stop drawing while the scene is scrolled out of view or the tab or app is in the background. Default `true`. */ pauseWhenHidden?: boolean; /** * With `'user'` (the default), visitors who ask for less motion get a still scene: it draws on * demand, so `useFrame` animations stop while controls and state changes still redraw it. */ reducedMotion?: SceneReducedMotion; /** Pixel ratio, or the `[min, max]` range it stays within. Default `[1, 2]`. */ dpr?: number | [ number, number ]; /** Lower the pixel ratio when frames drop and raise it again when they recover. Default `true`. */ adaptive?: boolean; /** Called when the scene fails and the fallback is shown. */ onError?: (error: Error) => void; /** Class of the element around the canvas. The canvas fills it, so size the scene here. */ className?: string; /** Style of the element around the canvas. */ style?: CSSProperties; } ``` Props of ``: React Three Fiber's `` props, plus how the scene behaves around the page. ### UseModelOptions ```ts interface UseModelOptions { /** * Where the Draco decoder files live, or `false` for models that are not Draco-compressed. * Default `'/draco/'`, the folder `kitr 3d:decoders` fills, so compressed models load offline, * in desktop and mobile builds and under a strict CSP, without Google's CDN. */ draco?: string | false; /** Decode Meshopt-compressed models. Default `true`; the decoder is bundled, so it costs no request. */ meshopt?: boolean; } ``` Options of `useModel()`. ### SceneReducedMotion ```ts type SceneReducedMotion = 'user' | 'always' | 'never'; ``` How a scene treats the visitor's "reduce motion" setting. ## @kitr/3d/drei Re-exports `@react-three/drei`, `camera-controls` unchanged, plus the following. # 3D Guide Source: https://kitr.dev/packages/3d/guide Product viewers, configurators and hero scenes should behave like the rest of the page. `@kitr/3d` renders nothing during server rendering, shows a fallback without WebGL, stops drawing off screen, holds still for reduced motion and lowers the resolution when a phone slows down. > [!SUMMARY] > Render 3D scenes and models that stay smooth on phones and fall back when WebGL is missing. Add it with `kitr add 3d`; the overview is at [/packages/3d](/packages/3d). It is [React Three Fiber](https://r3f.docs.pmnd.rs) and [drei](https://drei.docs.pmnd.rs), re-exported unchanged, on top of [three.js](https://threejs.org). kitr adds four things: - ``, a `` with the behaviour above. - `useModel()`, which loads compressed models with a decoder your app serves itself. - `useThemeColor()`, which hands three.js the colours of your theme. - `kitr 3d:decoders`, with a `kitr doctor` check, to keep that decoder in place. It runs in the browser, inside React, on every kitr platform. For ordinary DOM movement, use [animation](/packages/animation). The examples build the order page of Acme's store at `app.acme.com/orders/1042`: Grace ordered the Acme headset and turns it around in 3D before it ships. ## Install ```sh kitr add 3d ```
Everything kitr add changes What `kitr add` does: - Adds `@kitr/3d`, `three`, `@react-three/fiber` and `@react-three/drei` to `dependencies`, and `@types/three` to `devDependencies`, then installs them. Pass `--no-install` to skip the install. - Adds `'@kitr/3d'` to `packages` in `kitrrc.ts`. It registers no provider. - Adds a `## kitr: 3d` section to `AGENTS.md`: wrap every scene in `` rather than a bare ``, import from `@kitr/3d` and `@kitr/3d/drei`, keep models in `public/models/` and load them with `useModel()`, take colours from the theme with `useThemeColor()`, and run `kitr 3d:decoders` for Draco-compressed models. It writes no config file and asks no questions. It adds the [react](/packages/react) package first when the project has none, and refuses in a server-only project. `kitr remove 3d` takes `'@kitr/3d'` out of `kitrrc.ts`, removes the four dependencies and the `AGENTS.md` section, then lists files that still import `@kitr/3d`. It leaves `public/draco/` and your models in place.
`kitr doctor` flags imports from `@react-three/fiber` and `@react-three/drei`; use `@kitr/3d` and `@kitr/3d/drei`. Import `three` itself directly: `import { Color } from 'three'`. ## Configure `@kitr/3d` has no config file. Each scene is configured by the props of ``: ```tsx // app/components/headset-viewer.tsx import { Scene } from '@kitr/3d' export function HeadsetViewer() { return ( } frameloop="always" pauseWhenHidden reducedMotion="user" dpr={[1, 2]} adaptive > {/* ... */} ) } ``` | Option | Default | Description | | --- | --- | --- | | `fallback` | nothing | Shown instead when there is no WebGL or the scene fails, such as an image of the scene | | `loading` | nothing | 3D content shown while models and textures load, such as drei's `` with a message | | `frameloop` | `'always'` | `'always'` draws every frame; `'demand'` draws only after something changes | | `pauseWhenHidden` | `true` | Stop drawing while the scene is scrolled out of view, or the tab or app is in the background | | `reducedMotion` | `'user'` | `'user'` draws on demand for visitors who ask for less motion, which stops `useFrame` animations. `'always'` does that for everyone, `'never'` for nobody | | `dpr` | `[1, 2]` | The pixel ratio, or the `[min, max]` range it stays within | | `adaptive` | `true` | Step the pixel ratio down within `dpr` when frames drop, and back up when they recover | | `onError` | logs to the console | Called with the error when the scene fails and `fallback` is shown | | `className`, `style` | `width: 100%`, `height: 100%` | Applied to the element around the canvas. The canvas fills it, so size the scene here | Every other prop goes to React Three Fiber's [``](https://r3f.docs.pmnd.rs/api/canvas): `camera`, `shadows`, `gl`, `scene`, `orthographic`, `flat`, `linear`, `events` and `onCreated`. ## Show your first scene A turning cube in the brand colour, lit from above. Give the scene a height, because a canvas fills its parent and a parent with no height shows nothing: ```tsx // app/components/brand-cube.tsx import { Scene, useFrame, useThemeColor } from '@kitr/3d' import { useRef } from 'react' import type { Mesh } from 'three' function Cube() { const ref = useRef(null) const primary = useThemeColor('--kitr-color-primary') useFrame((_, delta) => { if (ref.current) ref.current.rotation.y += delta * 0.6 }) return ( ) } export function BrandCube() { return ( }> ) } ``` The server sends an empty 320-pixel box. In the browser it becomes a canvas with a cube turning in the theme's primary colour, or `/images/cube.png` where WebGL is missing. Scroll it out of view and `
` around the canvas reads `never`; scroll back and it reads `always`. ## Load a model Put `.glb` files in `public/models/` and load them with `useModel()`. It suspends while the file downloads, so the scene shows `loading` until it is ready: ```tsx // app/components/headset-viewer.tsx import { Scene, useModel } from '@kitr/3d' import { Center, Html, OrbitControls } from '@kitr/3d/drei' function Headset() { const model = useModel('/models/headset.glb') return (
) } export function HeadsetViewer() { return ( } loading={Loading the headset} > ) } ``` Grace can drag to turn the headset and pinch or scroll to zoom. `useModel()` caches by URL, so two viewers download the file once. `model.nodes` and `model.materials` hold every named mesh and material. If the file is missing or broken, `` shows `fallback` and passes the error to `onError`; the rest of the page keeps working. To download early, for example when Grace hovers the link to her order, call `useModel.preload('/models/headset.glb')`. ## Load compressed models offline Draco-compressed models, such as the output of `gltfjsx --transform`, need a decoder at run time. drei fetches it from a CDN, which fails offline, under a strict Content Security Policy and on networks that block the CDN. `useModel()` loads it from your app at `/draco/` instead. Copy it there once: ```sh kitr 3d:decoders ``` That copies the three decoder files from the installed `three` into `public/draco/`. Commit them, and run the command again after upgrading `three`. `kitr doctor` reads every `.glb` and `.gltf` under `public/` and warns when one uses Draco and `public/draco/` has no decoder: ```text ⚠ public/models/headset.glb is Draco-compressed but public/draco has no decoder, so useModel() will fail to load it fix: kitr 3d:decoders ```
Other decoder locations and Meshopt To keep the decoder somewhere else, pass its folder: `useModel(url, { draco: '/static/draco/' })`. For a model with no Draco compression, skip the decoder with `{ draco: false }`. Meshopt compression needs no files; `useModel()` enables it by default.
## Show a fallback without WebGL Older browsers, locked-down machines, blocklisted GPUs and headless test browsers cannot draw WebGL. `` checks once, after mounting, and shows `fallback` instead of a broken canvas: ```tsx // app/components/order-preview.tsx import { Scene } from '@kitr/3d' export function OrderPreview({ order }: { order: { id: string; image: string } }) { return ( } onError={(error) => console.warn(`Order ${order.id} preview failed`, error)} > {/* ... */} ) } ``` The same `fallback` shows when the scene throws. To branch on WebGL outside a scene, for example to hide a "View in 3D" button, call `isWebGLAvailable()` in an effect or event handler; it returns `false` on the server. ## Use theme colours kitr's theme defines colours in `oklch()`, which three.js cannot parse. `useThemeColor()` reads a CSS variable, lets the browser convert it to `#rrggbb`, and reads it again when the theme changes: ```tsx // app/components/headset-stand.tsx import { useThemeColor } from '@kitr/3d' export function HeadsetStand() { const surface = useThemeColor('--kitr-color-muted') const accent = useThemeColor('--kitr-color-primary', '#2563eb') return ( ) } ``` When Grace switches to dark mode, the stand recolours without reloading the scene. The second argument is the colour for the server and for an unset variable. Any CSS variable works, including your own. To convert once, outside React, call `toHexColor('oklch(55% 0.2 262)')`. ## Animate with useFrame `useFrame` runs on every frame with the time since the last one. Scale by `delta` so speed is the same at any frame rate: ```tsx // app/components/floating-headset.tsx import { useFrame, useModel } from '@kitr/3d' import { useRef } from 'react' import type { Group } from 'three' export function FloatingHeadset() { const model = useModel('/models/headset.glb') const ref = useRef(null) useFrame(({ clock }, delta) => { if (!ref.current) return ref.current.rotation.y += delta * 0.4 ref.current.position.y = Math.sin(clock.elapsedTime) * 0.05 }) return } ``` > [!WARNING] > Change objects through the ref. Calling `setState` inside `useFrame` re-renders the React tree 60 times a second. For animations stored in the model file, use drei's `useAnimations(model.animations, ref)`, then `actions['Unfold']?.play()`. ## Respect reduced motion With `reducedMotion="user"`, visitors who ask for less motion get a still scene. `` switches to `'demand'`, so `useFrame` animations stop while dragging and state changes still redraw. Your scene does not check the setting. When movement is the point, such as a product turn on a button press, keep it gentle with `usePrefersReducedMotion()`: ```tsx // app/components/turn-button.tsx import { usePrefersReducedMotion } from '@kitr/3d' export function TurnButton({ onTurn }: { onTurn: (speed: number) => void }) { const reduce = usePrefersReducedMotion() return ( ) } ``` Use `reducedMotion="never"` only where motion carries meaning, such as a live chart in 3D. ## Draw only when something changes A scene that only moves when dragged doesn't need 60 frames a second. `frameloop="demand"` draws once, then only when something changes, which saves battery: ```tsx // app/components/still-headset.tsx import { Scene, useModel } from '@kitr/3d' import { OrbitControls } from '@kitr/3d/drei' function Headset() { const model = useModel('/models/headset.glb') return } export function StillHeadset() { return ( ) } ``` When you change three.js objects yourself, outside React, ask for a frame with `invalidate()` from `useThree()`. ## Keep it smooth on phones `` caps the pixel ratio at 2. With `adaptive`, it steps the ratio down towards 1 when frames drop and back up when they recover, so the picture softens instead of stuttering. Set the range with `dpr`. A hero scene that favours frame rate: ```tsx {/* ... */} ``` A fixed `dpr={1}` turns adaptation off. Keep models light: compress with `npx gltfjsx model.glb --transform`, and prefer one baked lighting texture over many real-time lights. When Grace switches apps, the scene stops drawing and resumes when she returns. ## Handle clicks and hovers Meshes take pointer events like DOM elements. Grace picks the headset colour by clicking a swatch on the model: ```tsx // app/components/colour-swatch.tsx import { useState } from 'react' export function ColourSwatch({ colour, onPick }: { colour: string; onPick: (colour: string) => void }) { const [hovered, setHovered] = useState(false) return ( { event.stopPropagation() onPick(colour) }} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)} > ) } ``` `event.stopPropagation()` stops the click reaching meshes behind. Touch works the same way. Put page text and buttons in plain DOM next to ``, so screen readers and keyboard users reach them. ## Lighting without a network drei's `` downloads its lighting file from a CDN. For offline or strict-CSP builds, put the `.hdr` file in `public/` and point to it: ```tsx // app/components/studio-lighting.tsx import { ContactShadows, Environment } from '@kitr/3d/drei' export function StudioLighting() { return ( <> ) } ``` ## Commands | Command | What it does | | --- | --- | | `kitr 3d:decoders` | Copies the Draco decoder from the installed `three` into `public/draco/`. `--out ` copies it elsewhere | ```sh kitr 3d:decoders --out public/static/draco ``` ```text wrote public/static/draco/draco_decoder.js wrote public/static/draco/draco_decoder.wasm wrote public/static/draco/draco_wasm_wrapper.js Draco decoder copied. useModel() loads it from /draco/. ``` With a custom folder, pass it to `useModel(url, { draco: '/static/draco/' })`. ## Testing [`@react-three/test-renderer`](https://r3f.docs.pmnd.rs/api/testing) renders React Three Fiber without a GPU and steps frames by hand. Add it once: ```sh pnpm add -D @react-three/test-renderer ``` Then render a component, advance a few frames and assert on the three.js objects: ```tsx // app/components/brand-cube.test.tsx import ReactThreeTestRenderer from '@react-three/test-renderer' import { useFrame } from '@kitr/3d' import { useRef } from 'react' import type { Mesh } from 'three' import { describe, expect, it } from 'vitest' function Cube() { const ref = useRef(null) useFrame((_, delta) => { if (ref.current) ref.current.rotation.y += delta }) return ( ) } describe('brand cube', () => { it('turns one radian a second', async () => { const renderer = await ReactThreeTestRenderer.create() const cube = renderer.scene.children[0]! await ReactThreeTestRenderer.act(async () => renderer.advanceFrames(2, 0.5)) expect(cube.instance.rotation.y).toBeCloseTo(1) expect(cube.allChildren.map((child) => child.type)).toEqual(['BoxGeometry', 'MeshStandardMaterial']) await renderer.unmount() }) }) ``` `` needs a real browser to draw. In Vitest with happy-dom there is no WebGL, so a page with a scene renders its `fallback`, which you can assert on: ```tsx // app/pages/order.test.tsx import { render, screen } from '@testing-library/react' import { expect, it } from 'vitest' import { HeadsetViewer } from '#app/components/headset-viewer' it('shows the photo where WebGL is missing', async () => { render() expect(await screen.findByAltText('The Acme headset')).toBeTruthy() }) ``` In Playwright against `kitr dev`, wait for `[data-kitr-scene="always"]` before a screenshot, and use `frameloop="demand"` so frames stop changing. ## Next steps - [API reference](/packages/3d/api): every export of `@kitr/3d`. - [drei](https://drei.docs.pmnd.rs): controls, loaders and helpers, imported from `@kitr/3d/drei`. - [Theme](/packages/theme): the colour tokens `useThemeColor()` reads. # 3D Source: https://kitr.dev/packages/3d ## What it is `@kitr/3d` renders 3D scenes in React. Put a `` on the page, add meshes and lights, load models with `useModel()`. It draws only when something changes, pauses off screen, and goes still for people who ask for less motion. Runs in the browser on every kitr platform. Renders nothing on the server, so pages still prerender. ## Install ```sh kitr add 3d ``` Adds `@kitr/3d` with three.js, React Three Fiber and drei. No provider, no config file. [Every change](/packages/3d/guide#install). ## Quick start A turning cube in the brand colour. Give the scene a height: ```tsx // app/components/brand-cube.tsx import { Scene, useFrame, useThemeColor } from '@kitr/3d' import { useRef } from 'react' import type { Mesh } from 'three' function Cube() { const ref = useRef(null) const primary = useThemeColor('--kitr-color-primary') useFrame((_, delta) => { if (ref.current) ref.current.rotation.y += delta * 0.6 }) return ( ) } export function BrandCube() { return ( }> ) } ``` The cube turns in the theme's primary colour. Without WebGL the fallback shows instead. Off screen, drawing stops. Next in the guide: [Show your first scene](/packages/3d/guide#show-your-first-scene). ## Built on | Library | What it gives you | What kitr adds | | --- | --- | --- | | [three.js](https://threejs.org) | Geometry, materials, lights, cameras and the WebGL renderer. | Nothing: it is the engine underneath. | | [React Three Fiber](https://r3f.docs.pmnd.rs) | A React renderer for three.js: ``, `useFrame`, `useThree`, events. | ``, a `` with kitr's defaults for SSR, visibility, reduced motion and performance. | | [drei](https://drei.docs.pmnd.rs) | Controls, loaders, staging and dozens of helpers. | `useModel()`, which loads compressed models with a decoder your app serves itself, and `useThemeColor()`, which hands three.js the colors of your [theme](/packages/theme). | `kitr 3d:decoders` keeps the Draco and KTX2 decoders in `public/`, so models load with no CDN. ## Problems it solves - **Scenes that break server rendering.** [Show your first scene](/packages/3d/guide#show-your-first-scene). - **Devices without WebGL.** [Show a fallback without WebGL](/packages/3d/guide#show-a-fallback-without-webgl). - **Battery drain from hidden scenes.** [Draw only when something changes](/packages/3d/guide#draw-only-when-something-changes). - **Dropped frames on phones.** [Keep it smooth on phones](/packages/3d/guide#keep-it-smooth-on-phones). - **Motion that some visitors cannot tolerate.** [animation](/packages/animation). - **Models that depend on a third-party CDN.** [Load compressed models offline](/packages/3d/guide#load-compressed-models-offline). - **Colors that drift from the design system.** [Use theme colours](/packages/3d/guide#use-theme-colours). For movement of ordinary DOM elements, such as a dialog opening or a list re-sorting, use [animation](/packages/animation) instead; three.js is for scenes with a camera. ## Further reading - [Guide](/packages/3d/guide). - [API reference](/packages/3d/api). - [React Three Fiber docs](https://r3f.docs.pmnd.rs). - [drei docs](https://drei.docs.pmnd.rs). - [three.js manual](https://threejs.org/manual/). - [Animation](/packages/animation) and [Theme](/packages/theme). # Server actions API reference Source: https://kitr.dev/packages/actions/api Every export of `@kitr/actions`, `@kitr/actions/types`, read from the published type definitions. The [guide](/packages/actions/guide) shows them in use. ## @kitr/actions Re-exports `@tanstack/start-client-core` unchanged, plus the following. ### defineMutation ```ts function defineMutation = [ ], TContext = MiddlewareContext>(name: string, options: DefineMutationOptions): ServerMutation, TOutput, TContext>; ``` Define a mutation whose handler runs on the server, with the cache effects it has on success. ```ts export const updateProject = defineMutation('projects.update', { input: z.object({ id: z.string(), name: z.string().min(2) }), effects: (project) => ({ set: [{ key: projectKeys.detail(project.id), data: project }], invalidate: [projectKeys.lists()] }), handler: ({ input }) => db.projects.update(input), }) ``` ### defineQuery ```ts function defineQuery = [ ], TContext = MiddlewareContext>(name: string, options: DefineQueryOptions): ServerQuery, TOutput, TContext>; ``` Define a query whose handler runs on the server. The name is the registry key `kitr types` writes to `.kitr/actions.ts`, so `useQuery('projects.list', input)` works with these types. ```ts export const listProjects = defineQuery('projects.list', { input: z.object({ status: z.enum(['open', 'closed']).optional() }), handler: ({ input }) => db.projects.findMany({ where: input }), }) ``` ### requireSession ```ts const requireSession: import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, undefined, undefined, undefined, undefined>; ``` Shortcut: a function middleware that rejects anonymous callers. Expects `context.session` from an earlier middleware. ### CallOptions ```ts interface CallOptions { signal?: AbortSignal; /** `.run()` only: the middleware context the handler would see. */ context?: Partial; } ``` Options for a direct call or `.run()`. ### DefineMutationOptions ```ts interface DefineMutationOptions = ReadonlyArray> extends DefineOperationOptions { /** Cache effects applied on the client after success. A function gets the result and the input. */ effects?: CacheEffect | ((result: TOutput, input: InputOf) => CacheEffect); } ``` The second argument of `defineMutation`: the shared options plus cache `effects`. ### DefineOperationOptions ```ts interface DefineOperationOptions = ReadonlyArray> { /** Any Standard Schema. Validated on the server; a failure reaches the caller as a VALIDATION error with field errors. */ input?: S; /** * TanStack Start function middleware (`createMiddleware({ type: 'function' })`). What each one * passes to `next({ context })` is typed on the handler's `context`. An error thrown here reaches * the caller the same way a handler error does, masked in production when it is a server error. */ middleware?: M; /** Runs on the server only. Its code, and anything it imports, never reaches the browser bundle. */ handler: (ctx: HandlerContext, TContext>) => TOutput | Promise; } ``` Options shared by `defineQuery` and `defineMutation`. ### DefineQueryOptions ```ts interface DefineQueryOptions = ReadonlyArray> extends DefineOperationOptions { /** How long a result counts as fresh, in milliseconds. Carried into the query options. */ staleTime?: number; } ``` The second argument of `defineQuery`: the shared options plus `staleTime`. ### HandlerContext ```ts interface HandlerContext { input: TInput; /** Context from Start middleware (request, session...). */ context: TContext; signal: AbortSignal; } ``` What a handler receives: the validated input, Start middleware context, and the request's abort signal. ### ServerMutation ```ts interface ServerMutation extends MutationDefinition { (input: TInput, options?: { signal?: AbortSignal; }): Promise; readonly name: string; /** Validation and the handler in-process, for tests. Skips Start middleware. */ run(input: TInput, options?: CallOptions): Promise; readonly serverFn: ServerFn; } ``` A server mutation: a `MutationDefinition` from `@kitr/query` that is also a function. Call it to mutate, pass it to `useMutation` for pending state and cache effects, or use it by name. ### ServerQuery ```ts interface ServerQuery extends QueryDefinition { (input: TInput, options?: { signal?: AbortSignal; }): Promise; readonly name: string; /** Validation and the handler in-process, for tests. Skips Start middleware. */ run(input: TInput, options?: CallOptions): Promise; readonly serverFn: ServerFn; } ``` A server query: a `QueryDefinition` from `@kitr/query` that is also a function. Call it to fetch, pass `.options(input)` to any TanStack hook or loader, or use it by name once `kitr types` has registered it. ### MiddlewareContext ```ts type MiddlewareContext> = [ M[number] ] extends [ never ] ? {} : UnionToIntersection> & {}; ``` The context a list of Start function middleware gives the handler: every middleware's `next({ context })`, merged. ### ServerFn ```ts type ServerFn = (opts: { data: unknown; signal?: AbortSignal; }) => Promise>; ``` The raw TanStack server function behind a definition, for `useServerFn` or middleware composition. ## @kitr/actions/types ### renderActions ```ts function renderActions(found: FoundOperation[], ctx: Pick): string; ``` The generated module for a set of operations. Exported for tests and for `kitr doctor`. ### scanActions ```ts function scanActions(sources: Array<{ file: string; source: string; }>, options?: ScanOptions): Promise; ``` Every exported `defineQuery` / `defineMutation` call in the given sources, found with the TypeScript compiler: the callee is resolved to its declaration, so an import alias (`import { defineQuery as query }`), a namespace import (`k.defineQuery`) and a re-export (`export { listProjects } from '../shared/projects'`) all count, and a local function that merely shares the name does not. A name that is not a string literal is an error with `file:line`. ### FoundOperation ```ts interface FoundOperation { /** Project-relative file. */ file: string; /** The exported identifier. */ exportName: string; /** The dotted name passed to `defineQuery` or `defineMutation`. */ name: string; kind: 'query' | 'mutation'; } ``` The `actions` generator for `kitr types`. It reads every file under `app/actions` (`directories.actions`), finds the exported `defineQuery` and `defineMutation` calls and writes `.kitr/actions.ts`: the `operations` map `app/app.ts` passes to `createApp`, the same names as a nested `actions` tree, and the `Operations` augmentation that types `useQuery('projects.list')`. ### ScanOptions ```ts interface ScanOptions { /** The project root: where `tsconfig.json` is read from and module specifiers resolve. Default `/`. */ root?: string; /** In-memory files by project-relative path (modules the actions import), seen before the file system. For tests. */ files?: Record; } ``` # Server actions Guide Source: https://kitr.dev/packages/actions/guide A button often just needs to run a bit of server code, like renaming a project. An action is a function you define once with `defineQuery` or `defineMutation`. The browser calls it like a local function; kitr sends the request, checks the input and returns the result or a typed error. > [!SUMMARY] > Call server code straight from a component, like a form submit, without writing an API route. Add it with `kitr add actions`; the overview is at [/packages/actions](/packages/actions). Actions are [TanStack Start server functions](https://tanstack.com/start/latest/docs/framework/react/server-functions) plus: - Input validation with any Standard Schema. - One error shape, so a validation failure lands on the right form field. - Cache effects after a mutation. - A generated registry, so `useQuery('projects.list')` is fully typed. Use actions when the only caller is your own React app. When a mobile app, partner or webhook calls the code too, use [API routes](/packages/api): they give you URLs, OpenAPI and a typed client. ## Install ```sh kitr add actions ```
Everything kitr add changes What `kitr add` does: - Adds [server](/packages/server) and [query](/packages/query) first when missing; actions require both. - Installs `@kitr/actions` and adds it to `packages` in `kitrrc.ts`. Actions register no provider. - Adds `#kitr/*` to `paths` and `.kitr/**/*` to `include` in `tsconfig.json`, and `.kitr` to `.gitignore`. Projects from `kitr new` already have both. - Points the `typecheck` script at `kitr typecheck`, which generates the names and then runs `tsc`, and adds `#kitr/*` to the `imports` field of `package.json` when it has one. - Creates an empty `app/actions/` folder (or `directories.actions` from `kitrrc.ts`). - Edits `app/app.ts`: `import { operations } from '#kitr/actions'` and `createApp({ operations, ... })`. - Runs `kitr types` once dependencies are installed, which writes `.kitr/actions.ts`. - Appends an `actions` section to `AGENTS.md`. `kitr add actions` asks no questions. `kitr remove actions` takes `operations` out of `app/app.ts` and removes the `AGENTS.md` section, the `packages` entry and the dependency. It leaves the `tsconfig.json`, `.gitignore`, `imports` and `typecheck` changes, which other type generators use, and your files in `app/actions/`. The `fullstack` and `saas` kits include actions.
## Configure This package has no config file. Everything is declared on each definition: ```ts // app/actions/projects/update.ts import { defineMutation } from '@kitr/actions' import { z } from 'zod' export const updateProject = defineMutation('projects.update', { input: z.object({ id: z.string(), name: z.string().min(2) }), middleware: [], effects: (project) => ({ invalidate: [['projects']] }), handler: async ({ input }) => ({ id: input.id, name: input.name }), }) ``` | Option | Default | Description | | --- | --- | --- | | `input` | none | A [Standard Schema](https://standardschema.dev) such as zod, checked on the server. A failure is a `VALIDATION` error with `fieldErrors`. | | `handler` | required | Runs on the server only. Receives `{ input, context, signal }`. Its code and imports never reach the browser bundle. | | `middleware` | `[]` | TanStack Start function middleware, run before the handler. Whatever it puts in `context` is typed on the handler. | | `staleTime` | the query client's | Queries only: how long a result counts as fresh, in milliseconds, carried into the query options. | | `effects` | none | Mutations only: cache effects applied on the client after success. An object, or a function of `(result, input)`. | The names you pass, `projects.update` above, go into one generated file: | File | Written by | Holds | | --- | --- | --- | | `.kitr/actions.ts` | `kitr types` | `operations`, a flat map by name; `actions`, the same as a tree; and the `Operations` augmentation that types every name. | | `.kitr/platforms.d.ts` | `kitr types` | The platform names from `kitrrc.ts`, so `app.platform` is `'site' \| 'api'`. | `kitr dev` and `kitr build` run `kitr types` first, and the Vite plugin reruns it when a file under `app/actions/` changes. The file is generated and ignored by git. Set `directories.actions` in `kitrrc.ts` to keep actions elsewhere. ## Your first mutation Generate a file: ```sh kitr make:mutation projects/rename ``` Then give it a schema and a handler: ```ts // app/actions/projects/rename.ts import { defineMutation } from '@kitr/actions' import { useDb } from '@kitr/db' import { useRequestApp } from '@kitr/server' import { z } from 'zod' import { app } from '#app/app' import type { AppDb } from '#config/db' export const renameProject = defineMutation('projects.rename', { input: z.object({ id: z.string(), name: z.string().min(2) }), effects: (project) => ({ set: [{ key: ['projects', 'detail', project.id], data: project }], invalidate: [['projects', 'list']], }), handler: async ({ input }) => { const db = useDb(useRequestApp(app)) return db.projects.update(input.id, { name: input.name }) }, }) ``` Use it in a component by name: ```tsx // app/components/rename-button.tsx import { useMutation } from '@kitr/query' export function RenameButton({ id }: { id: string }) { const rename = useMutation('projects.rename') return ( ) } ``` Clicking the button sends one request that validates the input, runs the handler and returns the project. The `set` effect writes it into the detail cache and `invalidate` refetches every project list. `rename.mutate({ id })` and `useMutation('projects.rnaem')` are type errors. `useRequestApp(app)` returns the app for the current request, or `app` when there is none. ## Read data with a query A query is an action that only reads. It is sent as a GET and cached like any other query: ```ts // app/actions/projects/list.ts import { defineQuery } from '@kitr/actions' import { useDb } from '@kitr/db' import { useRequestApp } from '@kitr/server' import { z } from 'zod' import { app } from '#app/app' import type { AppDb } from '#config/db' export const listProjects = defineQuery('projects.list', { input: z.object({ status: z.enum(['open', 'archived']).optional() }), staleTime: 30_000, handler: ({ input }) => useDb(useRequestApp(app)).projects.findMany({ where: { status: input.status } }), }) ``` In a component: ```tsx // app/components/project-list.tsx import { useSuspenseQuery } from '@kitr/query' export function ProjectList() { const { data: projects } = useSuspenseQuery('projects.list', { status: 'open' }) return
    {projects.map((p) =>
  • {p.name}
  • )}
} ``` `useQuery`, `useSuspenseQuery` and `queryOptions` take a name first and the input second; leave the input out when every field is optional. The query key is `['action', 'projects.list', input]`, so `['action', 'projects.list']` invalidates every variant. ## Load before the page renders In a route loader, `queryOptions` hands the router the options the component uses, so the server fetches once and the browser reuses the result: ```ts // app/routes.ts import { queryOptions } from '@kitr/query' r.page('/projects/', { loader: ({ context }) => context.queryClient.ensureQueryData( queryOptions(context.app, 'projects.list', { status: 'open' }), ), lazy: () => import('./pages/projects/index'), }) ``` ```ts // app/pages/projects/index.tsx import { ProjectList } from '#components/project-list' export default ProjectList ``` ## Call an action outside a component Every definition is a function. Import it and call it from a loader, a store, an event handler or a job: ```ts import { renameProject } from '#app/actions/projects/rename' const project = await renameProject({ id: 'prj_42', name: 'Acme site' }) // => { id: 'prj_42', name: 'Acme site', status: 'open' } ``` In the browser this is one request; on the server the handler runs in-process. A direct call applies no cache effects; use `useMutation` in components for that. When all you have is the name, `call` from `@kitr/query` looks it up in `app.operations`: ```ts import { call } from '@kitr/query' await call(app, 'projects.rename', { id: 'prj_42', name: 'Acme site' }) const open = await call(app, 'projects.list', { status: 'open' }) ``` The generated tree groups the same functions by feature: ```ts import { actions } from '#kitr/actions' await actions.projects.rename({ id: 'prj_42', name: 'Acme site' }) ``` ## Use the definition instead of the name When you prefer an import to a string, every hook takes the definition too, with the same types: ```tsx import { useMutation, useSuspenseQuery } from '@kitr/query' import { listProjects } from '#app/actions/projects/list' import { renameProject } from '#app/actions/projects/rename' const { data } = useSuspenseQuery(listProjects, { status: 'open' }) const rename = useMutation(renameProject) ``` Plain TanStack options also work: `useQuery({ queryKey, queryFn })` passes straight through. ## Pass your own query and mutation options The third argument of a query hook, and the second of `useMutation`, are TanStack overrides. Everything but `queryKey`, `queryFn` and `mutationFn` can be replaced: ```tsx const { data } = useQuery('projects.list', { status: 'open' }, { refetchInterval: 10_000 }) const rename = useMutation('projects.rename', { onSuccess: (project) => toast(`Renamed to ${project.name}`), }) ``` The mutation's cache effects run first, then your `onSuccess`. ## Protect an action with middleware Middleware runs before the handler and can add to `context`. A session middleware puts the session there; `requireSession` then rejects anonymous callers with `UNAUTHORIZED`: ```ts // app/actions/middleware/session.ts import { getRequest } from '@tanstack/react-start/server' import { createMiddleware } from '@kitr/actions' import { sessionFromRequest } from '#server/session' export const sessionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { // Your own lookup: a cookie, a bearer token, an auth adapter. It returns { user } or null. const session = await sessionFromRequest(getRequest()) return next({ context: { session } }) }) ``` ```ts // app/actions/projects/archive.ts import { defineMutation, requireSession } from '@kitr/actions' import { z } from 'zod' import { sessionMiddleware } from '#app/actions/middleware/session' export const archiveProject = defineMutation('projects.archive', { middleware: [sessionMiddleware, requireSession], input: z.object({ id: z.string() }), handler: async ({ input, context }) => { // context.session.user is typed here return { id: input.id, archivedBy: context.session.user.id } }, }) ``` The handler's `context` type is built from the middleware list. An error thrown in middleware reaches the caller as a `KitrError`, like a handler's. `redirect()` and `notFound()` pass through to the router. Any middleware can wrap the call: ```ts // app/actions/middleware/timing.ts import { createMiddleware } from '@kitr/actions' export const timing = createMiddleware({ type: 'function' }).server(async ({ next }) => { const started = Date.now() const result = await next() console.log(`took ${Date.now() - started}ms`) return result }) ``` ## Throw an error the caller understands Throw a `KitrError` and the caller receives the same code, status, message and field errors: ```ts import { KitrError } from '@kitr/kitr/app' handler: async ({ input }) => { const project = await db.projects.find(input.id) if (!project) throw KitrError.notFound('Project not found') if (project.ownerId !== input.userId) throw KitrError.forbidden() if (await db.projects.exists({ name: input.name })) throw KitrError.validation({ name: ['A project with this name exists'] }) return project } ``` On the client: ```ts try { await renameProject({ id: 'prj_42', name: 'Acme site' }) } catch (error) { if (error instanceof KitrError && error.code === 'VALIDATION') error.fieldErrors // => { name: ['A project with this name exists'] } } ``` With [forms](/packages/forms), pass `rename.mutateAsync` to `
` and each message appears under its field. > [!WARNING] > Anything that is not a `KitrError` becomes a 500. The real error is logged on the server; production browsers see only `Internal server error`. ## Organize actions by feature One file per action under `app/actions//`. The dotted name only needs to be unique; `kitr types` groups the tree by the part before the first dot: ``` app/actions/ ├── projects/ │ ├── list.ts projects.list │ ├── rename.ts projects.rename │ └── archive.ts projects.archive └── invoices/ └── send.ts invoices.send ``` > [!WARNING] > Only `export const = defineQuery('...')` and `defineMutation('...')` with a literal name are picked up. `kitr types` fails when a name is used twice, is not a dotted identifier, or is both an operation and a group (`projects` and `projects.list`). Any file under `app/actions/` counts at any depth, except tests and `.d.ts` files. ## Commands | Command | What it does | | --- | --- | | `kitr make:query /` | Writes `app/actions//.ts` with `defineQuery('.', ...)`. | | `kitr make:mutation /` | Writes `app/actions//.ts` with `defineMutation('.', ...)`. | | `kitr types` | Runs every type generator and writes `.kitr/`. `--check` fails when a file is stale; `--watch` keeps running; `--only actions` runs one generator. | ```sh kitr make:query invoices/list # → write app/actions/invoices/list.ts kitr types # → write .kitr/actions.ts ``` `kitr doctor` warns when `.kitr/` is stale. ## Errors | Code | When | What to do | | --- | --- | --- | | `VALIDATION` | The input failed the `input` schema, or the handler threw `KitrError.validation()`. | Read `fieldErrors`; forms show them under the field. | | `UNAUTHORIZED` | `requireSession` found no `context.session.user`. | Sign the user in, or drop the middleware for public actions. | | `NOT_FOUND`, `FORBIDDEN` | The handler threw them. | Show the message; the status is 404 or 403. | | `INTERNAL` | The handler threw anything else. | Check the server log; the browser never sees the details in production. | | `Nothing is registered` | A name was used before `createApp({ operations })` ran, or `operations` is not passed. | Import `operations` from `#kitr/actions` in `app/app.ts` and pass it to `createApp`. | | `"x" is a mutation; use useMutation` | A mutation name was given to `useQuery` or `queryOptions`, or the reverse. | Use the hook that matches the kind. | ## Testing `.run(input, { context })` validates the input and runs the handler in-process, skipping middleware and HTTP: ```ts // test/actions/rename.test.ts import { KitrError } from '@kitr/kitr/app' import { describe, expect, it } from 'vitest' import { renameProject } from '#app/actions/projects/rename' describe('projects.rename', () => { it('renames and returns the project', async () => { const project = await renameProject.run({ id: 'prj_42', name: 'Acme site' }) expect(project).toMatchObject({ id: 'prj_42', name: 'Acme site' }) }) it('rejects a short name with field errors', async () => { await expect(renameProject.run({ id: 'prj_42', name: 'A' })).rejects.toMatchObject({ code: 'VALIDATION', fieldErrors: { name: [expect.any(String)] }, }) }) it('sees the middleware context it is given', async () => { const result = await archiveProject.run( { id: 'prj_42' }, { context: { session: { user: { id: 'usr_ada' } } } }, ) expect(result.archivedBy).toBe('usr_ada') }) }) ``` To test a component that uses a name, register the definitions on the test app with [testing](/packages/testing): ```tsx // test/components/rename-button.test.tsx import { renderWithKitr } from '@kitr/testing/react' import { createTestApp } from '@kitr/testing' import { operations } from '#kitr/actions' import { RenameButton } from '#components/rename-button' const app = await createTestApp({ operations }) renderWithKitr(, { app }) ``` ## Next steps - [API reference](/packages/actions/api): every export of `@kitr/actions`. - [Query](/packages/query): the hooks, cache effects and `call`. - [Choosing a data source](/docs/data-sources): actions, API routes, HTTP or realtime. # Server actions Source: https://kitr.dev/packages/actions ## What it is An action is a server function you define with `defineQuery` or `defineMutation` and call from any component by name. kitr sends the request, validates the input with your schema and returns the result or a typed error. Use actions when only your own app calls the code. For mobile apps, partners or webhooks, use [API routes](/packages/api). ## Install ```sh kitr add actions ``` Adds `@kitr/actions` and `app/actions/`, and runs `kitr types` once. Adds [server](/packages/server) and [query](/packages/query) first if missing. [Every change](/packages/actions/guide#install). ## Quick start Define a mutation with its input schema and handler: ```ts // app/actions/projects/rename.ts import { defineMutation } from '@kitr/actions' import { z } from 'zod' export const renameProject = defineMutation('projects.rename', { input: z.object({ id: z.string(), name: z.string().min(2) }), effects: (project) => ({ set: [{ key: ['projects', 'detail', project.id], data: project }], invalidate: [['projects', 'list']], }), handler: async ({ input }) => { // Update the project in your database and return it. return { id: input.id, name: input.name } }, }) ``` Call it from a component by name: ```tsx // app/components/rename-button.tsx import { useMutation } from '@kitr/query' export function RenameButton({ id }: { id: string }) { const rename = useMutation('projects.rename') return ( ) } ``` One click sends one request. The server validates, runs the handler and returns the project; the cache effects update every screen that shows it. A missing `name` is a type error. Next in the guide: [Your first mutation](/packages/actions/guide#your-first-mutation). ## Built on kitr adds four things on top: | What kitr adds | Why | | --- | --- | | Input validation with any [Standard Schema](https://standardschema.dev) library (zod, valibot, arktype). | The server never trusts the browser, and the schema is also the TypeScript type. | | kitr's one error shape, `KitrError`. | A validation failure lands on the right form field in [forms](/packages/forms), and every other failure looks the same as it does from [api](/packages/api) or [http](/packages/http). | | Cache effects on mutations. | A mutation declares which [query](/packages/query) keys it changes, and every screen showing them updates. | | A generated registry. | `useQuery('projects.list')` is typed from the action's name. | kitr adds the input schema, the typed error, the generated registry and the cache effects. ## Problems it solves - **Writing an endpoint for every button.** [Your first mutation](/packages/actions/guide#your-first-mutation). - **Reading data without a REST layer.** [query](/packages/query). - **Data that arrives after the page.** [Load before the page renders](/packages/actions/guide#load-before-the-page-renders). - **Untrusted input reaching server code.** [Throw an error the caller understands](/packages/actions/guide#throw-an-error-the-caller-understands). - **Server code anyone can call.** [Protect an action with middleware](/packages/actions/guide#protect-an-action-with-middleware). - **Actions scattered across the codebase.** [Organize actions by feature](/packages/actions/guide#organize-actions-by-feature). ## Further reading - [Guide](/packages/actions/guide). - [API reference](/packages/actions/api). - [TanStack Start server functions](https://tanstack.com/start/latest/docs/framework/react/server-functions). - [Query](/packages/query). - [API routes](/packages/api). - [Forms](/packages/forms). # Analytics API reference Source: https://kitr.dev/packages/analytics/api Every export of `@kitr/analytics`, `@kitr/analytics/provider`, `@kitr/analytics/react`, read from the published type definitions. The [guide](/packages/analytics/guide) shows them in use. ## @kitr/analytics ### consoleAdapter ```ts function consoleAdapter(options?: { prefix?: string; }): AnalyticsAdapter; ``` Logs every call. The default in development when no other adapter is configured. ### createAnalytics ```ts function createAnalytics(config?: AnalyticsConfig): Analytics; ``` One API over any number of destinations. Calls made before `enable()` queue (up to 100) so a consent banner can gate everything, and every adapter call is wrapped so a blocked script never breaks the app. ### defineEvents ```ts function defineEvents(): >(config: C) => C & { events?: E; }; ``` Identity helper that fixes the event map type: `const analytics = createAnalytics(...)`. ### googleAnalytics ```ts function googleAnalytics(options: { measurementId: string; debug?: boolean; }): AnalyticsAdapter; ``` Google Analytics 4 through gtag.js. Loads the script on `init()` (client only) and forwards page views, events and user ids. `sendPageView: false` is set on config so the router, not the script, decides what a page view is. ### memoryAdapter ```ts function memoryAdapter(): AnalyticsAdapter & { calls: Array<{ type: string; args: unknown[]; }>; }; ``` Collects calls in memory. For tests: `expect(adapter.calls).toContainEqual(...)`. ### plausible ```ts function plausible(options: { domain: string; apiHost?: string; }): AnalyticsAdapter; ``` Plausible: one script, `page()` becomes a pageview, `track()` a custom event with props. ### posthog ```ts function posthog(client: PostHogLike, options: { apiKey: string; apiHost?: string; config?: Record; }): AnalyticsAdapter; ``` PostHog through its own client (`posthog-js`), with autocapture of page views turned off so the router drives them. ### defineConfig ```ts const defineConfig: >(config: C) => C; ``` Types the default export of `config/analytics.ts`: adapters, consent queueing, page views and global props. ### Analytics ```ts interface Analytics { /** Record a page view. The provider calls it on every route change unless `pageViews` is false. */ page(path: string, props?: EventProps & { url?: string; title?: string; }): void; /** * Record an event. With an event map (`useAnalytics()`), only its names are accepted, * each with its own props; without one, any name and props. */ track: Track; /** Attach later events to a user id, after sign-in. `globalProps` are not added. */ identify(userId: string, traits?: EventProps): void; /** Forget the identified user, on sign-out. */ reset(): void; /** Load each adapter's script, then send the queued calls and start sending. Call after consent. */ enable(): Promise; /** Stop sending and empty the queue. Later calls queue again (up to 100) until the next `enable()`. */ disable(): void; /** True once `enable()` has finished and until `disable()`. */ readonly enabled: boolean; /** Add an adapter at runtime (after a consent choice, or lazily). */ use(adapter: AnalyticsAdapter): void; /** Every adapter in use, in the order calls reach them. */ readonly adapters: readonly AnalyticsAdapter[]; } ``` The analytics service bound as `analytics` in the container and returned by `useAnalytics()`. Every call goes to each adapter, or waits in a queue until `enable()`. ### AnalyticsAdapter ```ts interface AnalyticsAdapter { /** Shown in devtools and in debug warnings, such as `google-analytics` or `plausible`. */ name: string; /** Load scripts, create clients. Called once on the client when tracking is enabled. */ init?(): void | Promise; /** Record a page view for `path`; `props.url` and `props.title` carry the full URL and document title. */ page(path: string, props?: EventProps & { url?: string; title?: string; }): void; /** Record a named event with its props. */ track(event: string, props?: EventProps): void; /** Attach later events to a user id. Optional; services without user profiles leave it out. */ identify?(userId: string, traits?: EventProps): void; /** Forget the identified user, on sign-out. Optional. */ reset?(): void; } ``` What every destination implements. Methods are best-effort and must never throw into app code. ### AnalyticsConfig ```ts interface AnalyticsConfig { /** Where calls go. Empty in the provider means the console in development and nothing in production. */ adapters?: AnalyticsAdapter[]; /** Start sending immediately (default). Set false to queue until `analytics.enable()` (consent banners). */ enabled?: boolean; /** Track route changes automatically through the router. Default true. */ pageViews?: boolean; /** Props merged into every event (app version, build id). */ globalProps?: EventProps; /** Log every call. Default: `app.environment` is development. */ debug?: boolean; /** Phantom for `defineEvents`. */ readonly events?: E; } ``` Options for `config/analytics.ts` and `createAnalytics()`. `E` is the app's event map. ### PostHogLike ```ts interface PostHogLike { /** Start the client with a project API key and options. */ init(token: string, config?: Record): unknown; /** Send one event, `$pageview` included. */ capture(event: string, properties?: Record | null): unknown; /** Attach later events to a user id. */ identify(id?: string, properties?: Record): void; /** Forget the identified user. */ reset(): void; } ``` Minimal shape of the posthog-js client; pass `posthog` from `posthog-js` or any compatible object. ### EventMap ```ts type EventMap = Record; ``` `defineEvents<{ 'project.created': { id: string } }>()` gives `track()` typed names and props. ### EventProps ```ts type EventProps = Record; ``` Props sent with an event, a page view or an identify call. Adapters pass them to the vendor as they are. ### Track ```ts type Track = string extends keyof E ? (event: string, props?: EventProps) => void : (event: K, ...props: E[K] extends undefined ? [ ] : [ E[K] ]) => void; ``` `track()`'s signature: checked against the event map when there is one. ## @kitr/analytics/provider ### analyticsProvider ```ts const analyticsProvider: import("@kitr/core").KitrProvider; ``` Binds `analytics` into the container from `config/analytics.ts` and, on the client, tracks route changes. With no adapters configured, development logs to the console and production sends nothing. ## @kitr/analytics/react ### TrackPageViews ```ts function TrackPageViews(): null; ``` Sends a `page()` on every resolved navigation. Mounted by the provider when `pageViews` is not false; render it yourself if you build the tree by hand. ### useAnalytics ```ts function useAnalytics(): Analytics; ``` The analytics instance from the container. # Analytics Guide Source: https://kitr.dev/packages/analytics/guide Sooner or later someone asks how many people created a project this week. `@kitr/analytics` gives you four calls, `page`, `track`, `identify` and `reset`, and sends them to [Google Analytics](https://developers.google.com/analytics/devguides/collection/ga4), [Plausible](https://plausible.io/docs), [PostHog](https://posthog.com/docs/libraries/js) or several at once. > [!SUMMARY] > Count page views and button clicks, and send them to the analytics service you choose. Add it with `kitr add analytics`; the overview is at [/packages/analytics](/packages/analytics). Page views are counted for you, and calls can wait until the visitor agrees to tracking. The examples follow Acme's app at `app.acme.com`: how often Ada creates a project, which plan Grace's team is on, and when Linus pays an invoice. It runs in the browser. For events your server owns, such as a paid invoice, write them to your database or send them from a [job](/packages/jobs). To report errors rather than usage, use [monitoring](/packages/monitoring). ## Install ```sh kitr add analytics ```
Everything kitr add changes What `kitr add` does: - Installs `@kitr/analytics`. - Adds `() => import('@kitr/analytics/provider')` to `providers` and `'@kitr/analytics'` to `packages` in `kitrrc.ts`. - Adds `VITE_PLAUSIBLE_DOMAIN`, `VITE_PLAUSIBLE_HOST` and `VITE_GA_MEASUREMENT_ID`, each `e.string({ optional: true })`, to the `public` block of `app/env.ts`, and an empty line for each to `.env.example` (and to `.env` when that file exists). - Writes `config/analytics.ts` (shown in [Configure](#configure)) and regenerates `config/index.ts`. With [consent](/packages/consent) already installed, the `enabled: false` hint becomes a comment saying analytics is held until the visitor accepts. - Writes `app/analytics.ts`, the typed list of your events. It starts empty, with two events as a commented example. - Adds a `## kitr: analytics` section to `AGENTS.md` telling coding agents to track through `useAnalytics()`, to declare new events in `app/analytics.ts` first, and never to call gtag or posthog directly. `kitr add analytics` asks no questions. Pass `--no-install` to skip the package manager. The `saas` and `site` kits include analytics. In a server-only project it prints a warning and skips. `kitr remove analytics` removes the provider, the `packages` entry, the dependency and the `AGENTS.md` section, and deletes `config/analytics.ts`. It leaves `app/analytics.ts`, the env variable and every `track()` call, which you delete by hand.
## Configure `kitr add analytics` writes this file: ```ts // config/analytics.ts import { defineConfig, googleAnalytics, plausible } from '@kitr/analytics' import { env } from '#app/env' /** * With no adapters, development logs every event to the console and production sends nothing, * so the app works before any vendor is chosen. Plausible is cookieless; set its domain first. */ export default defineConfig({ adapters: [ ...(env.VITE_PLAUSIBLE_DOMAIN ? [plausible({ domain: env.VITE_PLAUSIBLE_DOMAIN, apiHost: env.VITE_PLAUSIBLE_HOST })] : []), ...(env.VITE_GA_MEASUREMENT_ID ? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID })] : []), // posthog(posthogJs, { apiKey: env.VITE_POSTHOG_KEY }), // pnpm add posthog-js ], // Consent banners: start queued, then call analytics.enable() once the user agrees. // enabled: false, globalProps: { app: env.VITE_APP_NAME }, }) ``` With both variables empty, development logs every call to the console and production sends nothing, so your buttons work before you choose a vendor. | Option | Default | Description | | --- | --- | --- | | `adapters` | `[]` | Where calls go. Each entry is an adapter such as `googleAnalytics()` or `plausible()`. When empty, the provider uses `consoleAdapter()` in development and nothing in production. | | `enabled` | `true` | Send at once. `false` queues up to 100 calls until `analytics.enable()`. With [consent](/packages/consent), it waits for consent. | | `pageViews` | `true` | Send `page()` on every route change. `false` leaves page views to you. | | `globalProps` | none | Props merged into every `page()` and `track()` call, such as the app name or build id. Props on the call win. | | `debug` | `true` in development | Print every call and adapter error to the console. Off when devtools is installed. | | `events` | none | A type-only field that carries your event map. You never set it at run time. | | Variable | Used for | | --- | --- | | `VITE_PLAUSIBLE_DOMAIN` | The site as Plausible knows it, such as `app.acme.com`. When set, the generated config adds the Plausible adapter. | | `VITE_PLAUSIBLE_HOST` | Optional. Your self-hosted Plausible, such as `https://plausible.acme.com`. Default `https://plausible.io`. | | `VITE_GA_MEASUREMENT_ID` | Your Google Analytics 4 measurement id, such as `G-ACME12345`. When set, the generated config adds the Google Analytics adapter. | | `VITE_APP_NAME` | Already in your env. The generated config sends it as the `app` prop on every event. | > [!WARNING] Public variables > `VITE_` variables are bundled into the browser. Measurement ids and public project keys are fine; never put a secret API key in one. ## Track your first event `kitr add` wrote `app/analytics.ts`, an empty list of the events the app sends. Declare `project.created` there, with the props it carries: ```ts // app/analytics.ts import type { EventMap } from '@kitr/analytics' /** * Every event the app sends, with its props. `useAnalytics()` checks names and props, * so declare an event here before you track it. */ export interface AppEvents extends EventMap { 'project.created': { id: string } } ``` In a component, get the analytics service with `useAnalytics()` from `@kitr/analytics/react` and call `track()` after the thing happened: ```tsx // app/components/projects/new-project-button.tsx import { useAnalytics } from '@kitr/analytics/react' import type { AppEvents } from '#app/analytics' export function NewProjectButton({ onCreate }: { onCreate: () => Promise<{ id: string }> }) { const analytics = useAnalytics() return ( ) } ``` Click it in development, with no adapter configured, and the console shows: ``` [analytics] track project.created { app: 'acme', id: 'prj_42' } ``` The `app` prop comes from `globalProps`. With [devtools](/packages/devtools) installed, the call also appears in the Analytics panel. Set `VITE_PLAUSIBLE_DOMAIN=app.acme.com` in `.env`, restart, and the same click sends `project.created` to Plausible. `VITE_GA_MEASUREMENT_ID=G-ACME12345` does the same for Google Analytics. No component changes. ## Declare your events Add a line to `AppEvents` for every new event. Use `undefined` for an event without props: ```ts // app/analytics.ts import type { EventMap } from '@kitr/analytics' export interface AppEvents extends EventMap { 'sign-in.completed': { method: 'password' | 'oauth' } 'project.created': { id: string } 'invoice.paid': { invoiceId: string; amount: number; currency: 'USD' | 'EUR' } 'team.invited': { teamId: string; role: 'admin' | 'member' } 'upgrade.clicked': undefined } ``` `useAnalytics()` gives your editor the names and props, so `analytics.track('inv` completes to `'invoice.paid'`: ```tsx // app/components/invoices/pay-button.tsx import { useAnalytics } from '@kitr/analytics/react' import type { AppEvents } from '#app/analytics' export function PayButton({ invoiceId, amount }: { invoiceId: string; amount: number }) { const analytics = useAnalytics() return ( ) } ``` With an event map, a misspelt name, a missing prop or a wrong type is a type error. Without one (`useAnalytics()` with no type argument), `track()` takes anything. Name events `object.action` in the past tense, like `project.created`, so dashboards stay readable.
Building your own instance `defineEvents()` ties the event map to a config object for code that builds its own analytics instance: ```ts // app/lib/onboarding/analytics.ts import { consoleAdapter, createAnalytics, defineEvents } from '@kitr/analytics' import type { AppEvents } from '#app/analytics' const config = defineEvents()({ adapters: [consoleAdapter({ prefix: '[onboarding]' })] }) export const onboardingAnalytics = createAnalytics(config) ```
## Track page views The provider calls `page()` once for the landing page and again after every navigation that changes the path. A change to the search or hash alone is not a new page view. Each call carries the path, `url` and `title`: ```ts // What the tracker sends when Ada opens her projects page analytics.page('/projects', { url: 'https://app.acme.com/projects', title: 'Projects · Acme' }) // adapters receive: ('/projects', { app: 'acme', url: 'https://app.acme.com/projects', title: 'Projects · Acme' }) ``` The tracker needs the router that `createKitrRouter()` from [`@kitr/router`](/packages/router) binds; without one, it sends nothing. To send page views yourself, turn the automatic ones off: ```ts // config/analytics.ts import { defineConfig } from '@kitr/analytics' export default defineConfig({ adapters: [], pageViews: false, }) ``` Then call `page()` where you decide a page was seen, such as a wizard step with no URL change: ```tsx // app/components/onboarding/step.tsx import { useAnalytics } from '@kitr/analytics/react' import { useEffect } from 'react' export function OnboardingStep({ step }: { step: 'team' | 'project' | 'invite' }) { const analytics = useAnalytics() useEffect(() => { analytics.page(`/onboarding/${step}`, { title: `Onboarding: ${step}` }) }, [analytics, step]) return null } ``` ## Identify a signed-in user After sign-in, tell the services who the visitor is, so later events join up. Pass a stable id, never an email address: ```tsx // app/components/auth/sign-in-form.tsx import { useAnalytics } from '@kitr/analytics/react' import type { AppEvents } from '#app/analytics' export function useAfterSignIn() { const analytics = useAnalytics() return (user: { id: string; plan: 'free' | 'team' }) => { analytics.identify(user.id, { plan: user.plan }) analytics.track('sign-in.completed', { method: 'password' }) } } ``` Google Analytics sets `user_id` and the `plan` user property, PostHog calls `posthog.identify`, and Plausible, which has no user profiles, ignores it. `identify()` does not add `globalProps`. ## Forget the user on sign-out Call `reset()` on sign-out so the next person on a shared computer is not counted as them: ```tsx // app/components/auth/sign-out-button.tsx import { useAnalytics } from '@kitr/analytics/react' export function SignOutButton({ onSignOut }: { onSignOut: () => Promise }) { const analytics = useAnalytics() return ( ) } ``` Google Analytics clears `user_id` and PostHog calls `posthog.reset()`. ## Add props to every event `globalProps` are merged into every `page()` and `track()` call. Use them for facts about the build, not the visitor: ```ts // config/analytics.ts import { defineConfig, plausible } from '@kitr/analytics' import { env } from '#app/env' export default defineConfig({ adapters: [plausible({ domain: 'app.acme.com' })], globalProps: { app: env.VITE_APP_NAME, release: '2026.11.1' }, }) ``` A prop on the call wins over a global prop with the same name: `track('project.created', { id: 'prj_42', release: 'canary' })` sends `release: 'canary'`. ## Track outside a component Outside React, `app.container.use('analytics')` returns the instance the components use: ```ts // app/lib/projects/create-project.ts import type { Analytics } from '@kitr/analytics' import type { KitrApp } from '@kitr/kitr/app' import type { AppEvents } from '#app/analytics' export async function createProject(app: KitrApp, name: string) { const response = await fetch('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }), }) const project: { id: string } = await response.json() const analytics = app.container.use('analytics') as Analytics analytics.track('project.created', { id: project.id }) return project } ``` > [!WARNING] > Don't call `window.gtag` or an adapter from feature code. Go through the service so consent, queuing and every other adapter still apply. ## Adapters An adapter sends calls to one service. Every call goes to each listed adapter in order. Each loads its own script the first time tracking is enabled in the browser, never on the server. | Adapter | Sends to | Script or client | | --- | --- | --- | | `googleAnalytics({ measurementId })` | Google Analytics 4 | Loads `gtag.js` for you. | | `plausible({ domain, apiHost })` | Plausible, cloud or self-hosted | Loads `script.manual.js` for you. | | `posthog(client, { apiKey })` | PostHog | You install `posthog-js` and pass it in. | | `consoleAdapter({ prefix })` | The browser console | None. The development default. | | `memoryAdapter()` | An array in memory | None. For tests. | ### Google Analytics Nothing to install. `googleAnalytics()` injects `gtag.js` once and turns off gtag's own page views, so the router decides. ```ts // config/analytics.ts import { defineConfig, googleAnalytics } from '@kitr/analytics' import { env } from '#app/env' export default defineConfig({ adapters: env.VITE_GA_MEASUREMENT_ID ? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID, debug: false })] : [], }) ``` Then set the id in `.env`: ```sh VITE_GA_MEASUREMENT_ID=G-ACME12345 ``` | Option | Default | Description | | --- | --- | --- | | `measurementId` | required | The `G-` id of your GA4 data stream. | | `debug` | off | Sets gtag's `debug_mode`, so events show in GA's DebugView. | `page()` becomes a `page_view` event; `track('project.created', { id })` becomes `gtag('event', 'project.created', { id })`. GA4 has its own rules for event names; see the [GA4 event reference](https://developers.google.com/analytics/devguides/collection/ga4/reference/events). ### Plausible Nothing to install. `plausible()` injects Plausible's manual script once. ```ts // config/analytics.ts import { defineConfig, plausible } from '@kitr/analytics' export default defineConfig({ adapters: [plausible({ domain: 'app.acme.com' })], }) ``` For a self-hosted Plausible, point `apiHost` at it: ```ts // config/analytics.ts import { defineConfig, plausible } from '@kitr/analytics' export default defineConfig({ adapters: [plausible({ domain: 'app.acme.com', apiHost: 'https://stats.acme.com' })], }) ``` | Option | Default | Description | | --- | --- | --- | | `domain` | required | The site as registered in Plausible. | | `apiHost` | `https://plausible.io` | Where the script is loaded from. | `page()` sends a `pageview`; `track()` sends a custom event with props. `identify()` and `reset()` do nothing. Create a goal in Plausible for each event name you want to count; see [custom event goals](https://plausible.io/docs/custom-event-goals). ### PostHog Install the PostHog browser client, then pass it in: ```sh pnpm add posthog-js ``` ```ts // config/analytics.ts import posthogJs from 'posthog-js' import { defineConfig, posthog } from '@kitr/analytics' import { env } from '#app/env' export default defineConfig({ adapters: env.VITE_POSTHOG_KEY ? [ posthog(posthogJs, { apiKey: env.VITE_POSTHOG_KEY, apiHost: 'https://eu.i.posthog.com', config: { autocapture: false }, }), ] : [], }) ``` Declare the key in `app/env.ts`: ```ts // app/env.ts import { createEnv, env as e } from '@kitr/kitr/env' export const env = createEnv({ public: { // ... VITE_GA_MEASUREMENT_ID: e.string({ optional: true }), VITE_POSTHOG_KEY: e.string({ optional: true }), }, server: {}, }) ``` Then set it in `.env`: ```sh VITE_POSTHOG_KEY=phc_acme ``` | Option | Default | Description | | --- | --- | --- | | `apiKey` | required | Your PostHog project API key. | | `apiHost` | `https://us.i.posthog.com` | Your PostHog region or proxy. | | `config` | none | Any other `posthog.init()` options, merged last. | Automatic page views are off so the router drives them; `page()` becomes a `$pageview` capture. Any object with `init`, `capture`, `identify` and `reset` works as the client. See the [posthog-js docs](https://posthog.com/docs/libraries/js) for `config` options. ### Console `consoleAdapter()` prints each call with `console.info`; it is the development default. Add it next to a real service, with a prefix: ```ts // config/analytics.ts import { consoleAdapter, defineConfig, plausible } from '@kitr/analytics' export default defineConfig({ adapters: [plausible({ domain: 'app.acme.com' }), consoleAdapter({ prefix: '[acme]' })], }) ``` ``` [acme] page /projects { app: 'acme', url: 'https://app.acme.com/projects', title: 'Projects · Acme' } [acme] track project.created { app: 'acme', id: 'prj_42' } ``` ### Memory `memoryAdapter()` records every call in its `calls` array as `{ type, args }`. Use it in tests; see [Testing](#testing). ## Wait for consent Set `enabled: false` and every call waits in a queue instead of going out: ```ts // config/analytics.ts import { defineConfig, googleAnalytics } from '@kitr/analytics' import { env } from '#app/env' export default defineConfig({ adapters: env.VITE_GA_MEASUREMENT_ID ? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID })] : [], enabled: false, }) ``` When the visitor agrees, call `enable()`. It loads each adapter's script, then sends the queued calls in order: ```tsx // app/components/tracking-choice.tsx import { useAnalytics } from '@kitr/analytics/react' import { useState } from 'react' export function TrackingChoice() { const analytics = useAnalytics() const [answered, setAnswered] = useState(analytics.enabled) if (answered) return null return (

Acme counts page views to improve the app.

) } ``` `analytics.enabled` is a plain property, not React state, so the component keeps its own flag. This does not remember the answer across visits; [consent](/packages/consent) stores it in a cookie. - Before `enable()`, calls queue (up to 100; later calls are dropped). - `enable()` runs every adapter's `init()`, then sends the queue. Calling it again does nothing. - `disable()` empties the queue and stops sending; later calls queue again. The [consent](/packages/consent) package does this for you: it calls `enable()` when the visitor grants the `analytics` category and `disable()` when they withdraw it. With consent installed, analytics starts disabled whatever `enabled` says, and no script loads before the visitor agrees. > [!WARNING] > With consent installed, never call `enable()` yourself; let the banner decide. Set `analytics: false` in `config/consent.ts` to stop consent managing analytics. ## Add an adapter at run time `use()` adds an adapter after startup, for a service only some visitors need, such as session replay for Acme staff: ```tsx // app/components/staff/staff-tools.tsx import { posthog } from '@kitr/analytics' import { useAnalytics } from '@kitr/analytics/react' import posthogJs from 'posthog-js' import { useEffect } from 'react' export function StaffTools({ email }: { email: string }) { const analytics = useAnalytics() useEffect(() => { if (!email.endsWith('@acme.com')) return if (analytics.adapters.some((a) => a.name === 'posthog')) return analytics.use(posthog(posthogJs, { apiKey: 'phc_acme_staff' })) }, [analytics, email]) return null } ``` An added adapter only receives later calls, and cannot be removed. `analytics.adapters` lists them by `name`. ## Survive blocked scripts Ad blockers and vendor outages cannot break your click handlers: adapter errors are caught, so `track()` never throws and the other adapters still receive the call: ```ts import { createAnalytics, memoryAdapter } from '@kitr/analytics' const memory = memoryAdapter() const analytics = createAnalytics({ adapters: [ { name: 'blocked', page: () => {}, track: () => Promise.reject(new Error('blocked')) }, memory, ], }) await analytics.enable() analytics.track('project.created', { id: 'prj_42' }) // => no error; memory.calls has the track call ``` With `debug` on, each failure prints as a warning; in production failures are silent. ## Use it on the server During server rendering `useAnalytics()` renders the same, but no adapter script loads. > [!WARNING] > Call `track()` from event handlers and effects, never during render or in a loader. On the server it has no script to deliver it. Record server events in your database or a [job](/packages/jobs). ## Testing Pass `memoryAdapter()` in config and assert on its `calls`, recorded as `{ type, args }` with `globalProps` merged in: ```tsx // app/components/projects/new-project-button.test.tsx import { fireEvent, screen, waitFor } from '@testing-library/react' import { memoryAdapter } from '@kitr/analytics' import { analyticsProvider } from '@kitr/analytics/provider' import { createTestApp } from '@kitr/testing' import { renderWithKitr } from '@kitr/testing/react' import { describe, expect, it } from 'vitest' import { NewProjectButton } from './new-project-button' describe('NewProjectButton', () => { it('tracks project.created with the new id', async () => { const memory = memoryAdapter() const app = await createTestApp({ providers: [analyticsProvider], config: { analytics: { adapters: [memory], globalProps: { app: 'acme' } } }, }) renderWithKitr( ({ id: 'prj_42' })} />, { app }) fireEvent.click(screen.getByRole('button', { name: 'New project' })) await waitFor(() => expect(memory.calls).toContainEqual({ type: 'track', args: ['project.created', { app: 'acme', id: 'prj_42' }], }), ) }) }) ``` For code outside components, build an instance with `createAnalytics()` and no app at all. This one checks the consent queue: ```ts // app/lib/projects/analytics.test.ts import { createAnalytics, memoryAdapter } from '@kitr/analytics' import { expect, it } from 'vitest' it('holds events until tracking is enabled', async () => { const memory = memoryAdapter() const analytics = createAnalytics({ adapters: [memory], enabled: false }) analytics.track('project.created', { id: 'prj_42' }) analytics.identify('usr_ada', { plan: 'team' }) expect(memory.calls).toEqual([]) await analytics.enable() expect(memory.calls).toEqual([ { type: 'track', args: ['project.created', { id: 'prj_42' }] }, { type: 'identify', args: ['usr_ada', { plan: 'team' }] }, ]) }) ``` The assertions you will write most: | Assertion | Checks | | --- | --- | | `expect(memory.calls).toContainEqual({ type: 'track', args: ['invoice.paid', expect.objectContaining({ invoiceId: 'inv_7' })] })` | An event was sent with a prop, ignoring the others. | | `expect(memory.calls.filter((c) => c.type === 'page')).toHaveLength(1)` | One page view. | | `expect(memory.calls).toContainEqual({ type: 'reset', args: [] })` | Sign-out cleared the user. | | `expect(analytics.enabled).toBe(false)` | Nothing goes out before consent. | ## Extend An adapter is an object that implements `AnalyticsAdapter`. Only `name`, `page` and `track` are required: ```ts // The interface, from @kitr/analytics export interface AnalyticsAdapter { name: string init?(): void | Promise page(path: string, props?: EventProps & { url?: string; title?: string }): void track(event: string, props?: EventProps): void identify?(userId: string, traits?: EventProps): void reset?(): void } ``` `init()` runs once, in the browser, the first time tracking is enabled. Load scripts there, not at creation, so nothing loads before consent. The other methods should be quick and must not throw. This adapter sends events to Acme's endpoint with `navigator.sendBeacon`, which survives the page closing: ```ts // app/analytics/acme-adapter.ts import type { AnalyticsAdapter, EventProps } from '@kitr/analytics' export function acmeAdapter(options: { endpoint: string }): AnalyticsAdapter { let userId: string | undefined const send = (type: string, name: string, props?: EventProps) => { const body = JSON.stringify({ type, name, props, userId, at: new Date().toISOString() }) if (!navigator.sendBeacon(options.endpoint, body)) { void fetch(options.endpoint, { method: 'POST', body, keepalive: true }) } } return { name: 'acme', page: (path, props) => send('page', path, props), track: (event, props) => send('track', event, props), identify(id, traits) { userId = id send('identify', id, traits) }, reset() { userId = undefined }, } } ``` Register it in config like any built-in adapter: ```ts // config/analytics.ts import { defineConfig } from '@kitr/analytics' import { acmeAdapter } from '#app/analytics/acme-adapter' export default defineConfig({ adapters: [acmeAdapter({ endpoint: 'https://app.acme.com/api/events' })], }) ``` See [`AnalyticsAdapter`](/packages/analytics/api#analyticsadapter) in the API reference. ## Devtools With [devtools](/packages/devtools) installed, an Analytics panel shows whether tracking is enabled or queued, the active adapters and the last 200 calls. Enable, Disable and Clear events are also in the command palette. ## Next steps - [API reference](/packages/analytics/api): every export and type. - [Consent](/packages/consent): hold analytics until visitors agree. - [Monitoring](/packages/monitoring): report errors rather than usage. # Analytics Source: https://kitr.dev/packages/analytics ## What it is `@kitr/analytics` is four calls: `page`, `track`, `identify` and `reset`. Declare your events once, call `track` anywhere, and every configured adapter gets it. Page views are counted on each route change, and calls wait until the visitor agrees through [consent](/packages/consent). Runs in the browser. For server-side events, write to your database or send from a [job](/packages/jobs). For errors, use [monitoring](/packages/monitoring). ## Install ```sh kitr add analytics ``` Adds `@kitr/analytics`, its provider, `config/analytics.ts` and `app/analytics.ts` for your events. [Every change](/packages/analytics/guide#install). ## Quick start Declare the events your app sends, with their props: ```ts // app/analytics.ts import type { EventMap } from '@kitr/analytics' export interface AppEvents extends EventMap { 'sign-in.completed': { method: 'password' | 'oauth' } 'project.created': { id: string } } ``` Track one after the thing happened: ```tsx // app/features/projects/new-project-button.tsx import { useAnalytics } from '@kitr/analytics/react' import type { AppEvents } from '#app/analytics' export function NewProjectButton({ onCreate }: { onCreate: () => Promise<{ id: string }> }) { const analytics = useAnalytics() return ( ) } ``` With no adapter configured, the console shows `[analytics] track project.created { app: 'acme', id: 'prj_42' }`. Set `VITE_PLAUSIBLE_DOMAIN` (or `VITE_GA_MEASUREMENT_ID`) and the same click reaches Plausible (or Google Analytics). Next in the guide: [Track your first event](/packages/analytics/guide#track-your-first-event). ## Built on `@kitr/analytics` is a thin adapter layer. Each adapter speaks to one vendor's script or API: | Adapter | Vendor docs | | --- | --- | | Google Analytics 4 | [GA4 collection guide](https://developers.google.com/analytics/devguides/collection/ga4) | | Plausible | [Plausible docs](https://plausible.io/docs) | | PostHog | [PostHog JavaScript library](https://posthog.com/docs/libraries/js) | kitr adds typed events, page view tracking, the consent queue, error wrapping around vendor calls and a memory adapter for tests. ## Problems it solves - **Vendor lock-in in every component.** [Adapters](/packages/analytics/guide#adapters). - **Misspelt event names.** [Declare your events](/packages/analytics/guide#declare-your-events). - **Page views that miss client-side navigation.** [Track page views](/packages/analytics/guide#track-page-views). - **Knowing who did it.** [Identify a signed-in user](/packages/analytics/guide#identify-a-signed-in-user). - **Tracking before consent.** [consent](/packages/consent). - **Ad blockers breaking the app.** [Survive blocked scripts](/packages/analytics/guide#survive-blocked-scripts). ## Further reading - [Guide](/packages/analytics/guide). - [API reference](/packages/analytics/api). - [GA4](https://developers.google.com/analytics/devguides/collection/ga4), [Plausible](https://plausible.io/docs) and [PostHog](https://posthog.com/docs/libraries/js) docs: event limits, property rules and dashboards for each vendor. - [Consent](/packages/consent). - [Monitoring](/packages/monitoring). - [Devtools](/packages/devtools). # Animation API reference Source: https://kitr.dev/packages/animation/api Every export of `@kitr/animation`, `@kitr/animation/provider`, read from the published type definitions. The [guide](/packages/animation/guide) shows them in use. ## @kitr/animation Re-exports `framer-motion`, `motion`, `motion-dom`, `motion-utils` unchanged, plus the following. ### pageTransitions ```ts function pageTransitions(options?: PageTransitionOptions): PageTransitionsValue; ``` Animated page changes for `createKitrRouter`, through the browser's View Transitions API: `defaultViewTransition: pageTransitions()`. Each navigation gets the types `kitr- ``` ## Style with CSS Modules Without Tailwind, CSS Modules plus three small helpers cover most styling. `kitr css:types` writes a `.d.ts` next to each `*.module.css` so class names are checked: ```sh kitr css:types ``` ```ts // app/components/invoices/invoice-card.module.css.d.ts // Generated by `kitr css:types`. Do not edit. declare const classes: { readonly card: string readonly overdue: string } export default classes ``` `pick(styles, ...parts)` joins class names, with conditions in an object: ```tsx // app/components/invoices/invoice-card.tsx import { pick } from '@kitr/theme' import styles from './invoice-card.module.css' export function InvoiceCard({ id, total, overdue }: { id: string; total: number; overdue: boolean }) { return (
{id}: ${total}
) } // overdue → class="card_x1 overdue_x2" (the hashed names from the module) ``` `pick` skips `false`, `null` and `undefined`, so `pick(styles, 'card', isOpen && 'overdue')` works. `pick(styles, 'crad')` is a type error. To type a module by hand, use `CssModule<'card' | 'overdue'>`. ## Pass values to CSS `vars()` builds a `style` object of CSS variables, so a component passes a value to its stylesheet: ```tsx // app/components/projects/progress-bar.tsx import { vars } from '@kitr/theme' import styles from './progress-bar.module.css' export function ProgressBar({ done }: { done: number }) { return
} // vars({ '--progress': '40%', '--gap': 8 }) => { '--progress': '40%', '--gap': '8' } ``` ```css /* app/components/projects/progress-bar.module.css */ .track { height: 0.5rem; border-radius: var(--kitr-radius-sm); background: linear-gradient( to right, var(--kitr-color-primary) var(--progress), var(--kitr-color-muted) var(--progress) ); } ``` ## Style states with data attributes `dataAttrs()` turns an object into `data-*` attributes, so plain CSS can target a component's state: ```tsx // app/components/orders/order-row.tsx import { dataAttrs } from '@kitr/theme' export function OrderRow({ status, selected }: { status: 'open' | 'shipped'; selected: boolean }) { return } // dataAttrs({ status: 'open', isSelected: true }) => { 'data-status': 'open', 'data-is-selected': '' } // dataAttrs({ status: 'shipped', isSelected: false }) => { 'data-status': 'shipped' } ``` ```css /* app/styles/orders.css */ .order-row[data-status="shipped"] { color: var(--kitr-color-mutedForeground); } .order-row[data-is-selected] { background: var(--kitr-color-muted); } ``` Keys become kebab-case. `true` gives an empty attribute; `false`, `null` and `undefined` leave it out. ## Build component variants `@kitr/theme` re-exports [class-variance-authority](https://cva.style) as `cva` (with `VariantProps`) and [clsx](https://github.com/lukeed/clsx) as `clsx` and `cx`, which the registry components use: ```tsx // app/components/ui/badge.tsx import { cva, cx, type VariantProps } from '@kitr/theme' const badge = cva('badge', { variants: { tone: { neutral: 'badge-neutral', success: 'badge-success', danger: 'badge-danger' }, }, defaultVariants: { tone: 'neutral' }, }) export function Badge({ tone, className, children, }: VariantProps & { className?: string; children: string }) { return {children} } // badge({ tone: 'success' }) => 'badge badge-success' // cx('badge', { active: true, muted: false }) => 'badge active' ``` With [tailwind](/packages/tailwind), use its `cn()` instead of `cx`: it also merges conflicting utility classes. ## Add components `kitr ui add` copies components from a shadcn-compatible registry into `app/components/ui/`. The files are yours to edit. ```sh kitr ui add button input card dialog ``` ```tsx // app/components/projects/new-project-card.tsx import { Button } from '#components/ui/button' import { Card } from '#components/ui/card' export function NewProjectCard() { return (

New project

) } ``` The components use the `--kitr-*` tokens and `cva`, so they follow the theme and your overrides. `kitr ui add` also copies registry dependencies (`theme-toggle` brings `button` and `icon`) and installs the npm packages they need. | Component | What it is | | --- | --- | | `app-shell` | Sidebar and top bar layout with navigation links, a user menu slot and the theme toggle. | | `badge` | Status badge. | | `button` | Button with variants and sizes, built on `cva`. | | `card` | Card with header, content and footer. | | `checkbox` | Native checkbox with kitr styling. | | `data-table` | TanStack Table with sorting, pagination and URL-bound state via [table](/packages/table). | | `dialog` | Modal dialog on the native `` element. | | `empty-state` | Centered empty state with title, description and action. | | `form` | Styled `FormField`, `FormMessage` and `SubmitButton` over [forms](/packages/forms). | | `icon` | Hugeicons in the stroke-rounded style. | | `input`, `label`, `select`, `textarea` | Form controls. | | `page-header` | Title, description and actions row for a page. | | `skeleton` | Loading placeholder. | | `theme-toggle` | Light and dark toggle using `useTheme`. | | `list-page` | A list route: page header, data table bound to search params, empty state. Writes a page, not a component. | | `sign-in-page` | A sign-in route using [forms](/packages/forms). Writes a page, not a component. | For your company's registry, set `registry` in `config/theme.ts`, pass `--registry`, or set `KITR_REGISTRY`. A registry is any URL or directory serving `registry.json` and one `.json` per component in the shadcn format. ## Use icons Icons are [Hugeicons](https://hugeicons.com) in the stroke-rounded style, drawn in `currentColor`. `kitr ui add icon` gives you ``: ```tsx // app/components/projects/add-project-button.tsx import { Add01Icon } from '@hugeicons/core-free-icons' import { Button } from '#components/ui/button' import { Icon } from '#components/ui/icon' export function AddProjectButton() { return ( ) } ``` The registry components, kit pages and devtools overlay all use this set. Use one icon set across the app, not emoji or text glyphs. ## Change the storage key or attribute Two apps on one domain, such as `app.acme.com` and `admin.acme.com` sharing cookies, need different keys. Set them in config and pass the same values to the script: ```ts // config/theme.ts import { defineConfig } from '@kitr/theme' export default defineConfig({ default: 'system', storageKey: 'acme-admin-theme', attribute: 'data-mode', colors: { light: '#ffffff', dark: '#0a0a0a' }, componentsDir: 'app/components/ui', }) ``` ```tsx // app/document.tsx (inside ) ``` > [!WARNING] Custom attribute > With a different `attribute`, `@kitr/theme/styles.css` no longer switches to dark, because its dark block targets `[data-theme="dark"]`. Copy the dark values into your own `[data-mode="dark"]` block in `app/styles/tokens.css`, or keep the default. ## Use ThemeProvider on its own The provider in `kitrrc.ts` renders `ThemeProvider` with your config. Outside a kitr app, such as Storybook or an embedded widget, render it yourself: ```tsx // app/widget/main.tsx import { ThemeProvider } from '@kitr/theme' import { createRoot } from 'react-dom/client' import { InvoiceStatus } from '../components/invoices/invoice-status' createRoot(document.getElementById('acme-widget')!).render( , ) ``` | Prop | Default | Description | | --- | --- | --- | | `defaultTheme` | `'system'` | The theme before the user picks one. | | `storageKey` | `'kitr-theme'` | The `localStorage` key and cookie name. | | `attribute` | `'data-theme'` | The attribute set on ``. | | `initialResolved` | `'light'` | `resolved` before the first effect runs, for example from `themeFromCookie`, so the first render matches the server. | | `colors` | none | `` per resolved theme. | > [!WARNING] One ThemeProvider > Don't render a second `ThemeProvider` inside a kitr app: a nested one keeps its own state, so its `toggle` would not change what the rest of the app reads. ## Commands | Command | What it does | | --- | --- | | `kitr ui add ` | Copy components into your app: `kitr ui add button`. | | `kitr ui list` | Lists the components in the registry with a one-line description. Flag: `--registry`. | | `kitr css:types` | Type CSS Module class names. Comes from [web](/packages/web). | ```sh kitr ui add theme-toggle ``` This writes `theme-toggle.tsx`, `button.tsx` and `icon.tsx` into `app/components/ui/` and installs `class-variance-authority`, `@hugeicons/react` and `@hugeicons/core-free-icons`: ```tsx // app/components/ui/theme-toggle.tsx import { Moon02Icon, Sun03Icon } from '@hugeicons/core-free-icons' import { useTheme } from '@kitr/theme' import { Button } from '#components/ui/button' import { Icon } from '#components/ui/icon' export function ThemeToggle() { const { resolved, toggle } = useTheme() return ( ) } ``` ```sh kitr ui list ``` ``` app-shell Sidebar + top bar layout with navigation links, user menu slot and theme toggle. badge Status badge. button Button with variants and sizes, built on cva. ... list-page A list route: page header, data table bound to URL search params, empty state. (page) ``` ## Errors | Error | When | What to do | | --- | --- | --- | | `[@kitr/theme] useTheme() must be used inside .` | A component called `useTheme` with no theme provider above it. | Add the theme provider with `kitr add theme`, or render inside `ThemeProvider`. In tests, boot an app with `themeProvider`. | | `Component "" not found at (404)` | `kitr ui add` asked the registry for a component it does not have. | Check the name with `kitr ui list`, or the `registry` setting. | | `Component "" not found in ` | The same, for a registry that is a local directory. | Check the directory has `.json`. | | No error: the file is kept | `kitr ui add` found the component file already there. It keeps yours and still reports the component as added. | Pass `--overwrite` to replace it with the registry's version. | ## Testing Boot an app with the theme provider and render with `renderWithKitr` from [testing](/packages/testing). Config goes in through `createTestApp`: ```tsx // app/components/theme-button.test.tsx import themeProvider from '@kitr/theme/provider' import { createTestApp, renderWithKitr } from '@kitr/testing/react' import { act } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { ThemeButton } from './theme-button' describe('ThemeButton', () => { it('switches Ada to dark mode and remembers it', async () => { const app = await createTestApp({ providers: [themeProvider], config: { theme: { default: 'light' } }, }) const screen = renderWithKitr(, { app }) const button = screen.getByRole('button', { name: 'Switch to dark' }) await act(async () => button.click()) expect(document.documentElement.getAttribute('data-theme')).toBe('dark') expect(localStorage.getItem('kitr-theme')).toBe('dark') expect(document.cookie).toContain('kitr-theme=dark') expect(screen.getByRole('button', { name: 'Switch to light' })).toBeTruthy() }) }) ``` The theme is saved to `localStorage` and a cookie, which the test DOM keeps between tests. Clear them in `afterEach`: ```ts // test/setup.ts import { afterEach } from 'vitest' afterEach(() => { localStorage.clear() document.cookie = 'kitr-theme=; max-age=0; path=/' document.documentElement.removeAttribute('data-theme') }) ``` To test dark mode without clicking, save the choice first: `localStorage.setItem('kitr-theme', 'dark')`. For `system`, the provider asks `window.matchMedia('(prefers-color-scheme: dark)')`; replace it to control the OS setting: ```ts // test/dark-system.ts import { vi } from 'vitest' export function prefersDark(dark: boolean) { window.matchMedia = vi.fn(() => ({ matches: dark, addEventListener: () => {}, removeEventListener: () => {}, })) as unknown as typeof window.matchMedia } ``` ## Next steps - [API reference](/packages/theme/api): every export of `@kitr/theme`, `@kitr/theme/provider` and `@kitr/theme/server`. - [Tailwind](/packages/tailwind): the same tokens as utility classes, and `cn()`. - [Toast](/packages/toast): short messages like "Saved", which follow the theme. # Theme Source: https://kitr.dev/packages/theme ## What it is `@kitr/theme` gives your app light, dark and system themes with no flash on load, design tokens as CSS variables, and `kitr ui add` to copy components into your app. Runs in the browser and during server rendering. For utility classes on the same tokens, add [tailwind](/packages/tailwind). ## Install ```sh kitr add theme ``` Adds `@kitr/theme`, its provider, `config/theme.ts`, `app/styles/tokens.css` and `` in `app/document.tsx`. Adds [react](/packages/react) first if missing. [Every change](/packages/theme/guide#install). ## Quick start `useTheme()` returns the current choice and the functions that change it: ```tsx // app/components/theme-button.tsx import { useTheme } from '@kitr/theme' export function ThemeButton() { const { resolved, toggle } = useTheme() return ( ) } ``` Click it and the page turns dark: `` gets `data-theme="dark"` and every token switches. The choice is saved, so a reload keeps it. `kitr ui add theme-toggle` gives you a styled one. Next in the guide: [Add a dark mode toggle](/packages/theme/guide#add-a-dark-mode-toggle). ## Built on | Built on | Role | | --- | --- | | [CSS custom properties](https://developer.mozilla.org/docs/Web/CSS/Using_CSS_custom_properties) | The tokens: colors, radii, spacing, with a dark set under `[data-theme="dark"]`. | | [shadcn/ui](https://ui.shadcn.com)-compatible registries | The component format `kitr ui add` reads and copies into `app/components/ui/`. | | [class-variance-authority](https://cva.style/docs) | Component variants in the copied components. | kitr adds the provider, the no-flash script, the cookie for server rendering, typed tokens and the component registry. ## Problems it solves - **A white flash before dark mode.** [Avoid a flash on load](/packages/theme/guide#avoid-a-flash-on-load). - **Light, dark and system as three code paths.** [Let users pick light, dark or system](/packages/theme/guide#let-users-pick-light-dark-or-system). - **Colors copied between files.** [Use the tokens in CSS](/packages/theme/guide#use-the-tokens-in-css). - **Rebranding by search and replace.** [Brand the colors](/packages/theme/guide#brand-the-colors). - **Component libraries that fight the design system.** [Add components](/packages/theme/guide#add-components). ## Further reading - [Guide](/packages/theme/guide). - [API reference](/packages/theme/api). - [shadcn/ui registries](https://ui.shadcn.com/docs/registry). - [Using CSS custom properties on MDN](https://developer.mozilla.org/docs/Web/CSS/Using_CSS_custom_properties). - [Tailwind](/packages/tailwind) and [Router](/packages/router). # Toast API reference Source: https://kitr.dev/packages/toast/api Every export of `@kitr/toast`, `@kitr/toast/provider`, read from the published type definitions. The [guide](/packages/toast/guide) shows them in use. ## @kitr/toast ### setToastImplementation ```ts function setToastImplementation(fn: ToastFn | null): void; ``` Replace what `toast` calls: a function with `success`, `error`, `info`, `warning` and `promise` methods. The toast provider sets sonner's `toast` when the app boots; `null` goes back to the console fallback. ### toastError ```ts function toastError(error: unknown, fallbackMessage?: string): void; ``` Show a KitrError (or anything) as an error toast with its message. ### defineConfig ```ts const defineConfig: (config: C) => C; ``` Type-check `config/toast.ts`: returns the object unchanged, with autocomplete for every `ToastConfig` option. ### toast ```ts const toast: ToastFn; ``` Show a toast: `toast('Saved')`, `toast.success()`, `.error()`, `.info()`, `.warning()` and `.promise()`. Goes to sonner once the toast provider has booted, and to the console before that or without it. ### ToastConfig ```ts interface ToastConfig { /** Where toasts appear. Default `'bottom-right'`. */ position?: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center'; /** Coloured success, error, info and warning toasts. Default `true`. */ richColors?: boolean; /** How long a toast stays, in milliseconds. Default `4000`. */ duration?: number; /** Show a close button on every toast. Default `false`. */ closeButton?: boolean; } ``` The options of `config/toast.ts`: where toasts appear and how they look. ### ToastFn ```ts /** What `toast` calls: a function with `success`, `error`, `info`, `warning` and `promise` methods. */ type ToastFn = ((message: string, options?: Record) => unknown) & { success: (message: string, options?: Record) => unknown; error: (message: string, options?: Record) => unknown; info: (message: string, options?: Record) => unknown; warning: (message: string, options?: Record) => unknown; promise: (promise: Promise, messages: { loading: string; success: string; error: string; }) => Promise; }; ``` What `toast` calls: a function with `success`, `error`, `info`, `warning` and `promise` methods. ## @kitr/toast/provider ### toastProvider ```ts const toastProvider: import("@kitr/core").KitrProvider; ``` Sends `toast` to sonner and mounts its toaster with `config/toast.ts`. Listed in kitrrc.ts as `() => import('@kitr/toast/provider')`. # Toast Guide Source: https://kitr.dev/packages/toast/guide A toast is a short message that appears for a few seconds and then goes away: "Invoice saved", "Could not send", "You are offline". `@kitr/toast` gives you one `toast()` function to call from anywhere in the app, and a provider that draws the messages with [sonner](https://sonner.emilkowal.ski). > [!SUMMARY] > One `toast()` function to call from anywhere, drawn by sonner and following the theme. Add it with `kitr add toast`; the overview is at [/packages/toast](/packages/toast). `toast` itself imports nothing. Until the provider boots, and in any place without one, such as a test or a script, every call goes to the console instead. That is what lets other packages and plain modules call it safely. This guide follows Acme's dashboard at `app.acme.com`: Ada shows a toast when Grace saves an invoice, a loading toast while one is sent, and an error toast when a payment fails. ## Install ```sh kitr add toast ```
Everything kitr add changes What `kitr add` does: - Adds [react](/packages/react) first if the project does not have it, because toast requires it. - Installs `@kitr/toast`, which brings `sonner` with it. - Adds `'@kitr/toast'` to `packages` and `() => import('@kitr/toast/provider')` to `providers` in `kitrrc.ts`. - Writes `config/toast.ts` (shown under Configure). - Adds a `toast` section to `AGENTS.md`: call `toast` from `@kitr/toast`, never render a second toaster, and keep the options in `config/toast.ts`. - Adds no environment variables and no scripts. `kitr add` skips `config/toast.ts` when it already exists. It asks no questions; `--no-install` skips the package manager and `--dry-run` prints the plan. ```sh kitr add toast --dry-run ``` The `app`, `fullstack`, `saas`, `realtime`, `desktop`, `tauri` and `mobile` kits include toast. The `site`, `server` and `blank` kits do not. `kitr remove toast` removes the provider, the `packages` entry, the dependency, `config/toast.ts` and the `AGENTS.md` section. Calls to `toast` left in your code keep compiling only while the package is installed, so remove them first.
## Configure `kitr add toast` writes this file: ```ts // config/toast.ts import { defineConfig } from '@kitr/toast' export default defineConfig({ position: 'bottom-right', richColors: true, }) ``` | Option | Default | Description | | --- | --- | --- | | `position` | `'bottom-right'` | Where toasts appear: `'top-left'`, `'top-center'`, `'top-right'`, `'bottom-left'`, `'bottom-center'` or `'bottom-right'`. | | `richColors` | `true` | Green success, red error, blue info and yellow warning toasts. `false` draws them all in the neutral colours. | | `duration` | `4000` | How long a toast stays, in milliseconds. | | `closeButton` | `false` | Show a close button on every toast. | The config is read once, when the app is created. Each call can still pass its own `duration`, as shown below. ## Show a toast `toast` takes a message. Its methods pick the kind: ```tsx // app/components/invoices/save-invoice-button.tsx import { toast } from '@kitr/toast' export function SaveInvoiceButton({ id }: { id: string }) { async function save() { await fetch(`/api/invoices/${id}`, { method: 'PUT' }) toast.success(`Invoice ${id} saved`) } return ( ) } ``` | Call | Shows | | --- | --- | | `toast(message, options?)` | A plain toast. | | `toast.success(message, options?)` | A green toast with a check mark. | | `toast.info(message, options?)` | A blue toast. | | `toast.warning(message, options?)` | A yellow toast. | | `toast.error(message, options?)` | A red toast. | | `toast.promise(promise, { loading, success, error })` | A loading toast that turns into success or error when the promise settles. | | `toastError(error, fallback?)` | `toast.error` with the error's message. | `options` go straight to sonner. The ones you will use most are a second line and a longer stay: ```ts // app/lib/invoices/notify.ts import { toast } from '@kitr/toast' export function invoiceOverdue(id: string, days: number) { toast.warning(`Invoice ${id} is overdue`, { description: `Grace has not paid for ${days} days.`, duration: 10_000, }) } ``` ## Write messages people can act on A toast is read in a second, often out of the corner of the eye. Say what happened to what, in a few words, and put any detail in `description`. Name the thing ("Invoice INV-1042 sent"), not the action ("Success"). For an error, say what the person can do next, not what went wrong inside the app: ```ts // Good: names the invoice, and the error says what to do toast.success('Invoice INV-1042 sent') toast.error('Could not send INV-1042', { description: 'Check the email address and try again.' }) // Hard to use: no subject, and an error nobody can act on toast.success('Done!') toast.error('Error 500: ECONNRESET') ``` Toasts are announced to screen readers as they appear, so the same rule helps people who never see them. ## Show progress with a promise `toast.promise` shows a loading toast while a slow request runs, then swaps it for the result. It returns the same promise, so you can still await it: ```ts // app/lib/invoices/send-invoice.ts import { toast } from '@kitr/toast' export function sendInvoice(id: string) { return toast.promise(fetch(`/api/invoices/${id}/send`, { method: 'POST' }), { loading: `Sending ${id} to grace@acme.com…`, success: `Invoice ${id} sent`, error: `Could not send ${id}`, }) } ``` ```tsx // app/components/invoices/send-button.tsx import { sendInvoice } from '#app/lib/invoices/send-invoice' export function SendButton({ id }: { id: string }) { return ( ) } ``` ## Show an error `toastError` takes anything you caught and shows its `message`. Errors from kitr packages carry a message written for people, so this is usually all a catch block needs: ```ts // app/lib/payments/charge.ts import { toast, toastError } from '@kitr/toast' export async function charge(invoiceId: string) { try { await fetch(`/api/invoices/${invoiceId}/charge`, { method: 'POST' }).then((r) => { if (!r.ok) throw new Error('Payment declined') }) toast.success('Payment received') } catch (error) { toastError(error) } } ``` When the thrown value has no `message`, such as a string, it shows the fallback instead: `'Something went wrong'`, or the one you pass. ```ts toastError('timeout', 'The bank did not answer. Try again in a minute.') ``` ## Add an action button A toast can carry one button, such as an undo after a delete. Pass `action` with a label and what to do on click. The toast closes when the button is pressed: ```ts // app/lib/invoices/archive.ts import { toast } from '@kitr/toast' export async function archiveInvoice(id: string) { await fetch(`/api/invoices/${id}/archive`, { method: 'POST' }) toast(`Invoice ${id} archived`, { action: { label: 'Undo', onClick: () => fetch(`/api/invoices/${id}/unarchive`, { method: 'POST' }), }, }) } ``` Keep the action short and safe to skip: toasts go away by themselves, so anyone who looks away a moment too long misses it. For a choice that matters, use a dialog instead. ## Update a toast in place Give two calls the same `id` and the second replaces the first instead of stacking a new toast. This suits a long upload that reports its own progress: ```ts // app/lib/files/upload-report.ts import { toast } from '@kitr/toast' export async function uploadReport(file: File) { toast(`Uploading ${file.name}…`, { id: 'report-upload', duration: Infinity }) await fetch('/api/reports', { method: 'POST', body: file }) toast.success(`${file.name} uploaded`, { id: 'report-upload', duration: 4000 }) } ``` `toast.promise` does the same for you when all you need is loading, done and failed. Reach for `id` when there are more steps in between, or when the same event can fire twice and should only show once, such as "You are offline". ## Show toasts outside React `toast` is a plain function, so it works where hooks do not: a mutation callback, a store action, a router loader or another package. ```ts // app/stores/cart.ts import { create } from 'zustand' import { toast } from '@kitr/toast' export const useCart = create<{ items: string[]; add: (item: string) => void }>()((set) => ({ items: [], add: (item) => { set((s) => ({ items: [...s.items, item] })) toast(`${item} added to the cart`) }, })) ``` With [query](/packages/query), show the result of a mutation from its callbacks: ```ts // app/lib/orders/ship.ts import { useMutation } from '@kitr/query' import { toast, toastError } from '@kitr/toast' export function useShipOrder() { return useMutation('orders.ship', { onSuccess: () => toast.success('Shipped'), onError: (error) => toastError(error), }) } ``` Until the provider has booted, such as in a script or a server-rendered pass, calls go to the console: ```ts import { toast, toastError } from '@kitr/toast' toast('Draft saved') // console.info('[toast] Draft saved') toast.warning('Invoice inv_2 is overdue') // console.warn('[toast:warning] Invoice inv_2 is overdue') toastError(new Error('Payment declined')) // console.error('[toast:error] Payment declined') ``` ## Follow the theme The toaster follows `data-theme` on ``, which [theme](/packages/theme) sets. When Ada switches to dark mode, open toasts and new ones turn dark too. Without theme, toasts follow the operating system. ## Send toasts somewhere else `setToastImplementation` replaces what `toast` calls. Give it a function with `success`, `error`, `info`, `warning` and `promise` methods. Do this to show toasts in your own component, or in a native notification on desktop: ```ts // app/providers/notify-provider.ts import { defineProvider } from '@kitr/kitr/app' import { setToastImplementation } from '@kitr/toast' import { notify } from '#app/components/notify' export default defineProvider({ name: 'notify', boot() { setToastImplementation( Object.assign((message: string) => notify({ tone: 'neutral', message }), { success: (message: string) => notify({ tone: 'success', message }), error: (message: string) => notify({ tone: 'danger', message }), info: (message: string) => notify({ tone: 'neutral', message }), warning: (message: string) => notify({ tone: 'warning', message }), promise: (promise: Promise) => promise, }), ) }, }) ``` List it after `@kitr/toast/provider` in `kitrrc.ts`, so it boots last and wins. `setToastImplementation(null)` goes back to the console. ## Errors `toast` never throws, so a failing toast cannot break the code that called it. What can go wrong is where the message ends up: | Symptom | Why | What to do | | --- | --- | --- | | Toasts show in the console, not on screen | The provider is not in `kitrrc.ts`, or the call ran before the app booted. | Run `kitr add toast`, or check `() => import('@kitr/toast/provider')` is listed in `providers`. | | Two toasts appear for every call | A second `` from sonner is mounted by hand. | Remove it: the provider already mounts one. | | Toasts stay light in dark mode | Something other than `data-theme` on `` marks the theme. | Use [theme](/packages/theme), or set `data-theme="dark"` yourself. | ## Testing Without the provider, toasts go to the console, so a test can spy on it. To assert on what was shown, set your own implementation and reset it after each test: ```ts // app/lib/payments/charge.test.ts import { setToastImplementation } from '@kitr/toast' import { afterEach, expect, it, vi } from 'vitest' import { charge } from './charge' afterEach(() => setToastImplementation(null)) it('shows the error from a declined payment', async () => { const shown = Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), promise: vi.fn(), }) setToastImplementation(shown) globalThis.fetch = vi.fn(async () => new Response(null, { status: 402 })) await charge('INV-1042') expect(shown.error).toHaveBeenCalledWith('Payment declined', undefined) }) ``` To see real toasts in a component test, boot an app with the provider and look for the text: ```tsx // app/components/invoices/save-invoice-button.test.tsx import toastProvider from '@kitr/toast/provider' import { createTestApp, renderWithKitr } from '@kitr/testing/react' import { act } from '@testing-library/react' import { expect, it, vi } from 'vitest' import { SaveInvoiceButton } from './save-invoice-button' it('tells Ada the invoice was saved', async () => { globalThis.fetch = vi.fn(async () => new Response(null)) const app = await createTestApp({ providers: [toastProvider] }) const screen = renderWithKitr(, { app }) await act(async () => screen.getByRole('button', { name: 'Save' }).click()) expect(await screen.findByText('Invoice INV-1042 saved')).toBeTruthy() }) ``` ## Next steps - [API reference](/packages/toast/api): every export of `@kitr/toast` and `@kitr/toast/provider`. - [Theme](/packages/theme): dark mode the toaster follows. - [sonner](https://sonner.emilkowal.ski): every toast option, such as `action` and `id`. # Toast Source: https://kitr.dev/packages/toast ## What it is `@kitr/toast` shows short messages that pop up and go away on their own: "Invoice saved", "Could not send". Call `toast()` from a component, an event handler or any plain module. Runs in the browser. Outside a browser, or before the app has booted, every call goes to the console, so a package can call `toast` without depending on a UI. ## Install ```sh kitr add toast ``` Adds `@kitr/toast`, its provider and `config/toast.ts`. Adds [react](/packages/react) first if missing. [Every change](/packages/toast/guide#install). ## Quick start ```tsx // app/components/save-button.tsx import { toast } from '@kitr/toast' export function SaveButton() { return ( ) } ``` Click it and a green "Invoice saved" slides in at the bottom right, then fades out after four seconds. You don't mount anything: the provider does. Next in the guide: [Show a toast](/packages/toast/guide#show-a-toast). ## Built on | Built on | Role | | --- | --- | | [sonner](https://sonner.emilkowal.ski) | Draws the toasts. | kitr adds the provider that mounts the toaster, a `toast` you can call before React has rendered, `toastError` for caught errors, and dark mode that follows [theme](/packages/theme). ## Problems it solves - **A different toast library in every project.** [Show a toast](/packages/toast/guide#show-a-toast). - **Toasts only from inside components.** [Show toasts outside React](/packages/toast/guide#show-toasts-outside-react). - **The same try/catch toast everywhere.** [Show an error](/packages/toast/guide#show-an-error). ## Further reading - [Guide](/packages/toast/guide). - [API reference](/packages/toast/api). - [sonner](https://sonner.emilkowal.ski). # Updater API reference Source: https://kitr.dev/packages/updater/api Every export of `@kitr/updater`, `@kitr/updater/react`, `@kitr/updater/provider`, `@kitr/updater/driver`, `@kitr/updater/drivers/web`, `@kitr/updater/drivers/electron`, `@kitr/updater/drivers/tauri`, `@kitr/updater/drivers/capacitor`, read from the published type definitions. The [guide](/packages/updater/guide) shows them in use. ## @kitr/updater ### createCapacitorUpdaterDriver ```ts function createCapacitorUpdaterDriver(_options?: DriverOptions): Driver; ``` Updater for the Capacitor shell on `@capawesome/capacitor-app-update`, which asks the store rather than a server of yours. Android uses Play in-app updates: a flexible update downloads in the background with progress and restarts on `apply()`, and a high-priority update (4 or 5) or a device that only allows it uses the immediate, full-screen flow. iOS cannot install in place, so `apply()` opens the App Store page. The service-worker path stays web-only. ### createElectronUpdaterDriver ```ts function createElectronUpdaterDriver(_options?: DriverOptions): Driver; ``` The updater driver for the Electron shell, on `electron-updater` in the main process (served over the `kitr:updater:*` channels of `handleKitrIpc()`). `check()` asks the update feed in `electron-builder.yml`'s `publish` block, `apply()` downloads the installer, reports progress and restarts into it with `quitAndInstall()`, and `onAvailable` fires when main finds an update on its own. `electron-updater` is an optional peer, imported by main on first use: when it is missing every method rejects with `E_DRIVER_UNAVAILABLE`. It only updates packaged apps; in development `check()` resolves to null. Throws `E_DRIVER_UNAVAILABLE` when the preload bridge is missing (no `exposeIpc()` call). ### createMemoryUpdaterDriver ```ts function createMemoryUpdaterDriver(options?: { version?: string; next?: UpdateInfo | null; }): { name: string; next: UpdateInfo | null; applied: UpdateInfo[]; check: () => Promise; apply: (onProgress?: (f: number) => void) => Promise; onAvailable(listener: (info: UpdateInfo) => void): () => void; announce(info: UpdateInfo): void; currentVersion: () => Promise; }; ``` For tests and the server: set `next` to simulate an available update. ### createServiceWorkerUpdaterDriver ```ts function createServiceWorkerUpdaterDriver(options?: { version?: string; }): UpdaterDriver; ``` Service-worker driver: an update is "available" when a new worker is waiting; applying tells it to skip waiting and reloads. `version` is the running build; the update is reported as `+`. ### createTauriUpdaterDriver ```ts function createTauriUpdaterDriver(_options?: DriverOptions): Driver; ``` The updater driver for the Tauri shell, on `@tauri-apps/plugin-updater` (find and install the signed update `tauri.conf.json`'s `plugins.updater` points at) and `@tauri-apps/plugin-process` (relaunch into it). Both are imported on first use and are optional peers: a missing one makes the method that needs it reject with `E_DRIVER_UNAVAILABLE`. `apply()` reports the download as a fraction when the server sends a content length. ### createUpdater ```ts function createUpdater(config?: UpdaterConfig, app?: KitrApp): Updater; ``` Build the `updater` service. The driver is `config.driver`, else the one registered on `app`, else `selectDriver()` for the runtime (the service-worker driver in a browser, `E_DRIVER_UNAVAILABLE` in a native shell unless `fallbackToWeb` is set). Starts the `interval` checks and the driver listener. ### selectDriver ```ts function selectDriver(runtime: Runtime, options?: DriverOptions): Driver; ``` The updater driver for a runtime: the service-worker driver on `web` (a memory driver on the server), and each shell's own updater (electron-updater, the Tauri updater plugin, store or live-update checks). A shell with no updater driver yet throws `E_DRIVER_UNAVAILABLE` rather than watching a service worker that cannot update a native app, unless `fallbackToWeb` is set. ### useUpdater ```ts function useUpdater(app: KitrApp): Updater; ``` The app's `updater` service, outside React. Same as `app.container.use('updater')`. ### defineConfig ```ts const defineConfig: (config: C) => C; ``` Types `config/updater.ts`. Returns the object unchanged. ### DriverOptions ```ts interface DriverOptions extends SelectDriverOptions { /** The running build's version, reported by the web driver as `+` when a new worker waits. */ version?: string; } ``` Options of `selectDriver()`. ### UpdateInfo ```ts interface UpdateInfo { /** Its version label, such as `2.4.0` (`new` from the service-worker driver without a version). */ version: string; /** Release notes to show next to the update. */ notes?: string; /** Release date, as the driver reports it. */ date?: string; /** Bytes, when the driver knows. */ size?: number; } ``` A version the driver found. ### Updater ```ts interface Updater { /** The active driver. */ readonly driver: UpdaterDriver; /** The current snapshot. */ readonly state: UpdaterState; /** Look for an update. Never throws: failures set `status: 'error'` and resolve to null. */ check(): Promise; /** Install the update found. Runs once however often it is called; failures set `status: 'error'`. */ apply(): Promise; /** Hide the prompt until the next update. */ dismiss(): void; /** Call `listener` after every state change; returns an unsubscribe function. */ subscribe(listener: () => void): () => void; /** Stop the interval and listeners. */ stop(): void; } ``` The `updater` service: check, apply and dismiss updates, and subscribe to the state. ### UpdaterConfig ```ts interface UpdaterConfig { /** Replace the driver the service would pick (tests, custom shells). */ driver?: UpdaterDriver; /** Re-check every N milliseconds while the app is open. Default: none (check at boot only). */ interval?: number; /** Check as soon as the app boots. Default true. */ checkOnBoot?: boolean; /** Apply as soon as an update is ready, without asking. Default false. */ auto?: boolean; /** * In a native shell (Electron, Tauri, Capacitor) with no updater driver, use the service-worker * driver inside the WebView instead of throwing `E_DRIVER_UNAVAILABLE`. Default false. */ fallbackToWeb?: boolean; } ``` Options for `config/updater.ts` and `createUpdater()`. ### UpdaterDriver ```ts interface UpdaterDriver { /** Shown in devtools, such as `service-worker` or `memory`. */ name: string; /** Look for a newer version. Resolves to the update or null when current. */ check(): Promise; /** Download (when needed) and install. Usually restarts or reloads the app. */ apply(onProgress?: (fraction: number) => void): Promise; /** Called by drivers that learn about updates on their own (service worker, background download). */ onAvailable?(listener: (info: UpdateInfo) => void): () => void; /** Current app version, when known. */ currentVersion?(): Promise; } ``` What a platform implements to find and install new versions. ### UpdaterState ```ts interface UpdaterState { /** Where the flow is. */ status: UpdateStatus; /** The update found, or null. */ update: UpdateInfo | null; /** 0 to 1 while downloading. */ progress: number; /** Why the last check or apply failed. Cleared by the next check. */ error: Error | null; /** The running version, once the driver reports it. */ currentVersion: string | null; } ``` A snapshot of the updater, replaced on every change. ### Driver ```ts type Driver = UpdaterDriver; ``` What an updater driver is: `UpdaterDriver`, whichever runtime supplies it. ### UpdateStatus ```ts /** Where the update flow is: `idle → checking → available → downloading → ready`, or `up-to-date` / `error`. */ type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'up-to-date' | 'error'; ``` Where the update flow is: `idle → checking → available → downloading → ready`, or `up-to-date` / `error`. ## @kitr/updater/react ### useUpdate ```ts function useUpdate(): { available: boolean; check: () => Promise; apply: () => Promise; dismiss: () => void; status: UpdateStatus; update: UpdateInfo | null; progress: number; error: Error | null; currentVersion: string | null; }; ``` `{ status, update, progress, check, apply, dismiss }` for an "update ready" banner. Hydration-safe: the server and first render see `idle`. ### useUpdater ```ts function useUpdater(): Updater; ``` The app's `updater` service in a component. Only bound in the browser. ## @kitr/updater/provider ### updaterProvider ```ts const updaterProvider: import("@kitr/core").KitrProvider; ``` Binds `updater` to the platform updater (`updater` capability) or the service-worker driver, checks at boot and on the configured interval, and stops at shutdown. ## @kitr/updater/driver ### selectDriver ```ts function selectDriver(runtime: Runtime, options?: DriverOptions): Driver; ``` The updater driver for a runtime: the service-worker driver on `web` (a memory driver on the server), and each shell's own updater (electron-updater, the Tauri updater plugin, store or live-update checks). A shell with no updater driver yet throws `E_DRIVER_UNAVAILABLE` rather than watching a service worker that cannot update a native app, unless `fallbackToWeb` is set. ### DriverOptions ```ts interface DriverOptions extends SelectDriverOptions { /** The running build's version, reported by the web driver as `+` when a new worker waits. */ version?: string; } ``` Options of `selectDriver()`. ### Driver ```ts type Driver = UpdaterDriver; ``` What an updater driver is: `UpdaterDriver`, whichever runtime supplies it. ## @kitr/updater/drivers/web ### createServiceWorkerUpdaterDriver ```ts function createServiceWorkerUpdaterDriver(options?: { version?: string; }): UpdaterDriver; ``` Service-worker driver: an update is "available" when a new worker is waiting; applying tells it to skip waiting and reloads. `version` is the running build; the update is reported as `+`. ## @kitr/updater/drivers/electron ### createElectronDriver ```ts function createElectronDriver(_options?: DriverOptions): Driver; ``` The updater driver for the Electron shell, on `electron-updater` in the main process (served over the `kitr:updater:*` channels of `handleKitrIpc()`). `check()` asks the update feed in `electron-builder.yml`'s `publish` block, `apply()` downloads the installer, reports progress and restarts into it with `quitAndInstall()`, and `onAvailable` fires when main finds an update on its own. `electron-updater` is an optional peer, imported by main on first use: when it is missing every method rejects with `E_DRIVER_UNAVAILABLE`. It only updates packaged apps; in development `check()` resolves to null. Throws `E_DRIVER_UNAVAILABLE` when the preload bridge is missing (no `exposeIpc()` call). ## @kitr/updater/drivers/tauri ### createTauriDriver ```ts function createTauriDriver(_options?: DriverOptions): Driver; ``` The updater driver for the Tauri shell, on `@tauri-apps/plugin-updater` (find and install the signed update `tauri.conf.json`'s `plugins.updater` points at) and `@tauri-apps/plugin-process` (relaunch into it). Both are imported on first use and are optional peers: a missing one makes the method that needs it reject with `E_DRIVER_UNAVAILABLE`. `apply()` reports the download as a fraction when the server sends a content length. ## @kitr/updater/drivers/capacitor ### createCapacitorDriver ```ts function createCapacitorDriver(_options?: DriverOptions): Driver; ``` Updater for the Capacitor shell on `@capawesome/capacitor-app-update`, which asks the store rather than a server of yours. Android uses Play in-app updates: a flexible update downloads in the background with progress and restarts on `apply()`, and a high-priority update (4 or 5) or a device that only allows it uses the immediate, full-screen flow. iOS cannot install in place, so `apply()` opens the App Store page. The service-worker path stays web-only. # Updater Guide Source: https://kitr.dev/packages/updater/guide You ship a fix, but Ada keeps a tab on `app.acme.com` open all week and never reloads it, and installed apps run the old version until someone updates. `@kitr/updater` notices when a new version is ready, tells people with a banner, and updates the app in place when they click it. > [!SUMMARY] > Tells people a new version is ready and updates the app in place on web, desktop and mobile. Add it with `kitr add updater`; the overview is at [/packages/updater](/packages/updater). It is one `check()` and `apply()` flow with one status, whatever does the updating. In the browser the driver watches the [service worker](https://developer.mozilla.org/docs/Web/API/Service_Worker_API) that the [Web](/packages/web) platform's `pwa` option builds; desktop and mobile shells plug in their own driver. kitr adds periodic checks, "remind me later", a React hook, a ready-made banner and a memory driver for tests. It runs in the browser only; to keep data fresh between versions, use [Query](/packages/query). 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. ## Install ```sh kitr add updater ```
Everything kitr add changes - Installs `@kitr/updater` and adds it and its provider to `kitrrc.ts`. - Writes `config/updater.ts` (see [Configure](#configure)). - Writes `app/components/update-banner.tsx`, a banner hidden until an update is ready (see [Show your first update banner](#show-your-first-update-banner)). - Adds a `## kitr: updater` section to `AGENTS.md`. It adds no environment variables or scripts and asks no questions. No kit includes it. Preview with `kitr add updater --dry-run`. `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: ```ts // 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 driver for the runtime | An `UpdaterDriver` that replaces the one the service would pick. For tests and custom shells. | | `fallbackToWeb` | `false` | In Electron, Tauri or Capacitor with no updater driver, use the service-worker driver inside the webview instead of throwing `E_DRIVER_UNAVAILABLE`. | The service picks its driver in this order: `driver` from the config, a driver a provider registered under `updater`, then the runtime's default: the service-worker driver in a browser, and `E_DRIVER_UNAVAILABLE` in a native shell unless `fallbackToWeb` is set. The generated file only adds the hourly `interval` to the defaults. For the browser to have something to update, turn on the service worker in the web platform. `registerType: 'prompt'` is the default and what the updater expects: the new worker waits until you apply it. ```ts // kitrrc.ts import { defineKitrConfig } from '@kitr/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: ```tsx // 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 (
Version {update?.version} is ready.
) } ``` Mount it once, in the root route: ```tsx // app/document.tsx import { HeadContent, Outlet, Scripts } from '@kitr/router' import { UpdateBanner } from '#app/components/update-banner' export function RootDocument() { return ( ) } ``` Deploy a new build while Ada has the app open. Within the hour (or at her next visit) a bar appears: "Version new is ready. **Restart to update** · Later". She clicks, 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](#show-version-numbers-on-the-web). On the server `useUpdate()` reports `idle` with nothing available, so the banner renders nothing until 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` | Look now. | | `apply` | `() => Promise` | 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. ## 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: ```tsx // app/components/settings/check-for-updates.tsx import { useUpdate } from '@kitr/updater/react' export function CheckForUpdates() { const { status, check, currentVersion } = useUpdate() return (

Acme Orders {currentVersion ?? ''}

{status === 'up-to-date' &&

You're on the latest version.

}
) } ``` When something is found, `status` becomes `available` and the root-layout banner shows it. 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. Repeat calls while an apply is running or done do nothing, so a double click installs once. ```tsx // 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 (

Version {update?.version} is ready.

{update?.notes &&

{update.notes}

}
) } ``` What "install" means depends on the driver: the service-worker driver activates the waiting worker (waiting up to two seconds) and reloads; a desktop driver downloads the installer and restarts. With no update found, `apply()` does nothing. > [!WARNING] Unsaved work is lost > Applying reloads or restarts the app. 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: ```tsx // 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 ( ) } ``` After Ada dismisses 2.4.0, the hourly check still finds it but keeps the banner hidden until 2.4.1 ships. The dismissal lives in memory, so a reload shows 2.4.0 again at the next check. ## Apply updates without asking For a kiosk or a wall display nobody touches, set `auto: true`: every update found, or announced by the driver, is applied at once: ```ts // config/updater.ts import { defineConfig } from '@kitr/updater' export default defineConfig({ checkOnBoot: true, interval: 15 * 60 * 1000, auto: true, }) ``` > [!WARNING] Keep auto off for forms > On the web, `auto: true` reloads the page 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 the running version and it reports `'2.3.0'` and `'2.3.0+'` for the waiting build. Set the driver yourself in the config: ```ts // 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](/packages/offline) adds the same variable). For real version strings, serve a small JSON file with each deploy and write a driver that reads it, as under [Extend](#extend). Without a registered worker, `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`; a later `check()` clears it: ```tsx // 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 (

Couldn't update: {error?.message}.{' '}

) } ``` ## Use it outside React Outside components, read the service from the app's container with `useUpdater(app)`: ```ts // app/lib/settings/updates-on-focus.ts import type { KitrApp } from '@kitr/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)` returns an unsubscribe function. `updater.stop()` ends the interval and the driver's listener; the provider calls it at shutdown. To build a service with no app, call `createUpdater(config)`. With [Devtools](/packages/devtools) installed, the service logs `available`, `applied` and failed checks 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`. | | Mobile shell (Capacitor) | `createCapacitorUpdaterDriver()`, the store | Android: runs Play's in-app update. iOS: opens the App Store page. | | Server | None; the provider binds nothing | Not available. | | Electron | `createElectronDriver()`: `electron-updater` | Downloads the installer, then quits and installs. | | Tauri | `createTauriDriver()`: `@tauri-apps/plugin-updater` | Downloads and installs the signed update, then relaunches. | ### Capacitor In the [Mobile](/packages/mobile) shell the service asks the store, not a server of yours, through `@capawesome/capacitor-app-update`, an optional peer that `kitr add updater` installs when the project has the mobile platform. | Call | Android | iOS | | --- | --- | --- | | `check()` | Asks Google Play. An update is available when Play reports one; `version` is its version name (or code). | Looks the app up in the App Store by its bundle id. | | `apply()` | Downloads in the background and restarts. Priority 4 and 5 updates use Play's full-screen flow. | Opens the App Store page: iOS cannot install in place. | | `currentVersion()` | The installed version name. | The installed version. | The driver sets no `onAvailable`, so the banner appears after `check()`, which `checkOnBoot` and `interval` run. Play only offers an update to an app installed from Play: a debug build or sideloaded APK always checks as up to date. ```tsx // app/components/update-banner.tsx import { useUpdate } from '@kitr/updater/react' export function UpdateBanner() { const { available, update, status, progress, apply } = useUpdate() if (!available) return null return ( ) } // Android flexible update => "Updating 40%" ... then the app restarts // iOS => opens apps.apple.com/app/id... ``` [Mobile](/packages/mobile) registers only `platform` and `storage` drivers; to update an installed mobile app any other way, register a driver as under [Extend](#extend). ## Use it in Electron and Tauri In a desktop shell the updater replaces the installed app. `check()` asks the update feed, `apply()` downloads with progress and restarts, and `currentVersion()` reads the app's version. `config/updater.ts` is unchanged. ### Electron The driver runs [`electron-updater`](https://www.electron.build/auto-update) in the main process over the `kitr:updater:*` channels that `handleKitrIpc(ipcMain, app)` serves. Nothing downloads until `apply()`. Point the feed at your release server in `electron-builder.yml`: ```yaml # platforms/app/electron-builder.yml publish: provider: generic url: https://updates.acme.com/orders/ ``` Requirements: - `electron-updater` in the app's `dependencies` (not dev), so electron-builder packs it. `kitr add desktop` and `kitr add updater` install it; when it is missing, every method rejects with `E_DRIVER_UNAVAILABLE`. - A packaged, signed build: `electron-updater` does nothing in development, and macOS only installs from a signed and notarized app. - `handleKitrIpc()` in `main.ts` and `exposeIpc()` in the preload script. ### Tauri The driver uses [`@tauri-apps/plugin-updater`](https://v2.tauri.app/plugin/updater/) and [`plugin-process`](https://v2.tauri.app/plugin/process/): `check()` returns the signed update `tauri.conf.json` points at, and `apply()` downloads, installs and relaunches. Updates are signed: generate a key pair with `tauri signer generate`, put the public key and endpoints in `tauri.conf.json`, and sign builds with the private key: ```json { "bundle": { "createUpdaterArtifacts": true }, "plugins": { "updater": { "pubkey": "", "endpoints": ["https://updates.acme.com/orders/{{target}}/{{arch}}/{{current_version}}"] } } } ``` Requirements: - `@tauri-apps/plugin-updater` and `@tauri-apps/plugin-process` in `package.json` (`kitr add tauri` and `kitr add updater` install them); a missing one rejects with `E_DRIVER_UNAVAILABLE`. - `tauri-plugin-updater` and `tauri-plugin-process` in `src-tauri/Cargo.toml`, and `updater:default` and `process:default` in `capabilities/default.json`. The Tauri scaffold writes them, and registers the updater plugin in `lib.rs` only once `plugins.updater` exists, because the plugin refuses to start without a public key. Until then `check()` sets `status: 'error'`. ## Errors | Error | When | What to do | | --- | --- | --- | | `E_DRIVER_UNAVAILABLE` (`KitrError`, `data.runtime`, `data.package`) | A native shell has no updater driver. Thrown at boot. | Register a driver, or set `fallbackToWeb: true`. | ## 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'`: ```tsx // 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(, { 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(, { 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(, { 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`: ```ts interface UpdaterDriver { name: string check(): Promise // null when current apply(onProgress?: (fraction: number) => void): Promise // fraction 0..1 onAvailable?(listener: (info: UpdateInfo) => void): () => void // for updates found in the background currentVersion?(): Promise } ``` 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: ```ts // 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, } } ``` ```ts // 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 another shell's updater, register a driver for it: `app.drivers.register('updater', driver, { platform: 'desktop' })` in a provider. The `UpdaterDriver` and `UpdateInfo` types are in the [API reference](/packages/updater/api). ## Next steps - [API reference](/packages/updater/api): every export with its signature. - [Web](/packages/web): the `pwa` option that builds the service worker. - [Desktop](/packages/desktop): the Electron shell an installed-app driver plugs into. # Updater Source: https://kitr.dev/packages/updater ## What it is `@kitr/updater` tells people a new version is ready and applies it in place. One `check()` and `apply()` flow, one status, a `useUpdate` hook and a ready-made banner. Runs in the browser only. The web driver watches the service worker; desktop and mobile plug in their own. ## Install ```sh kitr add updater ``` Adds `@kitr/updater`, its provider, `config/updater.ts` and a banner in `app/components/update-banner.tsx`. [Every change](/packages/updater/guide#install). ## Quick start `kitr add` writes this banner: ```tsx // 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 (
Version {update?.version} is ready.
) } ``` Mount it once in `app/document.tsx`. Deploy a new build and the bar appears: "Version new is ready. **Restart to update** · Later". One click reloads on the new build. Next in the guide: [Show your first update banner](/packages/updater/guide#show-your-first-update-banner). ## Built on | Where | Driver built on | | --- | --- | | Web | The [service worker](https://developer.mozilla.org/docs/Web/API/Service_Worker_API) that the [web](/packages/web) platform's `pwa` option builds with [vite-plugin-pwa](https://vite-pwa-org.netlify.app/). Applying activates the waiting worker and reloads. | | Desktop | [electron-updater](https://www.electron.build/auto-update) or Tauri's updater, through a driver the shell registers. | | Mobile | A store or over-the-air driver the shell registers. | kitr adds the status machine, periodic checks, "remind me later", the hook, the banner and the `UpdaterDriver` interface. ## Problems it solves - **Stale tabs running old code.** [Show your first update banner](/packages/updater/guide#show-your-first-update-banner). - **Update UI written per platform.** [Follow the update status](/packages/updater/guide#follow-the-update-status). - **Interrupting someone mid-task.** [Let people postpone an update](/packages/updater/guide#let-people-postpone-an-update). - **Kiosks and internal tools that should always be current.** [Apply updates without asking](/packages/updater/guide#apply-updates-without-asking). - **Updates that fail silently.** [Handle a failed update](/packages/updater/guide#handle-a-failed-update). ## Further reading - [Guide](/packages/updater/guide). - [API reference](/packages/updater/api). - [vite-plugin-pwa: prompt for update](https://vite-pwa-org.netlify.app/guide/prompt-for-update) and [electron-updater](https://www.electron.build/auto-update). - [Web](/packages/web), [Desktop](/packages/desktop) and [Offline](/packages/offline). # Uploads API reference Source: https://kitr.dev/packages/uploads/api Every export of `@kitr/uploads`, `@kitr/uploads/react`, `@kitr/uploads/server`, `@kitr/uploads/provider`, read from the published type definitions. The [guide](/packages/uploads/guide) shows them in use. ## @kitr/uploads ### accepts ```ts function accepts(type: string, name: string, accept?: string[]): boolean; ``` `image/*` matches `image/png`; exact types match themselves; `.pdf` matches by extension. ### checkFile ```ts function checkFile(file: File, rules?: UploadRules): string | null; ``` Client-side check against the same rules the server enforces, so errors show before the request. ### formatBytes ```ts function formatBytes(bytes: number): string; ``` Formats a byte count for people: `formatBytes(10 * 1024 * 1024)` is `'10.0 MB'`. ### upload ```ts function upload(files: File | File[], options?: UploadOptions): Promise; ``` Upload one or more files with progress. XMLHttpRequest is the only browser API that reports upload progress, so this is XHR with a promise around it; a fetch fallback runs elsewhere. ### UploadError ```ts class UploadError extends Error { readonly status: number; readonly body?: unknown | undefined; constructor(message: string, status: number, body?: unknown | undefined); } ``` What `upload()` rejects with: the server's message and HTTP `status` (0 for network errors and cancels), and the parsed response `body`, which carries `fieldErrors` for rule violations. ### defineConfig ```ts const defineConfig: (config: C) => C; ``` Typed identity helper for `config/uploads.ts`. ### ServedFile ```ts interface ServedFile { /** The storage key: `/`. */ key: string; /** The file name, from the key. */ name: string; /** The type the file will be served with (sniffed from its bytes, or allow-listed). */ type: string; /** Size in bytes. */ size: number; } ``` The stored file `authorize` is asked about. ### UploadAccess ```ts interface UploadAccess { /** The h3 event of the request. */ event: H3Event; /** The request. */ request: Request; /** The app for this request (a fork per request on a kitr server): `app.container.use('auth')` for the session. */ app: KitrApp; } ``` Who is asking, as `authorize` sees it. ### UploadedFile ```ts interface UploadedFile { /** The random id the file was stored under: the first segment of `key`. */ id: string; /** The original file name, as the browser sent it. */ name: string; /** Size in bytes. */ size: number; /** The MIME type the browser declared. Not sniffed from the bytes. */ type: string; /** Where it can be fetched, when the driver serves files. */ url?: string; /** The storage key the driver wrote. */ key: string; } ``` What the server returns for every stored file, and what the client resolves with. ### UploadOptions ```ts interface UploadOptions { /** The endpoint, default `/api/uploads`. */ url?: string; /** Form field name, default `file`. */ field?: string; /** Extra fields sent with the file. */ fields?: Record; /** Extra request headers, such as a CSRF token. */ headers?: Record; /** Called as bytes go out, with `percent` from 0 to 100. Only the XHR path (browsers) reports progress. */ onProgress?: (progress: { loaded: number; total: number; percent: number; }) => void; /** Aborts the upload; the promise rejects with `UploadError('Upload cancelled', 0)`. */ signal?: AbortSignal; /** Sent with credentials (cookies). Default true. */ credentials?: boolean; } ``` Options for `upload()` and `useUpload()`: where to send, extra form fields and headers, progress and cancel. ### UploadRules ```ts interface UploadRules { /** Bytes. Default 10 MiB. */ maxSize?: number; /** Allowed MIME types or patterns such as `image/*`. Default: anything. */ accept?: string[]; /** Files per request. Default 1. */ maxFiles?: number; } ``` The size, type and count limits checked in the browser (`checkFile`, `useUpload`) and on the server (`receive`). ### UploadsConfig ```ts interface UploadsConfig extends UploadRules { /** Where files go. Default: `storage/uploads` on disk (`createDiskUploadsDriver`). */ driver?: unknown; /** Public URL prefix the driver answers under, e.g. `/uploads`. */ publicPath?: string; /** * Declared types served as themselves when the file's bytes carry no signature to check (text * formats have none), always as a download except `text/plain`. Patterns such as `text/*` work. * Files with a recognised signature (images, PDF, audio, video, archives) are served with the * type their bytes show, whatever was declared; anything else is `application/octet-stream`. * Default `['text/plain', 'text/csv', 'application/json']`. */ allowTypes?: string[]; /** * Secret for signed links (`uploads.signedUrl(key, { expiresIn })`), at least 16 characters. * Set it from the environment, the same on every instance. */ secret?: string; /** * Decides who may fetch a file without a signed link. Return false (or throw) to answer 403. * Runs for every unsigned `GET`; a valid signed link skips it. Without it, and without * `requireSigned`, files are public to anyone who knows the key. */ authorize?: (access: UploadAccess, file: ServedFile) => boolean | Promise; /** Serve files only through signed links: a request without a valid signature is refused. Needs `secret`. Default false. */ requireSigned?: boolean; } ``` The shape of `config/uploads.ts`: the default rules plus where files are stored and served. ## @kitr/uploads/react ### useUpload ```ts function useUpload(options?: UploadOptions & UploadRules): { send: (input: FileList | File[] | File | null) => Promise; cancel: () => void | undefined; reset: () => void; files: UploadedFile[]; progress: number; uploading: boolean; error: Error | null; }; ``` Upload with progress from a component: ```ts const { send, progress, uploading, error } = useUpload({ accept: ['image/*'] }) send(e.target.files)} /> ``` `send` checks every file against the rules first, then uploads; it resolves with the stored files and rethrows failures. ### UseUploadState ```ts interface UseUploadState { /** The stored files from the last successful upload. */ files: UploadedFile[]; /** Percent sent, 0 to 100. */ progress: number; /** True while a request is in flight. */ uploading: boolean; /** The last failure: a plain `Error` from the client-side check, or an `UploadError` from the request. */ error: Error | null; } ``` The state `useUpload()` returns next to `send`, `cancel` and `reset`. ## @kitr/uploads/server ### createDiskUploadsDriver ```ts function createDiskUploadsDriver(options?: { dir?: string; publicPath?: string; }): UploadsDriver; ``` Files under a directory (default `storage/uploads`), loaded lazily so the browser bundle never sees node:fs. ### createMemoryUploadsDriver ```ts function createMemoryUploadsDriver(): UploadsDriver; ``` A driver that keeps files in a Map, for tests and demos. Everything is lost when the process exits. ### createUnstorageUploadsDriver ```ts function createUnstorageUploadsDriver(storage: UnstorageRawLike, options?: { base?: string; publicPath?: string; }): UploadsDriver; ``` An uploads driver over any unstorage instance, so every server instance reads and writes the same files: object storage such as S3 or R2 instead of one machine's disk. ```ts driver: createUnstorageUploadsDriver(createStorage({ driver: s3Driver({ bucket: 'acme-uploads', ... }) })) ``` Files live under `base` (default `uploads`) with their type and name kept beside them under `:meta`. `publicPath` gives the driver a `url(key)`, as with the disk driver. ### createUploads ```ts function createUploads(config?: UploadsConfig): Uploads; ``` Creates the uploads service from a config: the driver (disk under `storage/uploads` by default) and the default rules. The provider calls it with `config/uploads.ts`; call it yourself in tests. ### mountUploads ```ts function mountUploads; }; }>(server: S, path?: string): S; ``` Mount `POST ` (receive) and `GET /**` (serve) on a kitr server from `server/main.ts`. The service is resolved per request, so this can run before the app has booted. ### resolveServedType ```ts function resolveServedType(declared: string, data: Uint8Array, allow?: readonly string[]): { type: string; inline: boolean; }; ``` The type a stored file is served as, and whether a browser may show it inline: the type its bytes show (so a declared `image/png` over HTML is not an image); failing that, the declared type if allow-listed; otherwise `application/octet-stream`. ### servedFileHeaders ```ts function servedFileHeaders(type: string, name?: string, options?: ServedFileOptions): Record; ``` Headers for serving a stored file: never sniffed by the browser (`nosniff`), sandboxed, and inline only when that is safe; everything else is a download (`Content-Disposition: attachment`). Pass the bytes in `options.data` so the type comes from them, not from the uploader. ### signedUrl ```ts function signedUrl(path: string, options: SignedUrlOptions): Promise; ``` A link to `path` that works until it expires: `path?expires=&signature=`. The signature is HMAC-SHA-256 (Web Crypto) over the path and the expiry, so the link works for that file only and only until then. Anyone holding the link can fetch the file, so keep `expiresIn` short and send it only to the person it is for. ```ts signedUrl('/api/uploads/3f2a/report.pdf', { expiresIn: 300, secret }) ``` ### sniffType ```ts function sniffType(data: Uint8Array): string | undefined; ``` The content type of a file from its first bytes, for formats that have a signature; undefined for everything else (text formats, SVG, HTML and unknown binaries have none). Uploads are served with the sniffed type, never the one the uploader declared: a browser trusts whatever the server says, so a declared `image/png` over HTML bytes would otherwise run as the site. ### useUploads ```ts function useUploads(app: KitrApp): Uploads; ``` Returns the `uploads` service the provider bound, for a controller or job: `useUploads(ctx.app)`. ### verifySignedUrl ```ts function verifySignedUrl(url: URL, secret: string, now?: number): Promise; ``` Check the `expires` and `signature` query parameters of a request URL against `secret`. ### ServedFileOptions ```ts interface ServedFileOptions { /** * The file's bytes. With them the type is sniffed from the signature (PNG, JPEG, PDF, ZIP...) * and the declared type is only a fallback for allow-listed text formats; without them the * declared type is taken as given. */ data?: Uint8Array; /** Declared types that may be served as themselves when the bytes have no signature. Default `text/plain`, `text/csv`, `application/json`. */ allow?: string[]; /** Override `cache-control`. Default: public and immutable. */ cacheControl?: string; } ``` Options of `servedFileHeaders`. ### SignedUrlOptions ```ts interface SignedUrlOptions { /** Seconds the link works for. */ expiresIn: number; /** The signing secret: at least 16 characters, the same on every instance that serves the file. */ secret: string; /** The current time in ms; for tests. */ now?: number; } ``` Options for `signedUrl()`. ### StoredFile ```ts interface StoredFile { key: string; name: string; size: number; type: string; } ``` A stored file's key, original name, size and type. ### UnstorageRawLike ```ts interface UnstorageRawLike { getItemRaw(key: string): Promise; setItemRaw(key: string, value: Uint8Array): Promise; getItem(key: string): Promise; setItem(key: string, value: string): Promise; removeItem(key: string): Promise; } ``` The part of an [unstorage](https://unstorage.unjs.io) `Storage` the uploads driver uses. Any unstorage instance fits, whatever its driver: S3 and R2 (`unstorage/drivers/s3`), Azure Blob, Vercel Blob, Netlify Blobs, the filesystem. ### Uploads ```ts interface Uploads { /** Read a multipart request, apply the rules and store the files. Throws KitrError on violation. */ receive(event: H3Event, rules?: UploadRules & { field?: string; }): Promise; /** Store bytes directly (from a job, a migration). */ store(file: { name: string; type: string; data: Uint8Array; }): Promise; /** Read a stored file's bytes and type, or `undefined`. */ read(key: string): Promise<{ data: Uint8Array; type: string; } | undefined>; /** Delete a stored file through the driver. */ remove(key: string): Promise; /** * A link to a stored file that works for `expiresIn` seconds and no longer: the file's URL * under the mount path (default `/api/uploads`) with an HMAC signature. Needs `secret` in * `config/uploads.ts`. The link skips `authorize`, so hand it only to the person it is for. */ signedUrl(key: string, options: { expiresIn: number; path?: string; }): Promise; /** Answer a `GET` for a stored file under `base`: checks the signed link or `authorize`, then sends the file with safe headers. What the mounted route runs. */ serve(event: H3Event, options?: { base?: string; app?: KitrApp; }): Promise; /** Mount `POST ` (receive) and `GET /:key` (serve) on a kitr server. */ mount(server: S, path?: string): S; /** The config this service was made from. */ readonly config: UploadsConfig; /** The driver files are stored with. */ readonly driver: UploadsDriver; } ``` The uploads service, as `createUploads` returns it and `useUploads(app)` reads it from the container. ### UploadsDriver ```ts interface UploadsDriver { /** A short label, such as `disk` or `memory`. */ name: string; /** Write the bytes under `key`, keeping `type` so `get` can return it. */ put(key: string, data: Uint8Array, meta: { type: string; name: string; }): Promise; /** Read a file back, or `undefined` when the key does not exist. */ get(key: string): Promise<{ data: Uint8Array; type: string; } | undefined>; /** Delete a file. Deleting a missing key is not an error. */ delete(key: string): Promise; /** A URL for the key, when files are served (CDN, signed URL, the kitr route). */ url?(key: string): string | Promise; } ``` Where bytes go: disk, S3, R2, GCS. Keys are relative paths the driver owns. ### SignatureCheck ```ts type SignatureCheck = 'ok' | 'unsigned' | 'expired' | 'invalid'; ``` Why a signed link was refused, or `ok`; `unsigned` when the request carries no signature at all. ## @kitr/uploads/provider ### uploadsProvider ```ts const uploadsProvider: import("@kitr/core").KitrProvider; ``` Binds `uploads` on the server from `config/uploads.ts`. The server module is loaded at boot so the browser bundle of this provider never resolves node:fs. # Uploads Guide Source: https://kitr.dev/packages/uploads/guide Avatars, signed invoices, CSV imports: sooner or later an app takes files from people. `@kitr/uploads` does the whole round trip. The browser checks size and type before sending and shows progress. The server checks again, stores the bytes through a driver (disk by default, or your own for S3 and R2) and answers with a file record you save in your database. > [!SUMMARY] > File uploads with size and type limits, disk or cloud storage and browser progress. Add it with `kitr add uploads`; the overview is at [/packages/uploads](/packages/uploads). It runs on both sides: `@kitr/uploads` and `@kitr/uploads/react` in the browser, `@kitr/uploads/server` on the [server](/packages/server). It handles files people send to your app; for files on the user's own device, use [files](/packages/files). This guide follows Acme's order screen at `app.acme.com`, where Grace attaches the signed invoice PDF to order `ord_1042`. ## Install ```sh kitr add uploads ```
Everything kitr add changes - Adds the [server](/packages/server) package first if the project does not have it. - Installs `@kitr/uploads` and adds it and its provider to `kitrrc.ts`. - Writes `config/uploads.ts` (see Configure); an existing file is left alone. - Adds `mountUploads(server)` to `server/main.ts` when that file exists. It registers `POST /api/uploads` to receive files and `GET /api/uploads/**` to serve them, and raises the server's body limit on that path to match `maxSize`. - Regenerates `config/index.ts`, the config list the standalone server imports. - Adds a `## kitr: uploads` section to `AGENTS.md`. It asks no questions. Pass `--dry-run` to print the changes first, or `--no-install` to skip the package manager. No kit includes uploads. `kitr remove uploads` removes the provider, the package entry, the dependency, `config/uploads.ts` and the `AGENTS.md` section. It leaves the `mountUploads` line in `server/main.ts` and the files under `storage/uploads`.
## Configure `kitr add uploads` writes this file: ```ts // config/uploads.ts import { defineConfig } from '@kitr/uploads' export default defineConfig({ maxSize: 10 * 1024 * 1024, accept: ['image/*', 'application/pdf'], // Files go to storage/uploads and are served by mountUploads() under publicPath. Pass a driver for S3/R2. publicPath: '/api/uploads', }) ``` | Option | Default | Description | | --- | --- | --- | | `maxSize` | `10485760` (10 MiB) | Largest file in bytes. Checked per file. | | `accept` | anything | Allowed types: exact MIME types (`application/pdf`), prefixes (`image/*`) or extensions (`.pdf`, case-insensitive). | | `maxFiles` | `1` | Most files in one request. | | `driver` | disk, under `storage/uploads` | Where bytes go. See Drivers. | | `publicPath` | none | URL prefix for the `url` of each stored file, such as `/api/uploads`. Without it (and without a driver `url`), files have no `url`. | | `allowTypes` | `text/plain`, `text/csv`, `application/json` | Declared types served as themselves when the file's bytes carry no signature. See Serve files safely. | | `secret` | none | At least 16 characters; signs links from `uploads.signedUrl()`. Read it from the environment and use the same one on every instance. | | `authorize` | none | `(access, file) => boolean`: who may fetch a file without a signed link. | | `requireSigned` | `false` | Serve files only through signed links. Needs `secret`. | Uploads adds no environment variables. The disk driver's directory is relative to the process's working directory. ### Raise the server's body limit The [server](/packages/server) refuses any body over its limit (1 MiB by default) before uploads sees it. `mountUploads(server)` raises the limit for its own path by `maxSize × maxFiles` plus 64 KiB per file for multipart framing. With the generated config, a 3 MB invoice posted to `/api/uploads` reaches the upload rules, and an 11 MB one gets the uploads size message. Your own upload routes, such as `/api/orders/:orderId/invoice`, keep the 1 MiB limit and a larger file gets `413 PAYLOAD_TOO_LARGE`. Raise it for that prefix in `config/server.ts`; the longest matching prefix wins: ```ts // config/server.ts import { defineConfig } from '@kitr/server' export default defineConfig({ healthPath: '/healthz', bodyLimits: { '/api/orders': 11 * 1024 * 1024 }, }) ``` ## Upload your first file The server already accepts files at `POST /api/uploads`. With `publicPath` set as under Configure, add a component that sends a file: ```tsx // app/components/orders/invoice-upload.tsx import { useUpload } from '@kitr/uploads/react' export function InvoiceUpload() { const { send, progress, uploading, error, files } = useUpload({ accept: ['application/pdf'], maxSize: 10 * 1024 * 1024, }) return (
send(e.target.files).catch(() => {})} /> {uploading ? : null} {files[0] ? {files[0].name} : null} {error ?

{error.message}

: null}
) } ``` Grace picks `invoice-1042.pdf`. The hook checks it, posts it as `multipart/form-data`, and updates `progress` from 0 to 100. The server checks the rules again, writes `storage/uploads//invoice-1042.pdf`, and answers: ```json { "files": [ { "id": "72eb7790-7c62-4c37-9167-d9989b7ac10f", "key": "72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf", "name": "invoice-1042.pdf", "size": 48213, "type": "application/pdf", "url": "/api/uploads/72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf" } ] } ``` `files` in the hook holds that array, so the link appears; `send` returns it too. `send` rethrows failures, which is why `onChange` ends with `.catch(() => {})`: the hook already shows the error in `error`. ## Track progress and cancel `useUpload(options)` returns the state and three functions: | Field | Description | | --- | --- | | `send(input)` | Check and upload files, then resolve with the stored files. Takes a `FileList`, `File[]` or `File`. | | `cancel()` | Aborts the request in flight. `error` becomes `Upload cancelled`. | | `reset()` | Clears `files`, `progress` and `error`. | | `files` | The stored files from the last successful upload. | | `progress` | Percent sent, 0 to 100. | | `uploading` | `true` while the request is in flight. | | `error` | The last failure, or `null`. | A progress indicator with a cancel button: ```tsx // app/components/orders/attachment-upload.tsx import { formatBytes } from '@kitr/uploads' import { useUpload } from '@kitr/uploads/react' const MAX = 25 * 1024 * 1024 export function AttachmentUpload() { const upload = useUpload({ maxSize: MAX, maxFiles: 3 }) return (
upload.send(e.target.files).catch(() => {})} />

Up to 3 files, {formatBytes(MAX)} each.

{/* => "Up to 3 files, 25.0 MB each." */} {upload.uploading ? ( <> ) : null}
) } ``` Calling `send` again while an upload runs aborts the first one. The server enforces `maxFiles`; the browser does not check it. ## Upload without React `upload(files, options)` from `@kitr/uploads` is the same request without the hook, for a store, an action or plain script code: ```ts // app/lib/orders/attach-invoice.ts import { upload, UploadError } from '@kitr/uploads' export async function attachInvoice(orderId: string, file: File) { const controller = new AbortController() try { const [stored] = await upload(file, { url: `/api/orders/${orderId}/invoice`, field: 'invoice', fields: { orderId }, headers: { 'x-csrf-token': document.querySelector('meta[name=csrf]')?.content ?? '' }, onProgress: ({ loaded, total, percent }) => console.log(`${loaded}/${total} (${percent}%)`), signal: controller.signal, }) return stored } catch (error) { if (error instanceof UploadError) console.warn(error.status, error.message) throw error } } ``` | Option | Default | Description | | --- | --- | --- | | `url` | `'/api/uploads'` | The endpoint to post to. | | `field` | `'file'` | The form field the files go in. The server reads the same field. | | `fields` | none | Extra form fields sent before the files. | | `headers` | none | Extra request headers, such as a CSRF token. | | `onProgress` | none | Called with `{ loaded, total, percent }` as bytes go out. | | `signal` | none | An `AbortSignal` that cancels the upload. | | `credentials` | `true` | Send cookies with the request. `false` omits them. | In a browser, `upload` uses `XMLHttpRequest`, the only API that reports upload progress. Without it (Node, a test) it uses `fetch` and `onProgress` is not called. It resolves with the response's `files` array, or the body wrapped in an array when the endpoint returns one file record. ## Check a file before sending `checkFile(file, rules)` runs the browser half of the rules and returns a message, or `null` when the file passes. `useUpload` calls it for you; call it yourself to show the problem as soon as a file is picked: ```ts // app/lib/orders/check-invoice.ts import { checkFile } from '@kitr/uploads' const scan = new File([new Uint8Array(12 * 1024 * 1024)], 'scan.png', { type: 'image/png' }) checkFile(scan, { maxSize: 10 * 1024 * 1024 }) // => 'scan.png is larger than 10.0 MB' checkFile(new File(['png'], 'logo.png', { type: 'image/png' }), { accept: ['application/pdf'] }) // => 'logo.png is not an accepted type' checkFile(new File(['%PDF-1.7'], 'invoice-1042.pdf', { type: 'application/pdf' }), { accept: ['.pdf'] }) // => null ``` `checkFile` uses a 10 MiB `maxSize` when you pass none, like the server, and does not check `maxFiles`. `accepts(type, name, accept)` is the type test on its own, and `formatBytes(bytes)` formats sizes for hints: ```ts // app/lib/orders/accept-hint.ts import { accepts, formatBytes } from '@kitr/uploads' accepts('image/png', 'logo.png', ['image/*']) // => true accepts('application/octet-stream', 'Invoice.PDF', ['.pdf']) // => true accepts('application/pdf', 'invoice.pdf.exe', ['.pdf']) // => false formatBytes(2048) // => '2 KB' formatBytes(10 * 1024 * 1024) // => '10.0 MB' ``` ## Share rules between client and server The browser check is for people; the server check is for safety. Put the rules in one module that a component and a controller both import: ```ts // app/lib/orders/invoice-rules.ts import type { UploadRules } from '@kitr/uploads' export const invoiceRules: UploadRules = { maxSize: 10 * 1024 * 1024, accept: ['application/pdf', '.pdf'], maxFiles: 1, } ``` ```tsx // app/components/orders/invoice-field.tsx import { useUpload } from '@kitr/uploads/react' import { invoiceRules } from '#app/lib/orders/invoice-rules' export function InvoiceField({ orderId }: { orderId: string }) { const { send, error } = useUpload({ ...invoiceRules, url: `/api/orders/${orderId}/invoice` }) return ( ) } ``` The controller that receives it is in the next section. > [!WARNING] Server checks are the ones that count > Never rely on the browser check alone: anyone can post to the endpoint directly, with any size and declared type. ## Receive files in a controller `mountUploads` gives you one general endpoint. For files that belong to something, such as an order's invoice, receive them in your own [api](/packages/api) controller. `useUploads(ctx.app)` returns the service, and `receive(event, rules)` reads the multipart body, applies the rules and stores the files: ```ts // server/controllers/order-invoices-controller.ts import type { HttpContext } from '@kitr/api' import { useUploads } from '@kitr/uploads/server' import { invoiceRules } from '#app/lib/orders/invoice-rules' export default class OrderInvoicesController { async store({ app, event, params }: HttpContext<{ orderId: string }>) { const [invoice] = await useUploads(app).receive(event, { ...invoiceRules, field: 'file' }) // ... save invoice.key and invoice.name on order params.orderId in your database return { files: [invoice] } } } ``` ```ts // server/routes.ts import { defineRoutes } from '@kitr/api' import OrderInvoicesController from '#server/controllers/order-invoices-controller' export const routes = defineRoutes((router) => router.post('/api/orders/:orderId/invoice', [OrderInvoicesController, 'store']), ) ``` Rules passed to `receive` replace the config's one by one (`maxSize`, `accept`, `maxFiles`, `field` defaulting to `'file'`); the rest come from `config/uploads.ts`. Files are checked one at a time, so a batch can be partly stored when its second file breaks a rule. When a rule fails, `receive` throws a `VALIDATION` error, and the response is: ```json { "code": "VALIDATION", "message": "Validation failed", "status": 422, "fieldErrors": { "file": ["notes.txt is not an accepted type"] } } ``` The `fieldErrors` key is the field name, so a [form](/packages/forms) can show it under that input. Don't give the route a body schema in `static validation`: that reads the body before `receive` can. ## Store the record in your database `receive` and `store` return an `UploadedFile`. Save `key` (how you read or delete the file later) and `name` for display. Don't save `url`; build it from `key` when you render, so a change of `publicPath` or driver does not break old rows. | Field | Example | Description | | --- | --- | --- | | `id` | `'72eb7790-...'` | A random UUID, the first segment of `key`. | | `key` | `'72eb7790-.../invoice-1042.pdf'` | The storage key: the id, a slash, and the name made safe. | | `name` | `'invoice 1042.pdf'` | The original name, unchanged. Escape it when you render it. | | `size` | `48213` | Bytes. | | `type` | `'application/pdf'` | The type the browser declared. Not checked against the bytes. | | `url` | `'/api/uploads/72eb.../invoice-1042.pdf'` | From the driver's `url(key)`, else `publicPath + key`, else absent. | The safe name keeps letters, digits, `.`, `_` and `-`, turns slashes into `_` and anything else into `-`, and is cut to 120 characters: `../../etc/passwd` becomes `.._.._etc_passwd`, inside its own id folder. ## Store and read files from server code `store` writes bytes you already have, such as a generated PDF; `read` and `remove` complete the set: ```ts // server/jobs/archive-invoice.ts import type { KitrApp } from '@kitr/kitr/app' import { useUploads } from '@kitr/uploads/server' export async function archiveInvoice(app: KitrApp, pdf: Uint8Array) { const uploads = useUploads(app) const stored = await uploads.store({ name: 'Q3 report.pdf', type: 'application/pdf', data: pdf }) // => { id: '788c...', key: '788c.../Q3-report.pdf', name: 'Q3 report.pdf', size: 48213, type: 'application/pdf', url: '/api/uploads/788c.../Q3-report.pdf' } const file = await uploads.read(stored.key) // => { data: Uint8Array(48213), type: 'application/pdf' } await uploads.remove(stored.key) await uploads.read(stored.key) // => undefined return stored } ``` `store` does not check the rules; they are for files people send. The provider binds `uploads` on the server only, so `useUploads` in browser code throws `[kitr] No binding named "uploads"`. ## Serve files safely `mountUploads(server)` answers `GET /api/uploads/` with the stored bytes and the headers from `servedFileHeaders`. The stored type is whatever the uploader's browser declared, so the route reads the type from the bytes instead: - A known signature (PNG, JPEG, GIF, WebP, AVIF, PDF, MP4, WebM, MP3, ZIP and the like) is served as the type the bytes show. Images, audio, video and PDF are inline; archives and other binaries download. - No signature means text or an unknown format. It is served as its declared type only if that type is in `allowTypes` (default `text/plain`, `text/csv`, `application/json`), and only `text/plain` is inline. - Everything else, SVG and HTML included, is `application/octet-stream` with `Content-Disposition: attachment`. Every response also carries `X-Content-Type-Options: nosniff` and a sandboxing content security policy, so an uploaded file cannot run script as your site. `servedFileHeaders(type, name, { data })` from `@kitr/uploads/server` returns those headers for routes you write yourself. Pass the bytes in `data` so the type comes from them. For an HTML upload: ```ts // server/check_headers.ts import { servedFileHeaders } from '@kitr/uploads/server' const html = new TextEncoder().encode('') servedFileHeaders('text/html', 'page.html', { data: html }) // => { // 'content-type': 'application/octet-stream', // 'content-disposition': 'attachment; filename="page.html"', // 'x-content-type-options': 'nosniff', // 'content-security-policy': "default-src 'none'; img-src 'self'; media-src 'self'; sandbox", // 'cache-control': 'public, max-age=31536000, immutable', // } ``` `sniffType(bytes)` returns the type a file's signature shows, or `undefined`. `uploads.mount(server, path)` registers the same two routes with the same headers and body limit (see Use the service without the provider). > [!WARNING] Files are public by default > Without `secret`, `authorize` or `requireSigned`, every file is public to anyone with its key. Keys contain a random UUID, so they cannot be guessed, but links can be passed on. The next two sections close that. ## Share a file with a signed link `uploads.signedUrl(key, { expiresIn })` returns the file's URL with an expiry and an HMAC-SHA-256 signature over its path and expiry. Set `secret` in `config/uploads.ts` first: ```ts // config/uploads.ts import { defineConfig } from '@kitr/uploads' export default defineConfig({ accept: ['image/*', 'application/pdf'], publicPath: '/api/uploads', secret: process.env.UPLOADS_SECRET, requireSigned: true, }) ``` A controller then hands out five-minute links: ```ts // server/controllers/order_invoices_controller.ts import type { HttpContext } from '@kitr/api' import { KitrError } from '@kitr/kitr/app' import { useUploads } from '@kitr/uploads/server' export default class OrderInvoicesController { async link({ app, locals }: HttpContext) { if (!locals.user) throw KitrError.unauthorized() // ... look up the invoice key in your database and check the user's team const key = '72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf' return { url: await useUploads(app).signedUrl(key, { expiresIn: 300 }) } } } ``` The link looks like `/api/uploads/72eb7790-.../invoice-1042.pdf?expires=1767225900&signature=3Qk...`. It works for that file until `expires`; changing either makes the signature fail. An expired or altered link gets `403`, and so does a request with no link when `requireSigned` is on. Anyone holding a link can open the file until it expires, so keep `expiresIn` short. Responses reached through a link carry `Cache-Control: private`. `signedUrl(path, { expiresIn, secret })` and `verifySignedUrl(url, secret)` from `@kitr/uploads/server` do the same for any path. For a mount path other than `/api/uploads`, pass `path: '/files'`. ## Check who may open a file `authorize(access, file)` runs for every request without a signed link. `access` has the `event`, `request` and `app`, so it can read the session; `file` has `key`, `name`, the served `type` and `size`. Return `false` for `403`: ```ts // config/uploads.ts import { defineConfig } from '@kitr/uploads' export default defineConfig({ publicPath: '/api/uploads', authorize: ({ app }, file) => { const session = app.container.use('auth').session return !!session && file.key.startsWith(`${session.user.id}/`) }, }) ``` A valid signed link skips `authorize`, so a link is how you share a file with someone who has no account. Authorized responses carry `Cache-Control: private`. ## Serve private files To check access in your own route, read the file in a controller and answer with the safe headers: ```ts // server/controllers/order-invoices-controller.ts import type { HttpContext } from '@kitr/api' import { KitrError } from '@kitr/kitr/app' import { servedFileHeaders, useUploads } from '@kitr/uploads/server' export default class OrderInvoicesController { async show({ app, params, locals }: HttpContext<{ orderId: string }>) { if (!locals.user) throw KitrError.unauthorized() // ... look up the order and its invoice key in your database, and check the user's team const key = '72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf' const file = await useUploads(app).read(key) if (!file) throw KitrError.notFound('Invoice') return new Response(file.data as BodyInit, { headers: { ...servedFileHeaders(file.type, 'invoice-1042.pdf', { data: file.data }), 'cache-control': 'private, no-store', }, }) } } ``` `locals.user` is set by the [auth](/packages/auth) middleware. Override `cache-control`: the default lets shared caches keep the file for a year. ## Show field errors in a form A server rejection reaches the browser as an `UploadError`, with `status` and the parsed `body`, so field messages are at `body.fieldErrors`: ```tsx // app/components/orders/invoice-error.tsx import { UploadError } from '@kitr/uploads' export function InvoiceError({ error }: { error: Error | null }) { if (!error) return null if (error instanceof UploadError && error.status === 422) { const fields = (error.body as { fieldErrors?: Record }).fieldErrors return

{fields?.file?.[0] ?? error.message}

} if (error instanceof UploadError && error.status === 413) return

That file is too large.

return

{error.message}

} ``` A failed browser check in `useUpload` is a plain `Error` with the `checkFile` message, and no request is sent. ## Drivers A driver decides where bytes live. Two ship with kitr; for other storage, write one (see Extend). | Driver | Import | Use it for | | --- | --- | --- | | Disk | `createDiskUploadsDriver` | The default. One server with a persistent disk. | | Memory | `createMemoryUploadsDriver` | Tests and demos. Files vanish on restart. | | unstorage | `createUnstorageUploadsDriver` | Several servers or serverless hosts: S3, R2, Azure Blob, Vercel Blob, Netlify Blobs through [unstorage](https://unstorage.unjs.io). | | Anything else | your own `UploadsDriver` | A storage service unstorage has no driver for, or signed URLs from a CDN. | ### Disk The default when `driver` is not set; nothing to install. ```ts // config/uploads.ts import { defineConfig } from '@kitr/uploads' import { createDiskUploadsDriver } from '@kitr/uploads/server' export default defineConfig({ driver: createDiskUploadsDriver({ dir: '/var/lib/acme/uploads', publicPath: '/api/uploads' }), maxSize: 10 * 1024 * 1024, accept: ['image/png', 'image/jpeg', 'application/pdf'], }) ``` | Option | Default | Description | | --- | --- | --- | | `dir` | `'storage/uploads'` | Directory, relative to the working directory or absolute. Created on first write. | | `publicPath` | none | Gives the driver a `url(key)` that returns `publicPath/key`. | Each file is written next to a `.meta.json` sidecar with its type and name. Keys that resolve outside `dir`, or name a sidecar, throw `FORBIDDEN` (`Bad upload key`). A file without a sidecar is read as `application/octet-stream`. > [!WARNING] Ephemeral disks > On hosts with an ephemeral filesystem (most containers and serverless platforms), files under `storage/uploads` are lost on every deploy or restart. Mount a persistent volume at `dir`, or use object storage. > [!WARNING] One disk per instance > With more than one instance, each has its own disk: a file uploaded through instance A is a 404 on instance B. Use one shared volume, or the [unstorage](#unstorage) driver. ### unstorage `createUnstorageUploadsDriver` stores files through any [unstorage](https://unstorage.unjs.io) instance, so every instance reads and writes the same bucket. Install unstorage; for S3 or Cloudflare R2 the `s3` driver needs no extra package: ```sh pnpm add unstorage ``` ```ts // config/uploads.ts import { defineConfig } from '@kitr/uploads' import { createUnstorageUploadsDriver } from '@kitr/uploads/server' import { createStorage } from 'unstorage' import s3Driver from 'unstorage/drivers/s3' const bucket = createStorage({ driver: s3Driver({ accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, endpoint: process.env.S3_ENDPOINT!, bucket: 'acme-uploads', region: 'auto', }), }) export default defineConfig({ driver: createUnstorageUploadsDriver(bucket, { publicPath: '/api/uploads' }), maxSize: 10 * 1024 * 1024, accept: ['image/png', 'image/jpeg', 'application/pdf'], }) ``` Add the credentials to `.env`: ```sh S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_ENDPOINT=https://.r2.cloudflarestorage.com ``` | Option | Default | Description | | --- | --- | --- | | `base` | `'uploads'` | Second argument, `{ base }`. Files live under `:files:`, their type and name under `:meta:`. | | `publicPath` | none | Gives the driver a `url(key)` that returns `publicPath/key`. | Files are read back through the kitr route (`GET /api/uploads/:key`), so the bucket can stay private. Keys with an empty segment or `..` throw `FORBIDDEN`. See the [unstorage S3 driver docs](https://unstorage.unjs.io/drivers/s3) for its options. ### Memory ```ts // config/uploads.ts import { defineConfig } from '@kitr/uploads' import { createMemoryUploadsDriver } from '@kitr/uploads/server' export default defineConfig({ driver: createMemoryUploadsDriver(), publicPath: '/api/uploads', }) ``` The memory driver has no `url`, so `publicPath` builds the URLs. Use it in tests. ## Use the service without the provider `createUploads(config)` builds the service on its own, for a driver that imports a server-only SDK you don't want in `config/uploads.ts` (React apps bundle it for the browser too), or for a second set of rules with its own route: ```ts // server/avatars.ts import { createDiskUploadsDriver, createUploads } from '@kitr/uploads/server' export const avatars = createUploads({ driver: createDiskUploadsDriver({ dir: 'storage/avatars', publicPath: '/api/avatars' }), maxSize: 2 * 1024 * 1024, accept: ['image/png', 'image/jpeg', 'image/webp'], }) ``` ```ts // server/main.ts import { createServer } from '@kitr/server' import { config } from '../config' import rc from '../kitrrc' import { avatars } from './avatars' export const server = createServer({ providers: rc.providers, config }) // POST /api/avatars receives, GET /api/avatars/** serves with servedFileHeaders. avatars.mount(server, '/api/avatars') ``` `mount` receives with the service's own rules and serves with the safe headers. In components, point the hook at it: `useUpload({ url: '/api/avatars', accept: ['image/png', 'image/jpeg', 'image/webp'] })`. ## Errors The server throws kitr errors in the usual JSON shape. `upload()` and `useUpload` turn any non-2xx response into an `UploadError`. | Code / message | When | What to do | | --- | --- | --- | | `VALIDATION`, `Expected multipart/form-data` | The request is not a multipart form. | Send with `upload()` or a `FormData` body. | | `VALIDATION`, `No file sent` | The form has no file in the expected field. | Match `field` on both sides (default `file`). | | `VALIDATION`, `At most 1 file(s)` | More files than `maxFiles`. | Raise `maxFiles`, or send fewer. | | `VALIDATION`, `invoice.pdf is larger than 10485760 bytes` | A file is over `maxSize`. | Show the limit with `formatBytes`. | | `VALIDATION`, `notes.txt is not an accepted type` | A file fails `accept`. | Pick another file, or widen `accept`. | | `PAYLOAD_TOO_LARGE` (413) | The whole body is over the server's limit for that path. `mountUploads` sets its own path's limit from `maxSize`. | For your own routes, add the path to `bodyLimits` in `config/server.ts`. | | `NOT_FOUND`, `File not found` | `GET` for a key that does not exist. | Check the stored key. | | `FORBIDDEN`, `Bad upload key` | Disk driver: the key leaves `dir` or names a sidecar. | Use keys from `store` or `receive` only. | | `UploadError`, status `0`, `Network error during upload` | The request never completed. | Retry when online. | | `UploadError`, status `0`, `Upload cancelled` | `cancel()` or the `signal` aborted it. | Nothing; the user cancelled. | | `UploadError`, `Upload failed (502)` | A non-JSON error response, such as a proxy page. | Check the proxy or server logs. | ## Testing Use the memory driver and call the server with `server.fetch`; no port, no disk. This test posts Grace's invoice through the real route and checks the rules: ```ts // test/uploads.test.ts import { silentLogger } from '@kitr/logger' import { createServer } from '@kitr/server' import { createMemoryUploadsDriver, createUploads } from '@kitr/uploads/server' import { describe, expect, it } from 'vitest' function setup() { const uploads = createUploads({ driver: createMemoryUploadsDriver(), accept: ['application/pdf'], maxSize: 1024, publicPath: '/api/uploads', }) const server = createServer({ logger: silentLogger }) uploads.mount(server) return { uploads, server } } function post(server: { fetch(request: Request): Promise }, file: File) { const form = new FormData() form.append('file', file) return server.fetch(new Request('http://acme.test/api/uploads', { method: 'POST', body: form })) } describe('invoice uploads', () => { it('stores a PDF and serves it back', async () => { const { uploads, server } = setup() const res = await post(server, new File(['%PDF-1.7'], 'invoice 1042.pdf', { type: 'application/pdf' })) expect(res.status).toBe(200) const { files } = (await res.json()) as { files: Array<{ key: string; name: string; size: number; url: string }> } expect(files[0]).toMatchObject({ name: 'invoice 1042.pdf', size: 8 }) expect(files[0]!.key).toMatch(/\/invoice-1042\.pdf$/) expect(files[0]!.url).toBe(`/api/uploads/${files[0]!.key}`) const served = await server.fetch(new Request(`http://acme.test${files[0]!.url}`)) expect(served.headers.get('content-type')).toBe('application/pdf') expect(await served.text()).toBe('%PDF-1.7') expect(await uploads.read(files[0]!.key)).toBeDefined() }) it('rejects a file of the wrong type with a field error', async () => { const { server } = setup() const res = await post(server, new File(['hi'], 'notes.txt', { type: 'text/plain' })) expect(res.status).toBe(422) expect(await res.json()).toMatchObject({ code: 'VALIDATION', fieldErrors: { file: ['notes.txt is not an accepted type'] }, }) }) }) ``` To test code that calls `useUploads(app)`, boot an app with the provider and a memory driver from [testing](/packages/testing): ```ts // server/jobs/archive-invoice.test.ts import { createTestApp } from '@kitr/testing' import { uploadsProvider } from '@kitr/uploads/provider' import { createMemoryUploadsDriver, useUploads } from '@kitr/uploads/server' import { expect, it } from 'vitest' it('stores the generated report', async () => { const app = await createTestApp({ providers: [uploadsProvider], config: { uploads: { driver: createMemoryUploadsDriver() } }, }) const stored = await useUploads(app).store({ name: 'Q3 report.pdf', type: 'application/pdf', data: new TextEncoder().encode('%PDF'), }) expect(stored).toMatchObject({ name: 'Q3 report.pdf', size: 4 }) expect(stored.key).toMatch(/\/Q3-report\.pdf$/) }) ``` On the client, `upload()` falls back to `fetch` without `XMLHttpRequest`, so a Node test can replace `globalThis.fetch` with `(url, init) => server.fetch(new Request('http://acme.test' + url, init))`. ## Extend Any storage is a driver: an object with `put`, `get`, `delete` and optionally `url`. Keys are relative paths such as `72eb.../invoice-1042.pdf` that the driver owns. ```ts // node_modules/@kitr/uploads/dist/server.d.ts (excerpt) export interface UploadsDriver { name: string put(key: string, data: Uint8Array, meta: { type: string; name: string }): Promise get(key: string): Promise<{ data: Uint8Array; type: string } | undefined> delete(key: string): Promise url?(key: string): string | Promise } ``` An S3 driver with the [AWS SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3/); `endpoint` makes it work with Cloudflare R2 and other S3-compatible stores: ```sh pnpm add @aws-sdk/client-s3 ``` ```ts // server/uploads/s3-driver.ts import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3' import type { UploadsDriver } from '@kitr/uploads/server' export function createS3UploadsDriver(options: { bucket: string; endpoint?: string; publicUrl?: string }): UploadsDriver { const s3 = new S3Client({ endpoint: options.endpoint, region: process.env.AWS_REGION ?? 'auto' }) return { name: 's3', async put(key, data, meta) { await s3.send(new PutObjectCommand({ Bucket: options.bucket, Key: key, Body: data, ContentType: meta.type })) }, async get(key) { try { const object = await s3.send(new GetObjectCommand({ Bucket: options.bucket, Key: key })) const data = await object.Body!.transformToByteArray() return { data, type: object.ContentType ?? 'application/octet-stream' } } catch (error) { if ((error as { name?: string }).name === 'NoSuchKey') return undefined throw error } }, async delete(key) { await s3.send(new DeleteObjectCommand({ Bucket: options.bucket, Key: key })) }, url: options.publicUrl ? (key) => `${options.publicUrl}/${key}` : undefined, } } ``` Register it with a service created in server code, so the SDK stays out of the browser bundle: ```ts // server/uploads/index.ts import { createUploads } from '@kitr/uploads/server' import { createS3UploadsDriver } from './s3-driver' export const uploads = createUploads({ driver: createS3UploadsDriver({ bucket: 'acme-uploads', endpoint: process.env.S3_ENDPOINT, publicUrl: 'https://files.acme.com', }), maxSize: 10 * 1024 * 1024, accept: ['image/png', 'image/jpeg', 'application/pdf'], }) ``` Then call `uploads.mount(server)` in `server/main.ts` instead of `mountUploads(server)`. With `url` set, stored files link straight to the bucket's public domain and the `GET` route is only a fallback. See [`UploadsDriver`](/packages/uploads/api) in the API reference. ## Next steps - [API reference](/packages/uploads/api): every export with its signature. - [Server](/packages/server): `createServer`, `bodyLimits` and the error shape. - [Forms](/packages/forms): show `fieldErrors` under the file input. # Uploads Source: https://kitr.dev/packages/uploads ## What it is `@kitr/uploads` takes files from people. In the browser, `useUpload` checks size and type, then sends with progress. On the server, `mountUploads` checks again, stores the bytes through a driver and answers with a record for your database. Runs on both sides. For files on the user's own device, use [files](/packages/files). ## Install ```sh kitr add uploads ``` Adds `@kitr/uploads`, its provider, `config/uploads.ts` and `mountUploads(server)` in `server/main.ts`, which serves `POST` and `GET /api/uploads`. Adds [server](/packages/server) first if missing. [Every change](/packages/uploads/guide#install). ## Quick start The server already accepts files. Add a component that sends one: ```tsx // app/features/orders/invoice-upload.tsx import { useUpload } from '@kitr/uploads/react' export function InvoiceUpload() { const { send, progress, uploading, error, files } = useUpload({ accept: ['application/pdf'], maxSize: 10 * 1024 * 1024, }) return (
send(e.target.files).catch(() => {})} /> {uploading ? : null} {files[0] ? {files[0].name} : null} {error ?

{error.message}

: null}
) } ``` Grace picks `invoice-1042.pdf`. The hook checks it against the rules, posts it as `multipart/form-data` and updates `progress` from 0 to 100. The server checks the rules again, writes the file to `storage/uploads//invoice-1042.pdf`, and answers: ```json { "files": [ { "id": "72eb7790-7c62-4c37-9167-d9989b7ac10f", "name": "invoice-1042.pdf", "size": 48213, "type": "application/pdf", "url": "/api/uploads/72eb7790-7c62-4c37-9167-d9989b7ac10f/invoice-1042.pdf" } ] } ``` The link appears from `files`. A file over 10 MB, or not a PDF, is refused before a byte is sent, and `error` says why. Next in the guide: [Upload your first file](/packages/uploads/guide#upload-your-first-file). ## Built on kitr adds the shared rules, progress and cancellation, the hook, the error shape, the file record, safe serving headers and the driver interface. ## Problems it solves - **Uploads with no progress.** [Track progress and cancel](/packages/uploads/guide#track-progress-and-cancel). - **Size and type checked on one side only.** [Share rules between client and server](/packages/uploads/guide#share-rules-between-client-and-server). - **Files with no record.** [Store the record in your database](/packages/uploads/guide#store-the-record-in-your-database). - **Serving uploads unsafely.** [Serve files safely](/packages/uploads/guide#serve-files-safely). - **Errors in a generic alert.** [Show field errors in a form](/packages/uploads/guide#show-field-errors-in-a-form). - **Disk in development, object storage in production.** [Drivers](/packages/uploads/guide#drivers). ## Further reading - [Guide](/packages/uploads/guide). - [API reference](/packages/uploads/api). - [FormData on MDN](https://developer.mozilla.org/en-US/docs/Web/API/FormData). - [Server](/packages/server), [API](/packages/api) and [Forms](/packages/forms). - [Files](/packages/files). # Vite API reference Source: https://kitr.dev/packages/vite/api Every export of `@kitr/vite`, `@kitr/vite/client`, read from the published type definitions. The [guide](/packages/vite/guide) shows them in use. ## @kitr/vite ### configEnables ```ts function configEnables(configDir: string, name: string): boolean; ``` Static read of `config/.ts`: does it set `enabled` to anything but `false`? A literal `true` turns the package on; an expression (`process.env.DEBUG === '1'`) is decided at runtime, so the code has to ship for it. Absent or `false`: no. ### crawlFilter ```ts function crawlFilter(): (page: { path: string; sitemap?: { exclude?: boolean; }; }) => boolean; ``` Start's crawler treats `/docs/a#install` and `/docs/a` as two pages: it renders the same file once per anchor and lists every anchor in the sitemap. This keeps one page per path: a hash is dropped, and a path already taken is skipped and left out of the sitemap. ### finishPrerender ```ts function finishPrerender(options: { guard?: PrerenderGuard; clientDir: string; serverEntry: string; notFoundPage?: boolean; logger: { warn(msg: string): void; error(msg: string): void; }; }): Promise; ``` After Start's prerender: print one line per page that failed (`[prerender] 404 at /x, linked from /y`), with the status asked again of the built server, and fail the build when a listed route failed or `failOnError` is on. A static site also gets `404.html`: the root's `notFoundComponent`, rendered for a path no route matches, which Cloudflare Pages, Netlify and GitHub Pages serve for unknown paths. It is written here because Start's prerender refuses a page that answers 404, and so it is never crawled or listed in the sitemap. ### kitr ```ts function kitr(options?: KitrPluginOptions): PluginOption[]; ``` The kitr() Vite plugin. It reads `kitrrc.ts` and the installed packages' `vite` declarations at build time and: - wires TanStack Start (or the router plugin) and every plugin a package declares (React, Tailwind), - exposes `virtual:kitr/providers` and `virtual:kitr/config` as static imports, - pre-declares `optimizeDeps` from the packages' lists so dev start never rediscovers, - sets the active platform's port, outDir and the `#app`-style aliases. ### prerenderGuard ```ts function prerenderGuard(options: { routes: string[]; failOnError?: boolean; }): PrerenderGuard; ``` Start's prerender throws on the first page that does not answer 200, and the build dies with a dump of the `Response`. Start runs with `failOnError: false` instead, and this keeps the record: `filter` (crawlFilter, plus a note of every page asked for) holds each page out of the sitemap, `onSuccess` puts it back and notes which page links where, and `failures()` is the difference, for `finishPrerender` to print and fail on. ### readPrerender ```ts function readPrerender(root: string, configDir: string): { pages?: Array<{ path: string; }>; prerender?: Record; sitemap?: Record; failOnError?: boolean; }; ``` Static read of `config/seo.ts`: `prerender: ['/a']` or `{ routes: [...], crawlLinks }` becomes Start's `pages` + `prerender`, and `site.url` turns on the sitemap. `failOnError: true` comes back beside them, for `prerenderGuard`. No execution needed. ### readRc ```ts function readRc(root: string): RcInfo; ``` Static read of kitrrc.ts: `import('...')` specs under `providers:` and the `packages` list. No execution needed. ### readRcPlatforms ```ts function readRcPlatforms(src: string): Record; ``` Static read of `platforms: { site: web({ mode: 'ssr' }) }`: names, factories and literal options. ### robotsFor ```ts function robotsFor(root: string, configDir?: string): Promise; ``` The robots.txt for a project: `robotsTxt()` from ### KitrPluginOptions ```ts interface KitrPluginOptions { /** Project root. Default: Vite's root. */ root?: string; /** Use TanStack Start (SSR, prerender, server functions). Default: when an installed package asks for it (`router` does). */ start?: boolean | Record; /** * Leave out the Vite plugin a package declares, by its short name: `{ tailwind: false }` skips * `@kitr/tailwind/vite`, `{ react: false }` skips `@vitejs/plugin-react`. Default: every declared plugin. */ plugins?: Record; /** Route files directory. Default `app/routes`. */ routesDirectory?: string; generatedRouteTree?: string; /** Extra options for @tanstack/router-plugin. */ router?: Record; /** * Include `@kitr/devtools/provider`. Default: always in development; in production builds only * when `config/devtools.ts` sets `enabled`. */ devtools?: boolean; /** Which platform from kitrrc.ts this build is for. Default: `KITR_PLATFORM` (set by `kitr dev|build `), else the first web platform. */ platform?: string; /** * Run `kitr types` when the build starts and again in dev whenever a watched file changes, so * `.kitr/` is always current. Needs `@kitr/cli` installed (it is, in every kitr project). * Default true. */ types?: boolean; } ``` Options for `kitr()` in `vite.config.ts`. All optional: the rest comes from `kitrrc.ts`. ### PrerenderFailure ```ts interface PrerenderFailure { /** The page's path, without its `#hash`. */ path: string; /** The first page whose HTML linked to it; missing for a page only the config lists. */ from?: string; /** Listed in `config/seo.ts` `prerender.routes` (or `/` on a static site), not crawled. */ listed: boolean; } ``` A page the prerender asked for and did not write. ### PrerenderGuard ```ts interface PrerenderGuard { /** Start's `prerender.filter`: drops anchors and repeats, and notes every page it lets through. */ filter(page: { path: string; sitemap?: { exclude?: boolean; }; }): boolean; /** Start's `prerender.onSuccess`: notes the page as written and which pages it links to. */ onSuccess(result: { page: { path: string; sitemap?: { exclude?: boolean; }; }; html: string; }): void; /** Pages that passed the filter and were never written. */ failures(): PrerenderFailure[]; /** Fail the build for crawled pages too (`prerender.failOnError` in `config/seo.ts`). */ failOnError: boolean; } ``` The `filter` and `onSuccess` hooks `prerenderGuard()` hands Start, and what they saw. # Vite Guide Source: https://kitr.dev/packages/vite/guide A browser app needs a dev server, a bundler and a build step, and every package you add wants a piece of that setup: a React transform, a Tailwind compiler, a route generator. `@kitr/vite` is one plugin, `kitr()`, that reads `kitrrc.ts` and asks each installed package what it needs, so `vite.config.ts` stays a three-line file however much the app grows. > [!SUMMARY] > One Vite plugin that wires the dev server and build to `kitrrc.ts`. Add it with `kitr add vite`; the overview is at [/packages/vite](/packages/vite). It is built on [Vite](https://vite.dev) and adds the `virtual:kitr/providers` and `virtual:kitr/config` modules, the `#app`, `#config`, `#components`, `#server` and `#kitr` aliases, the active platform's port and output directory, and `kitr types` on every build. It runs at build time only. Every browser platform (`web`, `desktop`, `tauri`, `mobile`) requires it, so `kitr add web site` brings it in; a pure backend never installs it. The examples build Acme's order screens at `app.acme.com`. ## Install ```sh kitr add vite ```
Everything kitr add changes - Adds `@kitr/vite` and `vite` to `devDependencies` and installs them. Pass `--no-install` to skip the install. - Adds `'@kitr/vite'` to `packages` in `kitrrc.ts`. It registers no provider. - Writes `vite.config.ts` with `kitr()` as its only plugin, unless one exists. - Adds `vite/client` and `@kitr/vite/client` to `compilerOptions.types` in `tsconfig.json`, and `vite.config.ts` to `include`, so `import.meta.env`, asset imports and the virtual modules type-check. - Adds a `## kitr: vite` section to `AGENTS.md`. You rarely run it yourself: `kitr add web site`, `kitr add desktop app`, `kitr add tauri app` and `kitr add mobile phone` require it, and every kit with a browser platform includes it.
## Configure `@kitr/vite` has no config file. The file it writes is the whole configuration: ```ts // vite.config.ts import { kitr } from '@kitr/vite' import { defineConfig } from 'vite' export default defineConfig({ plugins: [kitr()], }) ``` `kitr()` takes options for the cases where the defaults are wrong: | Option | Default | Description | | --- | --- | --- | | `root` | Vite's root | The project directory holding `kitrrc.ts`. | | `platform` | `KITR_PLATFORM`, then the first web platform | Which platform from `kitrrc.ts` this build is for. `kitr dev ` and `kitr build ` set the variable. | | `start` | what the packages ask (`router` asks for it) | `false` keeps the plain TanStack Router plugin without Start; an object passes options to `tanstackStart()`. | | `plugins` | every declared plugin | `{ tailwind: false }` leaves out the plugin a package declares, by its short name. | | `router` | `{}` | Extra options for `@tanstack/router-plugin`. | | `routesDirectory` | `directories.routes` | Where route files are, in projects that use file routes. | | `generatedRouteTree` | `/routeTree.gen.ts`, or `.kitr/routeTree.gen.ts` with the table | Where TanStack's generator writes its tree. Unused by a table project; `createKitrRouter({ routes })` builds the tree. | | `devtools` | on, except in production builds without `enabled` in `config/devtools.ts` | `false` leaves every development-only provider out of this build too. `true` keeps them in production. | | `types` | `true` | Run `kitr types` on build start and on changes in dev. | The platform in `kitrrc.ts` sets the rest. `web({ port: 3000 })` becomes `server.port` with `strictPort`, and `kitr build site` writes `dist/site`: ```ts // kitrrc.ts import { defineKitrConfig } from '@kitr/kitr/app' import { web } from '@kitr/web' export default defineKitrConfig({ packages: ['@kitr/vite', '@kitr/react', '@kitr/router'], providers: [], platforms: { site: web({ mode: 'ssr', port: 3000 }) }, }) ``` ## Run your first dev server With `kitr add web site` done, the app object imports its providers and config from the virtual modules and the dev server serves it: ```ts // app/app.ts import { createApp } from '@kitr/kitr/app' import { config } from 'virtual:kitr/config' import { providers } from 'virtual:kitr/providers' import { env } from './env' export const app = createApp({ providers, config, env }) ``` ```sh kitr dev site ``` The browser opens `http://localhost:3000` with the pages from `app/routes.ts`. `virtual:kitr/providers` is the `providers` list from `kitrrc.ts` as static imports, so every provider is in the bundle and tree-shakable. `virtual:kitr/config` is every file under `config/` keyed by name (`config/query.ts` becomes `config.query`). Both reload when `kitrrc.ts` changes. ## Let a package configure the build A package tells the plugin what it needs through the `vite` field of its setup module, so adding the package is the whole setup. `@kitr/tailwind` declares its plugin and dependencies to pre-bundle; `@kitr/server` keeps `config/server.ts` out of the browser bundle; `@kitr/devtools` drops its provider from production builds; `@kitr/router` turns TanStack Start on: ```ts // packages/tailwind/src/kitr.ts (excerpt) export default definePackage({ name: 'tailwind', vite: { plugin: '@kitr/tailwind/vite', optimizeDeps: ['clsx', 'tailwind-merge', 'class-variance-authority'], }, }) ``` | Need | What the plugin does | | --- | --- | | `plugin` | Imports the module from the project and adds its default export's result to `plugins`. | | `optimizeDeps` | Adds the names that `package.json` installs to `optimizeDeps.include`. | | `serverOnlyConfig` | Leaves `config/.ts` out of `virtual:kitr/config` in browser bundles. | | `start` | Renders routes through TanStack Start instead of the router plugin alone. | | `provider: { production: false }` | Leaves the package's provider out of production builds. | Your own package does the same: see [the package contract](/docs/package-contract). ## Pick the platform to build A project with two browser platforms, say a web site and a desktop shell, builds each with its own port and output directory. `kitr dev ` and `kitr build ` set `KITR_PLATFORM`: ```ts // kitrrc.ts (excerpt) platforms: { site: web({ mode: 'ssr', port: 3000 }), app: desktop({ port: 3100 }), }, ``` ```sh kitr dev site # Vite on :3000, dist/site kitr dev app # Vite on :3100 inside the Electron window, dist/app kitr build # both, one after the other ``` Running `vite` directly picks the first web platform. For another without the CLI, set `KITR_PLATFORM=app vite build` or `kitr({ platform: 'app' })` in a second config file. ## Import through the aliases The `directories` block in `kitrrc.ts` names where the app lives and the plugin turns each entry into an alias, so moving `app/` to `src/` is one edit. `tsconfig.json` carries the same paths for the editor: ```ts // app/pages/orders.tsx import { OrderList } from '#components/order-list' import { ordersQuery } from '#app/queries/orders' import { Orders } from '#kitr/actions' import type { ServerRoutes } from '#server/routes' ``` | Alias | Directory | | --- | --- | | `#app` | `directories.app`, default `app` | | `#config` | `directories.config`, default `config` | | `#components` | `directories.components`, default `app/components` | | `#server` | `directories.server`, default `server` | | `#kitr` | `.kitr`, the generated types | ## Change the port or the base path Change platform options in `kitrrc.ts`, not in `vite.config.ts`, where the plugin would overwrite them: ```sh kitr config site port 4000 kitr config site base /orders/ ``` `kitr dev site --port 4000` and `KITR_SITE_PORT=4000` override the option for one run. `strictPort` is set, so a port taken by another process fails fast instead of drifting. ## Prerender pages In `static` mode every route is prerendered at build time. In `ssr` mode the [seo](/packages/seo) package's `prerender` list picks the routes to prerender while the rest render per request. The plugin reads `config/seo.ts` statically and passes the list to TanStack Start: ```ts // config/seo.ts import { defineConfig } from '@kitr/seo' export default defineConfig({ site: { url: 'https://app.acme.com' }, prerender: ['/', '/pricing', '/docs'], }) ``` ```sh kitr build site # dist/site/client/index.html, pricing/index.html, docs/index.html, sitemap.xml ``` With `site.url` set, Start writes `sitemap.xml` from the same list. A route that reads request data at render time stays out of the list. ## Pass options to TanStack Start `start` takes an object when the defaults are not enough, spread last so it wins: ```ts // vite.config.ts import { kitr } from '@kitr/vite' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ kitr({ start: { prerender: { enabled: true, crawlLinks: true } }, router: { quoteStyle: 'single', semicolons: false }, }), ], }) ``` `router` goes to `@tanstack/router-plugin`; `start` to `tanstackStart()`. See the [TanStack Start configuration](https://tanstack.com/start/latest/docs/framework/react/guide/configuration) for the full list.
Route files and the generated tree With the route table, the generator only sees a stub root in `.kitr/routes/` (written by the plugin, safe to delete) and the real tree comes from `app/routes.ts`. A project with `app/routes/__root.tsx` keeps file routes and `app/routeTree.gen.ts`.
## Keep generated types current `kitr types` writes `.kitr/`: platform types from `kitrrc.ts`, action names from `app/actions/`, and whatever each package's generator produces. The plugin runs it at build start, and in dev reruns only the generator whose watched files changed: ```sh kitr dev site # ✔ kitr types: 2 file(s) written (platforms, actions) # app/actions/orders.ts changed → actions regenerated ``` ```ts // app/queries/orders.ts import { Orders } from '#kitr/actions' // typed from app/actions/orders.ts, always current export const ordersQuery = Orders.list ``` Pass `types: false` to run `kitr types` yourself, for example in a CI job that builds without the CLI. ## Add a Vite plugin of your own `kitr()` returns a list, so it sits next to any other plugin: ```ts // vite.config.ts import { kitr } from '@kitr/vite' import { visualizer } from 'rollup-plugin-visualizer' import { defineConfig } from 'vite' export default defineConfig({ plugins: [kitr(), visualizer({ filename: 'dist/stats.html' })], }) ``` To replace a plugin a package declares, leave it out by name and add your own: `kitr({ plugins: { tailwind: false } })` with `tailwindcss()` beside it. ## Read the build target in app code `import.meta.env.KITR_PLATFORM` is the platform this bundle was built for, `KITR_PLATFORM_TYPE` its type and `KITR_PACKAGES` the packages from `kitrrc.ts`, all typed by `@kitr/vite/client`: ```ts // app/components/shell.tsx if (import.meta.env.KITR_PLATFORM_TYPE === 'desktop') { // the Electron window has its own title bar } ``` For runtime questions (online, foreground, the OS) use [platform](/packages/platform) instead; these constants are fixed at build time. ## Testing Vitest runs the same plugin, so tests import `virtual:kitr/*`, `#app/*` and route files as the app does. The `vitest.config.ts` from `kitr add testing` leaves Tailwind out, because tests check classes, not CSS: ```ts // vitest.config.ts import { kitr } from '@kitr/vite' import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [kitr({ plugins: { tailwind: false } })], test: { environment: 'happy-dom', include: ['app/**/*.test.{ts,tsx}', 'test/**/*.test.{ts,tsx}'], }, }) ``` ```ts // test/orders.test.ts import { app } from '#app/app' import { expect, it } from 'vitest' it('wires the providers from kitrrc.ts', async () => { await app.boot() expect(app.status).toBe('booted') }) ``` ## Next steps - [API reference](/packages/vite/api): every export with its signature. - [Web platform](/packages/web): modes, PWA and `kitr dev site`. - [Testing](/packages/testing): `createTestApp` and `renderWithKitr`. # Vite Source: https://kitr.dev/packages/vite ## What it is `@kitr/vite` is the build layer. The `kitr()` plugin reads `kitrrc.ts`, picks the platform being built, adds every package's Vite settings, the `virtual:kitr/*` modules and the `#app`-style aliases, and runs `kitr types`. Runs at build time only. Every browser platform requires it; a pure backend does not. ## Install ```sh kitr add vite ``` Adds `@kitr/vite` and Vite, `vite.config.ts` with `kitr()` as its only plugin, and the client types in `tsconfig.json`. Every kit with a browser platform includes it. [Every change](/packages/vite/guide#install). ## Quick start The whole Vite config is one plugin: ```ts // vite.config.ts import { kitr } from '@kitr/vite' import { defineConfig } from 'vite' export default defineConfig({ plugins: [kitr()], }) ``` It reads `kitrrc.ts` and serves two virtual modules, which `app/app.ts` imports: ```ts // app/app.ts import { createApp } from '@kitr/kitr/app' import { config } from 'virtual:kitr/config' import { providers } from 'virtual:kitr/providers' import { env } from './env' export const app = createApp({ providers, config, env }) ``` Run `kitr dev site`. `virtual:kitr/providers` is the provider list from `kitrrc.ts`; `virtual:kitr/config` is every file under `config/`. Change `kitrrc.ts` and both reload. Next in the guide: [Run your first dev server](/packages/vite/guide#run-your-first-dev-server). ## Built on kitr adds the `kitr()` plugin, the virtual modules, the aliases, platform selection and type generation. ## Problems it solves - **A growing `vite.config.ts`.** [Let a package configure the build](/packages/vite/guide#let-a-package-configure-the-build). - **Several platforms, several configs.** [Pick the platform to build](/packages/vite/guide#pick-the-platform-to-build). - **Relative import paths.** [Import through the aliases](/packages/vite/guide#import-through-the-aliases). - **Generated types going stale.** [Keep generated types current](/packages/vite/guide#keep-generated-types-current). - **Your own plugin beside kitr's.** [Add a Vite plugin of your own](/packages/vite/guide#add-a-vite-plugin-of-your-own). ## Further reading - [Guide](/packages/vite/guide). - [API reference](/packages/vite/api). - [Vite documentation](https://vite.dev/guide/). - [The package contract](/docs/package-contract). - [Web](/packages/web). # Web API reference Source: https://kitr.dev/packages/web/api Every export of `@kitr/web`, `@kitr/web/client`, `@kitr/web/provider`, `@kitr/web/vite`, read from the published type definitions. The [guide](/packages/web/guide) shows them in use. ## @kitr/web ### pwaManifest ```ts function pwaManifest(options: WebOptions, appName: string): { name: string; short_name: string; description: string | undefined; theme_color: string; background_color: string; display: "standalone" | "fullscreen" | "minimal-ui" | "browser"; start_url: string; icons: { src: string; sizes: string; type?: string; purpose?: string; }[]; }; ``` The manifest vite-plugin-pwa gets, from `web({ pwa })` and the app name. ### startWeb ```ts function startWeb(ctx: PlatformContext, options: { mode: WebMode; port: number; host: string; base?: string; }): Promise; ``` Serve a built web platform: the Start server bundle for `ssr`, static files otherwise. ### staticFiles ```ts function staticFiles(serveStatic: (options: { dir: string; maxAge?: number; immutable?: boolean; }) => unknown, dir: string, base?: string): StaticHandler; ``` Static files for a built web platform, under the platform's `base`. Vite's hashed files in `assets/` never change, so they are cached for a year; everything else (`index.html`, `sw.js`, the manifest, `public/` files) keeps its name across deploys and is revalidated on every load, or users would be stuck on an old release. ### web ```ts const web: import("@kitr/core").PlatformFactory; ``` The web platform. List it in kitrrc.ts under `platforms`: ```ts platforms: { site: web({ mode: 'ssr', pwa: true, port: 3000 }) } ``` `kitr dev site` starts Vite, `kitr build site` writes `dist/site`, `kitr start site` serves it. ### PwaOptions ```ts interface PwaOptions { /** Full app name in the install dialog. Default: the app name from package.json. */ name?: string; /** Name under the home-screen icon. Default: `name`, then the app name. */ shortName?: string; /** Manifest description. */ description?: string; /** Browser UI color. Default `#0f172a`. */ themeColor?: string; /** Splash screen background. Default `#ffffff`. */ backgroundColor?: string; /** How the installed app opens. Default `standalone`. */ display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser'; /** Page the installed app opens on. Default `/`. */ startUrl?: string; /** Manifest icons. Default `/icons/icon-192.png` and `/icons/icon-512.png` (maskable). */ icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string; }>; /** `autoUpdate` reloads on a new version; `prompt` lets `useServiceWorker()` show a banner. Default `prompt`. */ registerType?: 'autoUpdate' | 'prompt'; /** Enable the service worker in `kitr dev`. Default false. */ devEnabled?: boolean; /** Raw vite-plugin-pwa options merged last. */ plugin?: Record; } ``` The manifest and service worker settings of an installable web platform, passed as `web({ pwa: { ... } })`. ### WebOptions ```ts interface WebOptions { /** * `ssr`: TanStack Start renders on the server (needs a Node-ish host). * `static`: every route is prerendered at build time to plain HTML, served from any CDN. * `spa`: one shell, client rendering, served from any static host. Default `spa`. */ mode: WebMode; /** Dev server and production port. Default 3000. */ port: number; /** Interface to bind. Default: Vite's (localhost) in dev, `0.0.0.0` for `kitr start`. */ host?: string; /** Installable app: manifest and service worker. `true` uses the app name; an object sets the manifest. */ pwa: boolean | PwaOptions; /** Public base path, for apps served under a sub-path. Default `/`. */ base: string; /** Open the browser on `kitr dev`. Default false. */ open: boolean; } ``` Options of the web platform, as written in `web({ ... })` in kitrrc.ts and edited by `kitr config `. ### WebMode ```ts type WebMode = 'static' | 'spa' | 'ssr'; ``` How a web platform renders: prerendered HTML (`static`), a client-rendered shell (`spa`) or per-request server rendering (`ssr`). ## @kitr/web/client ### useInstallPrompt ```ts function useInstallPrompt(): { canInstall: boolean; installed: boolean; install: () => Promise<"unavailable" | "accepted" | "dismissed">; }; ``` Captures the browser's install prompt so the app can show its own button. `canInstall` is false on browsers that do not fire the event (Safari) and after install. ### useServiceWorker ```ts function useServiceWorker(options?: { path?: string; onNeedRefresh?: () => void; }): { needRefresh: boolean; offlineReady: boolean; update: () => void; registration: ServiceWorkerRegistration | null; }; ``` Registers the service worker vite-plugin-pwa emits (`/sw.js`) and reports a waiting update. With `registerType: 'prompt'`, call `update()` to activate it and reload. ## @kitr/web/provider ### webProvider ```ts const webProvider: import("@kitr/core").KitrProvider; ``` Runtime provider of the web platform. Registers the `platform` driver that tells `@kitr/platform` the app runs in a browser (or on the server rendering for one). ## @kitr/web/vite ### kitrPwa ```ts function kitrPwa(options: WebOptions, appName: string): Promise; ``` vite-plugin-pwa configured from `web({ pwa })`. The kitr() Vite plugin adds it when the running web platform has `pwa` on; call it yourself only for a custom Vite setup. # Web Guide Source: https://kitr.dev/packages/web/guide 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. > [!SUMMARY] > The web platform: static, single-page or server-rendered, installable if you want. Add it with `kitr add web site`; the overview is at [/packages/web](/packages/web). It is built on [Vite](https://vite.dev) and [TanStack Start](https://tanstack.com/start/latest), with [vite-plugin-pwa](https://vite-pwa-org.netlify.app) 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](/packages/mobile), [Desktop](/packages/desktop) (Electron) or [Tauri](/packages/tauri); for an API with no UI use [Server](/packages/server). The examples build the Acme orders app at `app.acme.com`. ## Install Add a web platform named `site`: ```sh kitr add web site ``` `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](/packages/router) and [React](/packages/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`: ```sh kitr add web site --mode ssr --pwa --port 3000 ``` | 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: ```sh kitr new acme-orders --kit app --mode ssr --pwa --port 3000 ``` `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`: ```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](https://vite-pwa-org.netlify.app/guide/), merged last. | > [!WARNING] > Write options as literals. The `kitr()` Vite plugin reads `kitrrc.ts` as text, so `web({ mode: siteMode })` reaches the CLI but not the build. 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_