# Server actions API 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) shows them in use. ## kitr/actions Re-exports `@tanstack/start-client-core` unchanged, plus the following. ### defineMutation ```ts function defineMutation(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(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 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 { /** 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' })`). */ middleware?: ReadonlyArray; /** 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 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. ### 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(file: string, source: string): FoundOperation[]; ``` Every `export const = defineQuery|defineMutation(''` in one file. ### 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')`. # Server actions Source: https://kitr.dev/packages/actions Rename a project, archive an invoice, invite Grace to a team: a button often needs to run a bit of server code and nothing more. An API route, a client for it and types on both sides is a lot of ceremony for that. An action is a function you define once with `defineQuery` or `defineMutation`; the browser calls it as if it were local, and kitr sends the request, validates the input on the server and brings back the result or a typed error. Actions are [TanStack Start server functions](https://tanstack.com/start/latest/docs/framework/react/server-functions) with four things added: input validation with any Standard Schema, kitr's one error shape so a validation failure lands on the right form field, cache effects after a mutation, and a generated registry that lets a component say `useQuery('projects.list')` with full types. Actions run on both sides: the handler on the server, the call anywhere. Use them when the only caller is your own React app. When something else calls the same code too, such as a mobile app, a partner or a webhook, use [API routes](/packages/api) instead; they give you URLs, OpenAPI and a typed client. ## Install ```sh kitr add actions ``` What `kitr add` does: - Installs `@kitr/actions`, and [server](/packages/server) and [query](/packages/query) first when the project does not have them. - Adds `@kitr/actions` to `packages` in `kitrrc.ts`. - Creates an empty `app/actions/` folder (or `directories.actions` from `kitrrc.ts`). - Adds `#kitr/*` to `paths` and `.kitr/**/*` to `include` in `tsconfig.json`, and `.kitr` to `.gitignore`. Projects from `kitr new` already have both. - Prefixes the `typecheck` script with `kitr types &&`, so `tsc` always sees current names. - 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 remove actions` reverses every step above except the `tsconfig.json` and `.gitignore` entries, which other generators use too. The `fullstack` and `saas` kits include actions. ## Configure This package has no config file. Everything is declared on each definition, so a reader of one file sees everything that affects it: ```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 | Any [Standard Schema](https://standardschema.dev) (zod, valibot, arktype). Validated on the server; a failure reaches the caller as a `VALIDATION` error with `fieldErrors`. Without it the input is `void`. | | `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 whenever a file under `app/actions/` changes, so the file is current while you work. It is generated and ignored by git. Set `directories.actions` in `kitrrc.ts` to keep actions somewhere else. ## 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 to the server, which 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 })` is a type error, because `name` is required, and `useMutation('projects.rnaem')` is one too, because the name is not in the registry. `useRequestApp(app)` returns the app for the current request, or `app` itself 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` from `kitr/query` all take a name as the first argument and the input as the second. When every field of the input is optional, the input can be left out: `useQuery('projects.list')`. 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` by name hands TanStack Router the same options the component uses, so the server fetches once and the browser reuses the result: ```ts // app/routes/projects/index.tsx import { createFileRoute } from 'kitr/router' import { queryOptions } from 'kitr/query' import { ProjectList } from '#components/project-list' export const Route = createFileRoute('/projects/')({ loader: ({ context }) => context.queryClient.ensureQueryData(queryOptions('projects.list', { status: 'open' })), component: 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, during rendering or inside another handler, Start runs the handler in-process; there is no HTTP hop. A direct call applies no cache effects, because there is no cache in play; use `useMutation` in components for that. When all you have is the name, `call` from `kitr/query` looks it up in the registry: ```ts import { call } from 'kitr/query' await call('projects.rename', { id: 'prj_42', name: 'Acme site' }) const open = await call('projects.list', { status: 'open' }) ``` The generated tree gives the same functions grouped by feature, which reads well in scripts: ```ts import { actions } from '#kitr/actions' await actions.projects.rename({ id: 'prj_42', name: 'Acme site' }) ``` ## Use the definition instead of the name A name resolves through the registry that `createApp({ operations })` fills. 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) ``` Both forms end in the same TanStack options. Plain TanStack options still work as before: `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. The definition's own `queryKey`, `queryFn` and `mutationFn` cannot be replaced; everything else can: ```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 Start middleware runs before the handler and can add to `context`. A session middleware reads the request once and puts the session on the context; `requireSession` then rejects anonymous callers with `UNAUTHORIZED`. It expects `context.session` from an earlier middleware: ```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 } }, }) ``` `createMiddleware` is re-exported from `kitr/actions` so every middleware uses the same import. 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/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. Anything that is not a `KitrError` becomes a 500: the real error is logged on the server, and in production the browser sees only `Internal server error`. ## Organise actions by feature The generator writes one file per action under `app/actions//`. The dotted name is yours; the registry only needs it to be unique, and `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 ``` Any file under `app/actions/` counts, at any depth, except tests and `.d.ts` files. Only `export const = defineQuery('...')` and `export const = defineMutation('...')` with a literal name are picked up; a name built at run time cannot be typed. `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`). ## 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 Start middleware and HTTP, so a test needs neither a server nor a browser: ```ts // test/actions/rename.test.ts import { KitrError } from '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 and render 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 }) ``` The server function itself is a TanStack Start function, so it also works with Start's own test helpers. ## Next steps - [API reference](/packages/actions/api) - [TanStack Start server functions](https://tanstack.com/start/latest/docs/framework/react/server-functions), including middleware and `useServerFn`. - [Query](/packages/query): the hooks, the cache effects and `call`. - [Forms](/packages/forms): field errors from a mutation. - [API routes](/packages/api) for the same handlers behind URLs. - [Type generation](/docs/cli#type-generation): the `kitr types` runner and how a package adds a generator. # Analytics API 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) 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 from the app's event map, with its props. */ track(event: K, ...props: E[K] extends undefined ? [ ] : [ E[K] ]): void; /** Record any event by name. Accepts names outside the event map. */ track(event: string, props?: EventProps): void; /** 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. ## 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 Source: https://kitr.dev/packages/analytics Sooner or later someone asks how many people created a project this week, or which page they left from. `kitr/analytics` gives you four calls (`page`, `track`, `identify` and `reset`) and sends each one to the services you configure: [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. Page views are counted for you on every route change, every vendor call is wrapped so a blocked script never breaks a button, and calls can wait in a queue until the visitor agrees to tracking. The examples on this page follow one scenario: Acme's app at `app.acme.com` counts how often Ada creates a project, which plan Grace's team is on, and when Linus pays an invoice. `kitr/analytics` runs in the browser. On the server it binds the same service so components render, but no vendor script loads. To record events your server owns (a paid invoice, a cancelled subscription), 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 ``` What `kitr add` does: - Installs `@kitr/analytics`. - Adds `() => import('@kitr/analytics/provider')` to `providers` and `'@kitr/analytics'` to `packages` in `kitrrc.ts`. - Adds `VITE_GA_MEASUREMENT_ID: e.string({ optional: true })` to the `public` block of `app/env.ts`, and `VITE_GA_MEASUREMENT_ID=` to `.env.example` (and to `.env` when that file exists). - Writes `config/analytics.ts` (shown in [Configure](#configure)) and regenerates `config/index.ts`. - Writes `app/analytics.ts`, the typed list of your events, with `sign-in.completed` and `project.created` to start. - 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. To edit `package.json` without running the package manager, pass the global flag: ```sh kitr add analytics --no-install ``` The `saas` and `site` kits include analytics. It is a client package, so in a server-only project `kitr add analytics` prints a warning and skips it. `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 in your components, which you delete by hand. ## Configure `kitr add analytics` writes this file: ```ts // config/analytics.ts import { defineConfig, googleAnalytics } 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. */ export default defineConfig({ adapters: [ ...(env.VITE_GA_MEASUREMENT_ID ? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID })] : []), // plausible({ domain: 'example.com' }), // 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 `VITE_GA_MEASUREMENT_ID` empty the `adapters` list is empty, and the provider picks a default: in development it logs every call to the console, in production it sends nothing. Your buttons work before you have chosen 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` | Start sending at once. `false` queues every call (up to 100) until `analytics.enable()`. Use it with a consent banner. | | `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` | development, unless [devtools](/packages/devtools) is installed | Print every call with `console.info('[analytics]', ...)` and print adapter errors with `console.warn`. | | `events` | none | A type-only field that carries your event map. You never set it at run time. | | Variable | Used for | | --- | --- | | `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. | Variables that start with `VITE_` are bundled into the browser code. Measurement ids and public project keys are meant to be public; never put a secret API key in one. ## Track your first event `kitr add` wrote `app/analytics.ts`. It lists every event the app sends, with the props each one carries: ```ts // app/analytics.ts import type { EventMap } from 'kitr/analytics' /** Every event the app sends, with its props. `useAnalytics()` checks names and props. */ export interface AppEvents extends EventMap { 'sign-in.completed': { method: 'password' | 'oauth' } '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/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 ( ) } ``` Render it on the projects page and click it. In development, with no adapter configured, the browser console shows: ``` [analytics] track project.created { app: 'acme', id: 'prj_42' } ``` The `app` prop comes from `globalProps` in `config/analytics.ts`. When [devtools](/packages/devtools) is not installed, the same line prints twice: once from `debug` logging and once from the console adapter. With devtools installed, it prints once and the call also appears in the Analytics panel. Set `VITE_GA_MEASUREMENT_ID=G-ACME12345` in `.env`, restart the dev server, and the same click sends a `project.created` event to 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 list of names and the props for each, so `analytics.track('inv` completes to `'invoice.paid'` and shows its props: ```tsx // app/features/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 ( ) } ``` `track()` also has an untyped form, `track(event: string, props?)`, so TypeScript accepts any name and any props. A misspelt name such as `'invoce.paid'` compiles and is sent as a new event. Keep every name in `AppEvents` and pick it from autocomplete rather than typing it. Name events `object.action` in the past tense (`project.created`, `invoice.paid`), the way the generated file does. Most analytics services group events by name, and one naming rule keeps their dashboards readable. `defineEvents()` ties the event map to a config object. It returns its input unchanged and exists for code that builds its own analytics instance: ```ts // app/features/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 sends a page view for you. It renders a small tracker after the app mounts that reads the router from the container and calls `page()`: - once for the page the visitor lands on, and - again after every navigation that changes the path. A change to the search or the hash alone (`/projects?sort=name`) is not a new page view. Each call carries the path and two props: ```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 into the container. 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, for example when a wizard step changes but the URL does not: ```tsx // app/features/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 } ``` If you build the React tree by hand instead of through the provider, render `` from `kitr/analytics/react` once inside it. It renders nothing and does the same work as the provider's tracker. ## Identify a signed-in user After sign-in, tell the services who the visitor is, so later events join up with their earlier ones. Pass a stable id, never an email address: ```tsx // app/features/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' }) } } ``` Call the returned function when Grace's sign-in succeeds: `afterSignIn({ id: 'usr_grace', plan: 'team' })`. Google Analytics sets `user_id` and the `plan` user property, PostHog calls `posthog.identify('usr_grace', { plan: 'team' })`, and Plausible, which has no user profiles, ignores it. `identify()` does not add `globalProps`. ## Forget the user on sign-out Call `reset()` when the visitor signs out, so the next person on a shared computer is not counted as them: ```tsx // app/features/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`, PostHog calls `posthog.reset()`, and adapters without a `reset` method skip it. ## Add props to every event `globalProps` in `config/analytics.ts` are merged into every `page()` and `track()` call. Use them for facts about the build, not about 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, read the service from the container. In a store, a query hook or a provider, `app.container.use('analytics')` returns the same instance the components use: ```ts // app/features/projects/create-project.ts import type { Analytics } from 'kitr/analytics' import type { KitrApp } from '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 } ``` Don't import `googleAnalytics()` or `window.gtag` into feature code to send an event. Go through the service, so consent, queuing and every other adapter still apply. ## Adapters An adapter sends calls to one service. List as many as you like in `adapters`; every call goes to each of them in order. Each adapter loads its own script or client 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 the `gtag.js` script once and turns off gtag's own page views, so the router decides what a page view is. ```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 with `page_path`, `page_location` and `page_title`. `track('project.created', { id })` becomes `gtag('event', 'project.created', { id })`. GA4 has its own rules for event names; check them in the [GA4 event reference](https://developers.google.com/analytics/devguides/collection/ga4/reference/events) before you settle on a naming scheme for an app that sends to GA. ### Plausible Nothing to install. `plausible()` injects Plausible's manual script (`/js/script.manual.js`) once, with `data-domain` set to your site. ```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. A trailing slash is removed: ```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` with the full URL and your props as custom props. `track()` sends a custom event with props. Calls made before the script loads wait in Plausible's own queue. Plausible has no user profiles, so `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. kitr does not import `posthog-js` itself, so it stays out of your bundle until you add it: ```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/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. | The adapter calls `posthog.init(apiKey, { api_host, capture_pageview: false, ...config })`. Automatic page views are off so the router drives them; `page()` becomes a `$pageview` capture with `$current_url`. Any object with `init`, `capture`, `identify` and `reset` works as the client (the `PostHogLike` type). See the [posthog-js docs](https://posthog.com/docs/libraries/js) for `config` options. ### Console `consoleAdapter()` prints each call with `console.info`. It is what the provider uses in development when `adapters` is empty. Add it yourself to see calls next to a real service, with a prefix to tell them apart: ```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 In much of the world you need permission before tracking. 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 `answered` flag to re-render. This example does not remember the answer across visits; [consent](/packages/consent) stores it in a cookie. What happens to each call: - Before `enable()`, calls queue. The queue holds 100; later calls are dropped until you enable. - `enable()` resolves once every adapter's `init()` has run, then the queue is sent. Calling it again does nothing. - `disable()` empties the queue and stops sending. Calls made after it queue again and go out if `enable()` is called later. - `analytics.enabled` tells you which state you are in. The [consent](/packages/consent) package does all of this for you: it calls `enable()` when the visitor grants the `analytics` category and `disable()` when they withdraw it. With consent installed, never call `enable()` yourself; add `enabled: false` to `config/analytics.ts` and let the banner decide. > **Warning** > Without `enabled: false`, the provider enables analytics while the app boots, before any consent check runs. That loads `gtag.js` (or the Plausible or PostHog script), and the vendor may set cookies, even though [consent](/packages/consent) disables sending a moment later. Set `enabled: false` whenever you need consent first. ## Add an adapter at run time `use()` adds an adapter after the app has started. If analytics is already enabled, the adapter's script loads at once; otherwise it loads on `enable()`. Use it for a service you only want for some visitors, such as session replay for Acme staff: ```tsx // app/features/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 calls made after it was added. `analytics.adapters` lists every adapter by `name`; there is no way to remove one. ## Survive blocked scripts Ad blockers block analytics scripts, and vendors have outages. Every adapter call runs inside a `try`, and a rejected promise from an adapter is caught, so `track()` never throws into your click handler 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 `[analytics] blocked Error: blocked` as a warning. In production `debug` is off and failures are silent. ## Use it on the server During server rendering the provider still binds `analytics`, so components that call `useAnalytics()` render the same on both sides. Adapters never load their scripts on the server, because `init()` is skipped when there is no `window`, and the page view tracker runs in an effect, which the server never runs. Call `track()` from event handlers and effects, never during render or in a route loader. On the server such a call still reaches the adapters, which have no script to deliver it: the console adapter prints it to the server log, and the Google Analytics and Plausible adapters push it onto a global queue that nothing reads. To record a server event, such as a paid invoice, write it from the controller to your database or a [job](/packages/jobs). ## Testing Pass `memoryAdapter()` in config and assert on its `calls`. Each call is recorded as `{ type, args }`, with `globalProps` already merged in. This test renders the button from [Track your first event](#track-your-first-event) inside a test app and checks the event it sends: ```tsx // app/features/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/features/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. | `createTestApp()` boots in the `test` mode, so the provider adds no console adapter and nothing is printed. ## 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 and create clients there, not when the adapter is created, so nothing loads before consent. The other methods should be quick and must not throw; the service catches errors, but an adapter that throws on every call hides real problems. This adapter sends Acme's events to its own 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, the provider adds a devtools adapter that copies every call into the log under the `analytics` channel, and an Analytics panel. The panel shows whether tracking is enabled or still queued, the names of the active adapters, and the last 200 calls with their props. Its actions are Enable (consent given), Disable and Clear events, and each one is also in the command palette. ## Next steps - [API reference](/packages/analytics/api) for every export and type. - [Consent](/packages/consent) to hold analytics until visitors agree. - [Devtools](/packages/devtools) to watch every call while you build. - [Monitoring](/packages/monitoring) to report errors rather than usage. - [Router](/packages/router) for the navigations that become page views. # API routes API Source: https://kitr.dev/packages/api/api ## defineRoutes ```ts const routes = defineRoutes((router) => router.get(...).group(...)) type AppRoutes = typeof routes.$types ``` ### Router methods | Method | Description | | --- | --- | | `get / post / put / patch / delete(path, handler, options?)` | Add a route. `handler` is `(ctx) => body` or `[Controller, 'method']`. | | `resource(name, Controller)` | `index`, `store`, `show`, `update`, `destroy` under `/`. | | `group({ prefix?, middleware?, as? }, (router) => router)` | Share a prefix, middleware and name prefix. | | `toJSON()` | Route names, methods and paths, for `registerRoutes`. | ### Route options | Option | Description | | --- | --- | | `name` | Used by the client (`api.projects.show`) and `router.url()`. | | `validate` | `{ params?, query?, body? }` Standard Schemas. | | `middleware` | Extra middleware for this route. | | `meta` | `summary`, `description`, `tags`, `deprecated`, emitted into OpenAPI. | ## HttpContext ```ts interface HttpContext { params: Params query: Query body: Body request: KitrRequest // method, url, header(name) response: KitrResponse // status(code), header(name, value) locals: Record logger: Logger requestId: string app: KitrApp validate(schema, source?: 'body' | 'query' | 'params'): Promise } ``` ## Middleware ```ts defineMiddleware(async (ctx, next) => { ...; return next() }) cors({ origin?, methods?, headers?, credentials?, maxAge? }) rateLimit({ max?, windowMs?, key? }) requireJson() compose([a, b, c]) ``` ## Mounting ```ts mountRoutes(server: KitrServer, routes: Router, base = '/'): KitrServer toH3(routes, app, { h3? }): H3 ``` ## toOpenApi ```ts toOpenApi(routes, { title?, version?, description?, servers?, schemaToJson? }): Promise ``` ## Configuration ```ts // config/api.ts defineConfig({ prefix?: string, openapi?: { path?: string; title?: string; version?: string; description?: string }, client?: { path?: string }, }) ``` Read by `kitr api:sync`. ## kitr/api/client ```ts createApiClient({ baseUrl: string, fetch?: (request: Request) => Promise, // e.g. server.fetch in tests headers?: () => HeadersInit | Promise, getToken?: () => string | null | Promise<...>, credentials?: RequestCredentials, }) ``` Each endpoint is callable and has: | Member | Description | | --- | --- | | `(input?)` | Call it. Input is `{ params?, query?, body? }`, typed per route. | | `.query()` | A query contract for `defineQuery`. | | `.mutation({ effects? })` | A mutation contract for `defineMutation`. | | `.key(input?)` | The cache key: `['api', routeName, params, query]`. | | `.url(input?)` | The full URL. | | `.method`, `.path` | From the route. | Errors reject with a `KitrError`. # API routes Source: https://kitr.dev/packages/api A mobile app that lists orders, a partner that posts invoices, a webhook from your payment provider: each needs JSON endpoints with clear inputs and clear errors. `kitr/api` gives you a router, controllers, validation and middleware in the style of [AdonisJS](https://docs.adonisjs.com/guides/basics/routing), and a client for the browser that knows every route's inputs and outputs without generating any code. It runs on [h3](https://h3.dev) through [Server](/packages/server), inside a TanStack Start app or as a standalone backend with no React at all. Validation takes any [Standard Schema](https://standardschema.dev) library, such as zod, valibot or arktype, and `kitr api:sync` writes an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) document from the same routes. Routes and controllers run on the server. The typed client (`kitr/api/client`) runs in the browser, on the server and in tests. The examples on this page build the Acme orders API at `app.acme.com/api`: list, create and show orders, with Ada, Grace and Linus as customers. ## When to use it Use API routes for anything outside your own React UI: mobile apps, partners, webhooks, scripts, or whenever you want REST URLs and an OpenAPI document. For functions only your own UI calls, use [Actions](/packages/actions): no URLs to design, and the same validation and error shape. Both can live in one app. ## Install ```sh kitr add api ``` What `kitr add api` does: - Runs the [Server](/packages/server) recipe first if it is missing, which also adds [Logger](/packages/logger). - Installs `@kitr/api` and adds it to `packages` in `kitrrc.ts`. - Writes `config/api.ts` (shown under Configure) and adds it to `config/index.ts`. - Writes `server/routes.ts`, with one `GET /api/health` route behind `cors()`. - Writes `server/controllers/health_controller.ts`, the controller that route calls. - When `server/main.ts` exists without routes, adds `import { mountRoutes } from 'kitr/api'`, `import { routes } from './routes'` and `mountRoutes(server, routes)` to it. - In a React project, writes `app/api/client.ts`, the typed client. - Adds a `kitr: api` section to `AGENTS.md`. The generated routes file: ```ts // server/routes.ts import { cors, defineRoutes } from 'kitr/api' import HealthController from '#server/controllers/health_controller' export const routes = defineRoutes((router) => router.group({ prefix: '/api', middleware: [cors()] }, (api) => api.get('/health', [HealthController, 'show'], { name: 'health' }), // api.resource('projects', () => import('#server/controllers/projects_controller')) ), ) /** Pass to createApiClient() on the client. */ export type AppRoutes = typeof routes.$types ``` The generated controller: ```ts // server/controllers/health_controller.ts import type { HttpContext } from 'kitr/api' export default class HealthController { async show({ requestId }: HttpContext) { return { ok: true, requestId } } } ``` API routes need a server platform to run on. If the project has none, add one with `kitr add server api`. The `fullstack`, `server`, `saas`, `site` and `realtime` kits include API routes already. `kitr remove api` removes `@kitr/api` from `package.json` and `kitrrc.ts`, deletes `config/api.ts` and drops the `AGENTS.md` section. It keeps `server/routes.ts`, your controllers and `app/api/client.ts`, and lists the files that still import `kitr/api`. ## Configure `kitr add api` writes this file, with your package name in the title: ```ts // config/api.ts import { defineConfig } from 'kitr/api' export default defineConfig({ prefix: '/api', openapi: { path: 'openapi.json', title: 'acme API', version: '0.1.0' }, }) ``` | Option | Default | Description | | --- | --- | --- | | `prefix` | none | Documents the prefix your routes share. The router does not read it: set the prefix with `router.group({ prefix: '/api' })` in `server/routes.ts`. | | `openapi.path` | `'openapi.json'` | Where `kitr api:sync` writes the OpenAPI document. | | `openapi.title` | `' API'` | `info.title` in the document. | | `openapi.version` | `package.json` version | `info.version` in the document. | | `openapi.description` | none | Declared for `info.description`. `kitr api:sync` does not read it yet; pass `description` to `toOpenApi()` instead. | | `client.path` | `'app/api/client.ts'` | Declared for the client file. `kitr api:sync` does not read it yet. | `kitr api:sync` reads `openapi.path`, `openapi.title` and `openapi.version` from the file's text, so keep them as plain string literals. API routes add no environment variables. ## Build your first endpoint Write a controller for orders. Each method receives the request context and returns the response body: ```ts // server/controllers/orders_controller.ts import type { HttpContext } from 'kitr/api' import { KitrError } from 'kitr/app' export interface Order { id: string customer: string total: number status: 'open' | 'paid' } const orders = new Map([ ['ord_1042', { id: 'ord_1042', customer: 'Ada', total: 129, status: 'open' }], ]) export default class OrdersController { async index() { return [...orders.values()] } async show({ params }: HttpContext<{ id: string }>) { const order = orders.get(params.id) if (!order) throw KitrError.notFound('Order') return order } } ``` Register it in the routes file: ```ts // server/routes.ts import { cors, defineRoutes } from 'kitr/api' import HealthController from '#server/controllers/health_controller' import OrdersController from '#server/controllers/orders_controller' export const routes = defineRoutes((router) => router.group({ prefix: '/api', middleware: [cors()] }, (api) => api .get('/health', [HealthController, 'show'], { name: 'health' }) .resource('orders', OrdersController), ), ) export type AppRoutes = typeof routes.$types ``` Start the server with `kitr dev api` (or `kitr dev` in a React app) and ask for an order: ```sh curl http://localhost:3001/api/orders/ord_1042 ``` ```json { "id": "ord_1042", "customer": "Ada", "total": 129, "status": "open" } ``` `resource('orders', ...)` registered `GET /api/orders` and `GET /api/orders/:id` because the controller has `index` and `show`. An unknown id answers `404` with `{ "code": "NOT_FOUND", "message": "Order not found", "status": 404 }`. ## Define routes The router has one method per HTTP verb. Each takes a path, a handler and options, and returns the router so you can chain: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' import InvoicesController from '#server/controllers/invoices_controller' export const routes = defineRoutes((router) => router .get('/api/invoices', [InvoicesController, 'index'], { name: 'invoices.index' }) .post('/api/invoices', [InvoicesController, 'store'], { name: 'invoices.store' }) .put('/api/invoices/:id', [InvoicesController, 'replace'], { name: 'invoices.replace' }) .patch('/api/invoices/:id', [InvoicesController, 'update'], { name: 'invoices.update' }) .delete('/api/invoices/:id', [InvoicesController, 'destroy'], { name: 'invoices.destroy' }) .get('/api/ping', () => ({ pong: true }), { name: 'ping' }), ) export type AppRoutes = typeof routes.$types ``` A handler is either a `[Controller, 'method']` pair or a function that takes the context. TypeScript checks that the method exists on the controller. | Route option | Description | | --- | --- | | `name` | The name the client calls (`api.invoices.index()`) and `router.url()` looks up. Dots make nested client properties. Without a name, the route is called `'GET /api/ping'`. | | `validate` | Schemas for `params`, `query` and `body`. See "Validate input". | | `middleware` | Middleware for this route only, after the group's. | | `meta` | `summary`, `description`, `tags` and `deprecated`, copied into the OpenAPI document. | Path parameters start with `:` and arrive as strings in `ctx.params`. A trailing `*` matches the rest of the path. `router.any(path, handler)` registers one handler for every method; it is not part of the typed client. Give every route you call from the client a `name`. Names are stable when paths change, and they become the query keys in the client. ## Group routes `router.group()` shares a prefix, middleware and a name prefix across routes: ```ts // server/routes.ts import { cors, defineRoutes } from 'kitr/api' import OrdersController from '#server/controllers/orders_controller' import TeamsController from '#server/controllers/teams_controller' import { audit } from '#app/middleware/audit' export const routes = defineRoutes((router) => router.group({ prefix: '/api', middleware: [cors()] }, (api) => api .resource('orders', OrdersController) .group({ prefix: '/admin', middleware: [audit], as: 'admin' }, (admin) => admin.resource('teams', TeamsController), ), ), ) export type AppRoutes = typeof routes.$types ``` | Group option | Effect | | --- | --- | | `prefix` | Joined onto every path inside: `/api` + `/admin` + `/teams` is `/api/admin/teams`. | | `middleware` | Runs before every route inside, outer groups first. | | `as` | Prefixes every name inside: `teams.index` becomes `admin.teams.index`, called as `api.admin.teams.index()`. | `group('/api', ...)` with a string is short for `{ prefix: '/api' }`. `router.use(audit)` adds middleware to every route registered after the call, not before it. ## Create a resource `resource(name, Controller)` registers the five REST routes for the methods the controller has, and names them `.`: | Method | Path | Controller method | Route name | Client call | | --- | --- | --- | --- | --- | | GET | `/orders` | `index` | `orders.index` | `api.orders.index()` | | POST | `/orders` | `store` | `orders.store` | `api.orders.store({ body })` | | GET | `/orders/:id` | `show` | `orders.show` | `api.orders.show({ params: { id } })` | | PATCH | `/orders/:id` | `update` | `orders.update` | `api.orders.update({ params, body })` | | DELETE | `/orders/:id` | `destroy` | `orders.destroy` | `api.orders.destroy({ params })` | Narrow it with options: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' import InvoicesController from '#server/controllers/invoices_controller' import ProjectsController from '#server/controllers/projects_controller' import { audit } from '#app/middleware/audit' export const routes = defineRoutes((router) => router.group('/api', (api) => api .resource('invoices', InvoicesController, { only: ['index', 'show'] }) .resource('projects', ProjectsController, { except: ['destroy'], param: 'projectId', middleware: [audit], }), ), ) export type AppRoutes = typeof routes.$types ``` `param: 'projectId'` makes the paths `/api/projects/:projectId`, so the controller reads `params.projectId`. ## Load controllers lazily Pass a function that imports the controller instead of the class. The file loads on the first request, which keeps server start-up fast when there are many controllers: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' export const routes = defineRoutes((router) => router.group('/api', (api) => api .resource('orders', () => import('#server/controllers/orders_controller')) .get('/orders/:id/receipt', [() => import('#server/controllers/receipts_controller'), 'show'], { name: 'orders.receipt', }), ), ) export type AppRoutes = typeof routes.$types ``` A lazy controller can't be inspected when routes are defined, so `resource()` registers all five routes. Calling a method the controller doesn't have answers `404` with `Controller method OrdersController.update not found`. ## Write a controller A controller is a plain class with a default export. `kitr make:controller orders --resource` writes one with all five methods. Each method receives an `HttpContext`; type its three parameters for `params`, `query` and `body`: ```ts // server/controllers/orders_controller.ts import type { HttpContext } from 'kitr/api' import { KitrError } from 'kitr/app' import { z } from 'zod' const createOrder = z.object({ customer: z.string().min(2), total: z.number().positive(), }) const updateOrder = createOrder.partial() export interface Order { id: string customer: string total: number status: 'open' | 'paid' } const orders = new Map([ ['ord_1042', { id: 'ord_1042', customer: 'Ada', total: 129, status: 'open' }], ]) export default class OrdersController { static validation = { store: { body: createOrder }, update: { body: updateOrder }, } async index({ query }: HttpContext) { const status = typeof query.status === 'string' ? query.status : undefined return [...orders.values()].filter((order) => !status || order.status === status) } async show({ params }: HttpContext<{ id: string }>) { const order = orders.get(params.id) if (!order) throw KitrError.notFound('Order') return order } async store({ body, response, logger }: HttpContext<{}, {}, z.infer>) { const order: Order = { id: `ord_${1042 + orders.size}`, status: 'open', ...body } orders.set(order.id, order) logger.info('order created', { id: order.id }) response.status(201) return order } async update({ params, body }: HttpContext<{ id: string }, {}, z.infer>) { const order = orders.get(params.id) if (!order) throw KitrError.notFound('Order') const updated = { ...order, ...body } orders.set(order.id, updated) return updated } async destroy({ params }: HttpContext<{ id: string }>) { orders.delete(params.id) } } ``` The context carries everything about the request: | Field | What it holds | | --- | --- | | `params` | Path parameters, validated when a `params` schema is set. | | `query` | Query string values (`string` or `string[]`), validated when a `query` schema is set. | | `body` | The parsed body on `POST`, `PUT` and `PATCH`, validated when a `body` schema is set. | | `request` | Headers, URL, IP and the raw body. See "Read the request". | | `response` | Status, headers, redirects and raw responses. See "Shape the response". | | `locals` | Values set by middleware. [Auth](/packages/auth) sets `user` and `can`. | | `logger` | The request's logger, which adds the request id. | | `requestId` | The request id from [Server](/packages/server). | | `app` | The request-scoped app: `app.container.use('db')`. | | `event` | The underlying h3 event. | | `validate(schema, source)` | Validates `body`, `query` or `params` on demand. | kitr creates one instance of each controller class and reuses it for every request. Don't store request data on `this`; keep it in local variables or `ctx.locals`. ## Validate input Declare schemas per controller method in `static validation`. kitr validates before the method runs, and the typed client picks up the same types: ```ts // server/controllers/orders_controller.ts // ... export default class OrdersController { static validation = { store: { body: createOrder }, update: { body: updateOrder }, } // ... } ``` A request that fails gets `422` with every problem at once: ```sh curl -X POST http://localhost:3001/api/orders \ -H 'content-type: application/json' \ -d '{"customer":"A","total":-1}' ``` ```json { "code": "VALIDATION", "message": "Validation failed", "status": 422, "fieldErrors": { "customer": ["Too small: expected string to have >=2 characters"], "total": ["Too small: expected number to be >0"] } } ``` [Forms](/packages/forms) puts `fieldErrors` on the matching fields. The messages come from your schema library. For function handlers, pass `validate` in the route options. Schemas for `params` and `query` also convert values, so `ctx.query.page` below is a number: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' import { z } from 'zod' const invoicePage = z.object({ page: z.coerce.number().int().min(1).default(1) }) export const routes = defineRoutes((router) => router.get('/api/invoices', ({ query }) => ({ page: query.page }), { name: 'invoices.index', validate: { query: invoicePage }, }), ) export type AppRoutes = typeof routes.$types ``` `GET /api/invoices?page=2` answers `{ "page": 2 }`; `?page=0` answers `422` with `{ "page": ["Too small: expected number to be >=1"] }`. When the shape depends on the request, validate inside the handler with `ctx.validate()`. It throws the same `422`: ```ts // server/controllers/invoices_controller.ts import type { HttpContext } from 'kitr/api' import { z } from 'zod' const refund = z.object({ amount: z.number().positive(), reason: z.string() }) export default class InvoicesController { async refund(ctx: HttpContext<{ id: string }>) { const input = await ctx.validate(refund, 'body') return { invoice: ctx.params.id, refunded: input.amount } } } ``` Validators set in route options take precedence over `static validation`. Without any validator, `body` is the raw parsed body on writes and `undefined` on reads. ## Read the request `ctx.request` wraps the incoming request: ```ts // server/controllers/webhooks_controller.ts import type { HttpContext } from 'kitr/api' export default class WebhooksController { async payment({ request, logger }: HttpContext) { const signature = request.header('x-acme-signature') const raw = await request.raw() logger.info('payment webhook', { ip: request.ip(), path: request.url.pathname, tags: request.qs().tag, signed: Boolean(signature), }) return { received: true, type: (raw as { type?: string }).type } } } ``` | Member | Returns | | --- | --- | | `method` | `'POST'` | | `url` | A `URL` for the request. | | `headers` | The `Headers` object. | | `header(name)` | One header value, or `null`. | | `ip()` | The client IP, or `undefined` when the runtime doesn't expose it. | | `qs()` | Raw query values; repeated keys become arrays: `?tag=a&tag=b` gives `{ tag: ['a', 'b'] }`. | | `raw()` | The body parsed by content type (JSON, form or text). Reading it twice returns the same value. | | `native` | The web `Request`. | Malformed JSON answers `400` with `{ "code": "BAD_REQUEST", "message": "Could not read request body: ..." }`, not a `500`. ## Shape the response Whatever the handler returns becomes the JSON body with status `200`. Use `ctx.response` to change that: ```ts // server/controllers/orders_controller.ts import type { HttpContext } from 'kitr/api' export default class OrdersController { async store({ response }: HttpContext) { response.status(201).header('location', '/api/orders/ord_1043') return { id: 'ord_1043', customer: 'Grace', total: 480, status: 'open' } } async legacy({ response }: HttpContext) { return response.redirect('/api/orders', 301) } async export({ response }: HttpContext) { return response.json({ queued: true }, { status: 202, headers: { 'x-export-id': 'exp_7' } }) } async archive({ response }: HttpContext) { return response.noContent() } } ``` | Method | Effect | | --- | --- | | `status(code)` | Sets the status for the returned body. Chainable. | | `header(name, value)` | Sets a response header. Chainable. | | `redirect(location, status = 302)` | Returns a redirect response. | | `json(data, init)` | Returns a JSON `Response` with your status and headers. | | `noContent()` | Returns an empty `204`. | A handler that returns `undefined` or `null` answers with an empty body: `204`, or the status you set with `response.status()`. You can also return any web `Response` directly, such as a file stream. ## Return errors Throw a `KitrError` from `kitr/app` to answer with an error. The status follows the code: ```ts // server/controllers/invoices_controller.ts import type { HttpContext } from 'kitr/api' import { KitrError } from 'kitr/app' export default class InvoicesController { async pay({ params }: HttpContext<{ id: string }>) { if (params.id === 'INV-1042') throw new KitrError('CONFLICT', 'Invoice INV-1042 is already paid') if (params.id.startsWith('draft')) throw KitrError.forbidden('Draft invoices cannot be paid') throw KitrError.notFound('Invoice') } } ``` ```json { "code": "CONFLICT", "message": "Invoice INV-1042 is already paid", "status": 409 } ``` Anything else that is thrown becomes `INTERNAL` with status `500`, and in production its message is replaced with `Internal server error`. [Server](/packages/server) logs every `5xx` with the request id. ## Write middleware Middleware is a function of the context and `next`. Call `next()` to run the rest of the chain and return its result; return without calling it to answer early: ```ts // app/middleware/audit.ts import { defineMiddleware } from 'kitr/api' export const audit = defineMiddleware(async (ctx, next) => { const started = performance.now() const result = await next() ctx.logger.info('audited', { path: ctx.request.url.pathname, durationMs: Math.round(performance.now() - started), }) return result }) ``` `kitr make:middleware audit` writes this skeleton in `app/middleware/audit.ts`. Attach middleware to a group, a resource or one route: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' import InvitesController from '#server/controllers/invites_controller' import { audit } from '#app/middleware/audit' import { requireTeam } from '#app/middleware/require_team' export const routes = defineRoutes((router) => router.group({ prefix: '/api', middleware: [audit] }, (api) => api.post('/teams/:teamId/invites', [InvitesController, 'store'], { name: 'invites.store', middleware: [requireTeam], }), ), ) export type AppRoutes = typeof routes.$types ``` Pass data to the handler through `ctx.locals`, and stop a request by throwing a `KitrError`: ```ts // app/middleware/require_team.ts import { defineMiddleware } from 'kitr/api' import { KitrError } from 'kitr/app' export const requireTeam = defineMiddleware(async (ctx, next) => { const teamId = ctx.request.header('x-acme-team') if (!teamId) throw new KitrError('BAD_REQUEST', 'Missing x-acme-team header') ctx.locals.teamId = teamId return next() }) ``` Middleware runs outer to inner: group middleware first, then route middleware, then the handler, and back out in reverse. Calling `next()` twice throws `[kitr/api] next() called more than once`. `compose([a, b])` combines several middleware into one. ## Allow cross-origin requests `cors()` answers browser preflight requests and sets the CORS headers. The generated routes file puts it on the `/api` group with defaults that allow any origin: ```ts // server/routes.ts import { cors, defineRoutes } from 'kitr/api' import OrdersController from '#server/controllers/orders_controller' export const routes = defineRoutes((router) => router.group( { prefix: '/api', middleware: [cors({ origin: ['https://app.acme.com', 'https://admin.acme.com'], credentials: true })], }, (api) => api.resource('orders', OrdersController), ), ) export type AppRoutes = typeof routes.$types ``` | Option | Default | Description | | --- | --- | --- | | `origin` | `'*'` | `'*'`, one origin, a list, or a function `(origin) => boolean`. With `'*'` and `credentials`, the request's origin is echoed. | | `methods` | `GET, POST, PUT, PATCH, DELETE, OPTIONS` | `access-control-allow-methods` on preflight. | | `headers` | `Content-Type, Authorization` | `access-control-allow-headers` on preflight. | | `credentials` | `false` | Sends `access-control-allow-credentials: true`, needed for cookies. | | `maxAge` | `600` | Seconds a browser may cache the preflight. | A preflight `OPTIONS /api/orders` answers `204` with the allow headers. kitr registers the `OPTIONS` route for every route behind `cors()`, so you don't write one. > **Warning** > `origin: '*'` with `credentials: true` echoes any origin, so any site can make signed-in requests to your API. With cookies, list your origins. ## Rate limit a route `rateLimit()` counts requests per client IP in a window and answers `429` past the limit: ```ts // server/routes.ts import { defineRoutes, rateLimit } from 'kitr/api' import InvitesController from '#server/controllers/invites_controller' export const routes = defineRoutes((router) => router.post('/api/invites', [InvitesController, 'store'], { name: 'invites.store', middleware: [ rateLimit({ max: 5, windowMs: 60_000, key: (ctx) => ctx.request.header('x-acme-team') ?? 'anonymous' }), ], }), ) export type AppRoutes = typeof routes.$types ``` | Option | Default | Description | | --- | --- | --- | | `max` | `100` | Requests allowed per key per window. | | `windowMs` | `60000` | Window length in milliseconds. | | `key` | client IP | Function from the context to a bucket key, such as a team or user id. | Every response carries `ratelimit-limit`, `ratelimit-remaining` and `ratelimit-reset` (seconds). The sixth request in a minute gets `{ "code": "RATE_LIMITED", "message": "Too many requests", "status": 429 }`. The counters live in the process's memory. With several server instances behind a load balancer, each counts on its own, so the real limit is `max` times the number of instances. For one limit across instances, use the rate limiter in [Shield](/packages/shield), which takes a shared `store`. ## Require JSON bodies `requireJson()` rejects `POST`, `PUT` and `PATCH` requests whose `content-type` is not `application/json`, with `400` and `Expected application/json`. Reads pass through: ```ts // server/routes.ts import { defineRoutes, requireJson } from 'kitr/api' import OrdersController from '#server/controllers/orders_controller' export const routes = defineRoutes((router) => router.group({ prefix: '/api', middleware: [requireJson()] }, (api) => api.resource('orders', OrdersController), ), ) export type AppRoutes = typeof routes.$types ``` ## Call the API from the browser In a React project, `kitr add api` writes the client: ```ts // app/api/client.ts import { createApiClient } from 'kitr/api/client' import type { AppRoutes } from '#server/routes' /** Typed, no codegen: route names, params, bodies and outputs come from the router. */ export const api = createApiClient({ baseUrl: typeof window === 'undefined' ? `http://localhost:${process.env.PORT ?? 3000}` : '' }) ``` Only the type is imported from `server/routes.ts`, so no server code reaches the browser bundle. The client also needs the runtime route table, which maps names such as `orders.show` to a method and path. Generate it and register it once: ```sh kitr api:sync ``` ```ts // app/api/client.ts import { createApiClient, registerRoutes } from 'kitr/api/client' import type { AppRoutes } from '#server/routes' import { routeTable } from './routes.gen' export const api = createApiClient({ baseUrl: typeof window === 'undefined' ? `http://localhost:${process.env.PORT ?? 3000}` : '', }) registerRoutes(api, [...routeTable]) ``` Without a registered table, using a named route such as `api.orders.show` throws a `KitrError` with code `INTERNAL` and the message `[kitr/api] Unknown route "orders.show"...`, which tells you to register the routes. Run `kitr api:sync` again whenever you add or rename a route. On the server you can register straight from the router with `registerRoutes(api, routes.toJSON())`. Every call is checked against the route: ```ts // app/features/orders/load.ts import { api } from '#app/api/client' const orders = await api.orders.index() // => [{ id: 'ord_1042', customer: 'Ada', total: 129, status: 'open' }] const order = await api.orders.show({ params: { id: 'ord_1042' } }) // => { id: 'ord_1042', customer: 'Ada', total: 129, status: 'open' } const created = await api.orders.store({ body: { customer: 'Grace', total: 480 } }) // => { id: 'ord_1043', status: 'open', customer: 'Grace', total: 480 } await api.orders.store({ body: { customer: 'Grace' } }) // type error: property 'total' is missing ``` The input object has up to three keys: `params` (required when the path has parameters), `query` (values are stringified, `undefined` is dropped) and `body` (sent as JSON on everything but `GET` and `HEAD`). The return type is whatever the controller method returns. A `204` resolves to `undefined`. Each endpoint also exposes helpers: ```ts // app/features/orders/links.ts import { api } from '#app/api/client' api.orders.show.url({ params: { id: 'ord_1042' } }) // => '/api/orders/ord_1042' api.orders.show.key({ params: { id: 'ord_1042' } }) // => ['api', 'orders.show', { id: 'ord_1042' }, {}] api.orders.show.method // => 'GET' api.orders.show.path // => '/api/orders/:id' ``` | Client option | Description | | --- | --- | | `baseUrl` | Prepended to every path. `''` in the browser calls the same origin. | | `fetch` | Custom fetch: a test fake, or `server.fetch` for in-process calls. | | `headers` | Function returning headers for every request; may be async. | | `getToken` | Function returning a bearer token; sent as `authorization: Bearer ` when it returns one. | | `credentials` | Passed to `fetch`: `'include'` sends cookies cross-origin. | ## Use endpoints with TanStack Query Every endpoint produces [Query](/packages/query) contracts. `query()` uses the endpoint's key and fetch, and `mutation()` takes cache effects: ```tsx // app/features/orders/orders_page.tsx import { defineMutation, defineQuery, useKitrMutation, useSuspenseQuery } from 'kitr/query' import { api } from '#app/api/client' const ordersQuery = defineQuery(api.orders.index.query()) const createOrder = defineMutation( api.orders.store.mutation({ effects: { invalidate: [['api', 'orders.index']] } }), ) export function OrdersPage() { const { data: orders } = useSuspenseQuery(ordersQuery.options({})) const create = useKitrMutation(createOrder) return (
    {orders.map((order) => (
  • {order.customer}: {order.total}
  • ))}
) } ``` The query key is `['api', '', params, query]`, so `invalidate: [['api', 'orders.index']]` refetches every orders list after Linus's order is created. In a route loader, call `context.queryClient.ensureQueryData(ordersQuery.options({}))` so the server and client fetch once. ## Handle errors on the client Failed calls reject with a `KitrError` that carries the server's code, status and field errors: ```ts // app/features/orders/create.ts import { KitrError } from 'kitr/app' import { api } from '#app/api/client' try { await api.orders.store({ body: { customer: 'A', total: -1 } }) } catch (error) { if (error instanceof KitrError && error.code === 'VALIDATION') { console.log(error.fieldErrors) // => { customer: ['Too small: expected string to have >=2 characters'], total: ['Too small: expected number to be >0'] } } } ``` | Response | Rejects with | | --- | --- | | A kitr error body | The same `code`, `message`, `status`, `fieldErrors` and `data`. | | Another error body | A code from the status (`403` is `FORBIDDEN`, `502` is `INTERNAL`) and the body's `message` when it has one. | | No response (offline, DNS, CORS) | `NETWORK`. | | A missing path parameter | `BAD_REQUEST`, `Missing param "id" for orders.show`, before any request is sent. | ## Send auth tokens from the client Give the client a token source and every request carries it: ```ts // app/api/client.ts import { createApiClient, registerRoutes } from 'kitr/api/client' import type { AppRoutes } from '#server/routes' import { app } from '#app/app' import { routeTable } from './routes.gen' export const api = createApiClient({ baseUrl: 'https://app.acme.com', getToken: () => app.container.use('auth').getToken(), headers: () => ({ 'x-acme-team': 'team_7' }), }) registerRoutes(api, [...routeTable]) ``` `getToken` here comes from [Auth](/packages/auth). For cookie sessions, pass `credentials: 'include'` instead. On the server, `authMiddleware` from `kitr/auth/server` reads the token and sets `ctx.locals.user`. ## Call routes in-process On the server, or in tests, pass the server's `fetch` so calls skip the network: ```ts // server/jobs/nightly_report.ts import { createApiClient, registerRoutes } from 'kitr/api/client' import { server } from '#server/main' import { type AppRoutes, routes } from '#server/routes' const api = createApiClient({ baseUrl: 'http://internal', fetch: (request) => server.fetch(request) }) registerRoutes(api, routes.toJSON()) export async function nightlyReport() { const orders = await api.orders.index() return { count: orders.length } } ``` ## Mount routes on a server `mountRoutes(server, routes)` turns the router into an h3 app and mounts it at `/`. `server/main.ts` does this for you: ```ts // server/main.ts import { createServer } from 'kitr/server' import { mountRoutes } from 'kitr/api' import { config } from '../config' import rc from '../kitrrc' import { routes } from './routes' export const server = createServer({ providers: rc.providers, config }) mountRoutes(server, routes) ``` Pass a third argument to mount under a base path: `mountRoutes(server, partnerRoutes, '/partners')`. To serve the router from an h3 app you build yourself, call `toH3(routes, app)` with a kitr app; it returns the h3 app. ## Build URLs for named routes `routes.url()` builds a path from a route name, with encoded parameters and an optional query string: ```ts // server/mail/order_link.ts import { routes } from '#server/routes' routes.url('orders.show', { id: 'ord_1042' }, { expand: 'customer' }) // => '/api/orders/ord_1042?expand=customer' ``` An unknown name throws `[kitr/api] No route named "..."`, and a missing parameter throws `[kitr/api] Missing param "id" for route "orders.show"`. ## Generate an OpenAPI document ```sh kitr api:sync ``` `kitr api:sync` loads `server/routes.ts` and writes two files: - `openapi.json` (or `openapi.path` from `config/api.ts`): an OpenAPI 3.1 document. Every route becomes an operation with its name as `operationId`, path parameters, query parameters and request body from the validators, and the `KitrError` schema for `422`. - `app/api/routes.gen.ts` (or `server/routes.gen.ts` when there is no `app/api/` folder): the route table for `registerRoutes`. It prints `5 routes → openapi.json, app/api/routes.gen.ts`. Zod 4 schemas convert to JSON Schema automatically; valibot needs `@valibot/to-json-schema` installed; arktype converts itself. Schemas that can't be converted are left out. Describe operations with `meta`: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' import OrdersController from '#server/controllers/orders_controller' export const routes = defineRoutes((router) => router.post('/api/orders/:id/refund', [OrdersController, 'refund'], { name: 'orders.refund', meta: { summary: 'Refund an order', tags: ['billing'], description: 'Refunds the full total to the card on file.' }, }), ) export type AppRoutes = typeof routes.$types ``` Build the document in code with `toOpenApi()`, for example to serve it from a route: ```ts // server/main.ts import { toOpenApi } from 'kitr/api' // ... server.h3.get('/openapi.json', () => toOpenApi(routes, { title: 'Acme API', version: '1.0.0', servers: [{ url: 'https://app.acme.com' }], }), ) ``` | `toOpenApi` option | Default | Description | | --- | --- | --- | | `title` | `'kitr API'` | `info.title`. | | `version` | `'0.0.0'` | `info.version`. | | `description` | none | `info.description`. | | `servers` | none | The `servers` list. | | `schemaToJson` | zod, valibot, arktype | Function from a Standard Schema to JSON Schema, for other libraries. | Operations without `meta.tags` are tagged with the first part of the route name (`orders`). Every operation documents a `200` response, including those that answer `201`. ## Commands | Command | What it does | | --- | --- | | `kitr make:controller ` | Writes `server/controllers/_controller.ts` with one `handle` method. | | `kitr make:controller --resource` | Writes the controller with `index`, `show`, `store`, `update`, `destroy` and zod validators. | | `kitr make:middleware ` | Writes `app/middleware/.ts` with `defineMiddleware`. | | `kitr api:sync` | Writes the OpenAPI document and the client route table. `--file` picks the routes file, `--out` the document path. | | `kitr routes:list` | Prints every route: method, path and name. `--file` picks the routes file. | `kitr make:controller orders --resource` writes: ```ts // server/controllers/orders_controller.ts import type { HttpContext } from 'kitr/api' import { KitrError } from 'kitr/app' import { z } from 'zod' const createOrders = z.object({ name: z.string().min(2) }) const updateOrders = createOrders.partial() export default class OrdersController { static validation = { store: { body: createOrders }, update: { body: updateOrders }, } async index({ query }: HttpContext) { return { items: [], page: Number(query.page ?? 1) } } async show({ params }: HttpContext<{ id: string }>) { throw KitrError.notFound('Orders ' + params.id) } async store({ body, response }: HttpContext<{}, {}, z.infer>) { response.status(201) return { id: '1', ...body } } async update({ params, body }: HttpContext<{ id: string }, {}, z.infer>) { return { id: params.id, ...body } } async destroy({ params }: HttpContext<{ id: string }>) { void params } } ``` `kitr make:middleware audit` writes: ```ts // app/middleware/audit.ts import { defineMiddleware } from 'kitr/api' export const audit = defineMiddleware(async (ctx, next) => { // Before the handler const result = await next() // After the handler return result }) ``` `kitr routes:list` prints: ```text GET /api/health health GET /api/orders orders.index POST /api/orders orders.store GET /api/orders/:id orders.show ``` All three loaders look for `server/routes.ts`, then `app/api/routes.ts`, then `start/routes.ts`. Put a `stubs/controller.ts` in your project to change what `make:controller` writes. ## Errors Every error reaches the client as JSON in this shape, and the typed client turns it back into a `KitrError`. | Code | When | What to do | | --- | --- | --- | | `VALIDATION` (422) | A `params`, `query` or `body` schema failed, or `ctx.validate()` failed. `fieldErrors` lists every problem. | Show `fieldErrors` on the form; [Forms](/packages/forms) does it for you. | | `BAD_REQUEST` (400) | The body is malformed JSON, `requireJson()` saw another content type, or the client was missing a path parameter. | Send valid JSON with `content-type: application/json`. | | `UNAUTHORIZED` (401) | `authMiddleware` found no valid token, or a handler threw it. | Sign in, or send the token with `getToken`. | | `FORBIDDEN` (403) | A permission check failed. | Check the user's abilities. | | `NOT_FOUND` (404) | No route matches, a controller method is missing, or a handler threw it. | Check the path with `kitr routes:list`. | | `CONFLICT` (409) | A handler threw it, such as for a duplicate. | Show the message; it is meant for the user. | | `RATE_LIMITED` (429) | `rateLimit()` saw too many requests. | Wait for `ratelimit-reset` seconds. | | `INTERNAL` (500) | A handler threw something that is not a `KitrError`, or the client called an unregistered route name. | Read the server log line with the request id; run `kitr api:sync`. | | `NETWORK` (0) | The client got no response. | Check the connection and CORS. | ## Testing Test routes through the server with `createTestClient` from [Testing](/packages/testing), or with the typed client pointed at `server.fetch`. No port is opened: ```ts // test/orders.test.ts import { createApiClient, registerRoutes } from 'kitr/api/client' import { createTestClient } from 'kitr/testing' import { describe, expect, it } from 'vitest' import { server } from '#server/main' import { type AppRoutes, routes } from '#server/routes' const api = createApiClient({ baseUrl: 'http://test', fetch: (request) => server.fetch(request) }) registerRoutes(api, routes.toJSON()) describe('orders API', () => { it('lists and shows orders', async () => { const orders = await api.orders.index() expect(orders.map((order) => order.customer)).toContain('Ada') const order = await api.orders.show({ params: { id: 'ord_1042' } }) expect(order).toMatchObject({ customer: 'Ada', total: 129 }) }) it('creates an order with 201', async () => { const res = await createTestClient(server).post('/api/orders', { customer: 'Grace', total: 480 }) expect(res.status).toBe(201) expect(res.body).toMatchObject({ customer: 'Grace', total: 480, status: 'open' }) }) it('rejects an invalid order with field errors', async () => { await expect(api.orders.store({ body: { customer: 'A', total: -1 } })).rejects.toMatchObject({ code: 'VALIDATION', status: 422, fieldErrors: { customer: expect.any(Array), total: expect.any(Array) }, }) }) it('answers 404 for an unknown order', async () => { await expect(api.orders.show({ params: { id: 'ord_9999' } })).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Order not found', }) }) }) ``` To test client code without a server, pass a fake `fetch` and assert on the requests it receives: ```ts // test/order_client.test.ts import { createApiClient, registerRoutes } from 'kitr/api/client' import { expect, it, vi } from 'vitest' import type { AppRoutes } from '#server/routes' it('sends the token and the JSON body', async () => { const fetch = vi.fn(async (_request: Request) => Response.json({ id: 'ord_1043' }, { status: 201 })) const api = createApiClient({ baseUrl: 'https://app.acme.com', fetch, getToken: () => 'tok_ada' }) registerRoutes(api, [{ name: 'orders.store', method: 'POST', path: '/api/orders' }]) await api.orders.store({ body: { customer: 'Ada', total: 129 } }) const request = fetch.mock.calls[0]![0] expect(request.url).toBe('https://app.acme.com/api/orders') expect(request.headers.get('authorization')).toBe('Bearer tok_ada') expect(await request.json()).toEqual({ customer: 'Ada', total: 129 }) }) ``` To test one middleware, call it with a minimal context and a `next` function. Each test file gets a fresh module, so in-memory `rateLimit()` counters start at zero. ## Next steps - [API reference](/packages/api/api) - [Server](/packages/server): ports, request context, errors and shutdown - [Auth](/packages/auth): `authMiddleware` and permissions for these routes - [Query](/packages/query): the contracts the client produces - [Forms](/packages/forms): showing `fieldErrors` on fields - [A backend without React](/docs/pure-backend) # Auth API Source: https://kitr.dev/packages/auth/api ## Configuration ```ts // config/auth.ts defineConfig({ adapter: AuthAdapter, // required signInPath?: string, // where guards send anonymous users eager?: boolean, // load the session before first render; default true }) ``` The provider binds `auth` in the container and, unless `eager: false`, restores the session during boot. ## Session ```ts interface Session { user: TUser | null expiresAt?: number // epoch ms, when known can(ability: string, subject?: unknown): boolean } ``` The router's `requireAuth()` and `can()` guards read `context.session`, which the provider supplies. ## Hooks | Hook | Returns | | --- | --- | | `useAuth()` | The `Auth` object: `session`, `user`, `load()`, `signIn(credentials)`, `signOut()`, `refresh()`, `getToken()`, `subscribe(listener)`. | | `useSession()` | The live `Session`. Re-renders on sign-in and sign-out. | | `useUser()` | `session.user`. | | `useCan()` | `session.can`. | ## Adapters ### tokenAdapter ```ts tokenAdapter({ fetchUser: (token) => Promise, // required signIn?: (credentials) => Promise<{ token; refreshToken?; user? }>, refresh?: (refreshToken) => Promise<{ token; refreshToken? } | null>, signOut?: (token) => Promise, storage?: TokenStorage, // default memoryStorage() refreshStorage?: TokenStorage, // default memoryStorage() abilities?: Abilities, }) ``` `memoryStorage()` and `localStorageTokens(key = 'kitr:token')` are the built-in `TokenStorage`s. Write your own with `{ get(), set(token) }`. ### betterAuthAdapter ```ts betterAuthAdapter(client, { abilities?, signIn?: (client, credentials) => Promise }) ``` Uses `client.signIn.email` unless you pass `signIn`. ### customAdapter ```ts customAdapter({ getSession: () => Promise | null>, // required signIn?, signOut?, refresh?, getToken?, onChange?, }) ``` Build sessions with `createSession(user, abilities?, { expiresAt? })`. ## defineAbilities ```ts const abilities = defineAbilities({ 'name': (user, subject?) => boolean }) abilities.can(user, 'name', subject) // false when user is null abilities.for(user)('name', subject) ``` Checks return `false` for an anonymous user, an unknown ability and a rule that throws. Annotate `user` in each rule instead of passing `` to keep the ability names typed. ## kitr/auth/server No React. Works with any `@kitr/api` router. ```ts authMiddleware({ verify: (token, ctx) => TUser | null | Promise, // required from?: 'bearer' | { cookie: string }, // default 'bearer' optional?: boolean, // let anonymous requests through abilities?: Abilities, }) requireUser(ctx): TUser // throws 401 when anonymous currentUser(ctx): TUser | null ``` The middleware sets `ctx.locals.user` and `ctx.locals.can(ability, subject)`, and throws `401 UNAUTHORIZED` when there is no valid user and `optional` is off. # Auth Source: https://kitr.dev/packages/auth Almost every screen in an app asks two questions: who is this, and are they allowed to do that? `kitr/auth` answers both, in the browser and on the server, with the same rules in both places. It gives the app one session to read, guards for pages, and permission checks written once as plain functions. It doesn't store passwords or run a login server. It connects to what you already have, such as your own API's token endpoint or [Better Auth](https://www.better-auth.com/docs), through an adapter. On top it adds React hooks, router guards for [Router](/packages/router), and middleware for [API routes](/packages/api). The session, hooks and adapters run in the browser. `kitr/auth/server` runs on the server and has no React dependency. If all you need is to protect API routes with a token your server checks, use `kitr/auth/server` alone. The examples on this page follow Acme's project tracker at `app.acme.com`. Ada is an admin, Grace and Linus are members, and members can edit only the projects they own. ## Install ```sh kitr add auth ``` What `kitr add auth` does: - Installs `@kitr/auth` and adds it to `packages` in `kitrrc.ts`. - Registers `() => import('@kitr/auth/provider')` in `providers` in `kitrrc.ts`. - Writes `app/abilities.ts`, with a `User` type and one permission rule. - Writes `config/auth.ts` (shown under Configure) and adds it to `config/index.ts`. - Adds a `kitr: auth` section to `AGENTS.md`. The generated permissions file: ```ts // app/abilities.ts import { defineAbilities } from 'kitr/auth' export interface User { id: string email: string role: 'admin' | 'member' } export const abilities = defineAbilities({ 'projects.update': (user: User, project: { ownerId: string }) => user.role === 'admin' || project.ownerId === user.id, }) ``` `kitr/auth` is a client package: in a server-only project, `kitr add auth` skips it. Use `kitr/auth/server` from `@kitr/auth` there by installing the package yourself. The `app`, `fullstack`, `saas`, `desktop` and `mobile` kits include auth already. `kitr remove auth` unregisters the provider, removes `@kitr/auth` from `package.json` and `kitrrc.ts`, deletes `config/auth.ts` and drops the `AGENTS.md` section. It keeps `app/abilities.ts` and lists the files that still import `kitr/auth`. ## Configure `kitr add auth` writes this file: ```ts // config/auth.ts import { defineConfig, localStorageTokens, tokenAdapter } from 'kitr/auth' import { type User, abilities } from '#app/abilities' import { env } from '#app/env' // Swap for betterAuthAdapter(createAuthClient()) or a customAdapter({...}). const adapter = tokenAdapter({ abilities, storage: localStorageTokens(), fetchUser: async (token) => { const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } }) return res.ok ? ((await res.json()) as User) : null }, signIn: async (credentials) => { const res = await fetch(`${env.VITE_API_URL}/sign-in`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(credentials) }) if (!res.ok) throw new Error('Invalid credentials') return (await res.json()) as { token: string; refreshToken?: string } }, }) export default defineConfig({ adapter, signInPath: '/sign-in' }) ``` | Option | Default | Description | | --- | --- | --- | | `adapter` | required | Where sessions come from: `tokenAdapter`, `betterAuthAdapter` or `customAdapter`. See "Adapters". | | `signInPath` | `'/sign-in'` | Documents your sign-in page. The router guard does not read it; pass the path to `requireAuth({ to })` when it differs from `/sign-in`. | | `eager` | `true` | Restore the session while the app boots, before the first render. `false` waits until you call `auth.load()`. | | Variable | Used for | | --- | --- | | `VITE_API_URL` | The API that answers `/me` and `/sign-in` in the generated config. | `kitr add auth` does not declare `VITE_API_URL`. The [HTTP](/packages/http) recipe does, and the `app` kit includes it. Without HTTP, add it to `app/env.ts` yourself: ```ts // app/env.ts import { createEnv, env as e } from 'kitr/env' export const env = createEnv({ public: { VITE_APP_NAME: e.string({ default: 'acme' }), VITE_API_URL: e.url({ default: 'https://app.acme.com/api' }), }, server: {}, }) ``` ## Show who is signed in With the provider registered, every component can read the session. Show Ada her name and a sign-out button: ```tsx // app/components/account_menu.tsx import { useAuth, useUser } from 'kitr/auth' import { Link } from 'kitr/router' import type { User } from '#app/abilities' export function AccountMenu() { const auth = useAuth() const user = useUser() if (!user) return Sign in return ( ) } ``` Render `` in your layout. Before sign-in it shows "Sign in"; after Ada signs in it shows "Sign out ada@acme.com". The component re-renders on every sign-in and sign-out, in any part of the app. | Hook | Returns | | --- | --- | | `useAuth()` | The `auth` service: `signIn`, `signOut`, `refresh`, `load`, `getToken`, `session`, `user`. | | `useSession()` | The current session: `{ user, expiresAt, can }`. | | `useUser()` | The signed-in user, or `null`. | | `useCan()` | The session's `can(ability, subject)` function. | ## Sign users in Call `auth.signIn(credentials)` with whatever your adapter expects. It resolves with the new session and throws when the credentials are wrong: ```tsx // app/routes/sign-in.tsx import { createFileRoute } from '@tanstack/react-router' import { useAuth } from 'kitr/auth' import { useNavigate } from 'kitr/router' import { type FormEvent, useState } from 'react' import type { User } from '#app/abilities' export const Route = createFileRoute('/sign-in')({ validateSearch: (search: Record) => ({ redirect: typeof search.redirect === 'string' ? search.redirect : undefined, }), component: SignInPage, }) function SignInPage() { const { redirect } = Route.useSearch() const navigate = useNavigate() const auth = useAuth() const [error, setError] = useState(null) async function onSubmit(event: FormEvent) { event.preventDefault() const data = new FormData(event.currentTarget) try { await auth.signIn({ email: String(data.get('email')), password: String(data.get('password')) }) await navigate({ to: redirect ?? '/' }) } catch (err) { setError(err instanceof Error ? err.message : 'Sign-in failed') } } return ( {error &&

{error}

} ) } ``` `redirect` is the page the visitor tried to open before the guard sent them here. With the generated config, wrong credentials throw `Invalid credentials`. With [Forms](/packages/forms), pass `auth.signIn` to the form's `onSubmit` and the error shows at the top of the form. ## Sign users out ```tsx // app/components/sign_out_button.tsx import { useAuth } from 'kitr/auth' import { useNavigate } from 'kitr/router' export function SignOutButton() { const auth = useAuth() const navigate = useNavigate() return ( ) } ``` `signOut()` calls the adapter's `signOut` (for tokens, your API's sign-out endpoint when you configure one), clears stored tokens and sets the session to anonymous. `tokenAdapter` ignores a failing sign-out endpoint, so the user is always signed out locally. With `customAdapter` and `betterAuthAdapter`, an error from the adapter's `signOut` rejects and leaves the session as it was. ## Adapters An adapter turns your backend into a session. Every adapter produces the same `Session`, so hooks, guards and permissions work the same with any of them. ### Your own API with tokens `tokenAdapter` works with any backend that issues bearer tokens: an Acme API built with [API routes](/packages/api), a JWT service, or another framework. It needs `fetchUser`; `signIn`, `refresh` and `signOut` are optional: ```ts // config/auth.ts import { defineConfig, localStorageTokens, tokenAdapter } from 'kitr/auth' import { type User, abilities } from '#app/abilities' import { env } from '#app/env' const adapter = tokenAdapter({ abilities, storage: localStorageTokens(), fetchUser: async (token) => { const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } }) return res.ok ? ((await res.json()) as User) : null }, signIn: async (credentials) => { const res = await fetch(`${env.VITE_API_URL}/sign-in`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(credentials), }) if (!res.ok) throw new Error('Wrong email or password') return (await res.json()) as { token: string; refreshToken?: string; user?: User } }, signOut: async (token) => { await fetch(`${env.VITE_API_URL}/sign-out`, { method: 'POST', headers: { authorization: `Bearer ${token}` } }) }, }) export default defineConfig({ adapter }) ``` | Option | Default | Description | | --- | --- | --- | | `fetchUser` | required | `(token) => user or null`. Called on boot and after sign-in. Return `null` for an invalid token. | | `signIn` | none | `(credentials) => { token, refreshToken?, user? }`. When it returns `user`, `fetchUser` is skipped. | | `refresh` | none | `(refreshToken) => { token, refreshToken? } or null`. See "Refresh expired tokens". | | `signOut` | none | `(token) => void`. Tells your API to revoke the token. | | `storage` | memory | Where the access token lives. See "Keep users signed in across reloads". | | `refreshStorage` | memory | Where the refresh token lives. | | `abilities` | none | Your `defineAbilities` result, for `session.can`. Without it, `can()` is true for any signed-in user. | The second type parameter types the credentials: `tokenAdapter` for a one-time-code sign-in. The default is `{ email: string; password: string }`. ### Better Auth `betterAuthAdapter` wraps a [Better Auth client](https://www.better-auth.com/docs/concepts/client). The session lives in Better Auth's cookie, so nothing is stored in the browser: ```sh pnpm add better-auth ``` ```ts // config/auth.ts import { createAuthClient } from 'better-auth/react' import { betterAuthAdapter, defineConfig } from 'kitr/auth' import { type User, abilities } from '#app/abilities' const client = createAuthClient({ baseURL: 'https://app.acme.com' }) export default defineConfig({ adapter: betterAuthAdapter(client, { abilities }), }) ``` `signIn({ email, password })` calls `client.signIn.email()` and then reads the session. Pass your own `signIn` to use another method, such as a social provider: ```ts // config/auth.ts import { createAuthClient } from 'better-auth/react' import { betterAuthAdapter, defineConfig } from 'kitr/auth' import { type User, abilities } from '#app/abilities' const client = createAuthClient({ baseURL: 'https://app.acme.com' }) export default defineConfig({ adapter: betterAuthAdapter(client, { abilities, signIn: (c, { provider }) => c.signIn!.social({ provider }), }), }) ``` The session's `expiresAt` comes from Better Auth's session. Better Auth runs its own server; see [its installation guide](https://www.better-auth.com/docs/installation). ### Any other backend `customAdapter` takes the functions you have. Only `getSession` is required. Build the session with `createSession(user, abilities)`: ```ts // config/auth.ts import { createSession, customAdapter, defineConfig } from 'kitr/auth' import { type User, abilities } from '#app/abilities' const adapter = customAdapter({ async getSession() { const res = await fetch('https://app.acme.com/api/session', { credentials: 'include' }) if (!res.ok) return null return createSession((await res.json()) as User, abilities) }, async signIn(credentials) { const res = await fetch('https://app.acme.com/api/session', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(credentials), }) if (!res.ok) throw new Error('Wrong email or password') return createSession((await res.json()) as User, abilities) }, async signOut() { await fetch('https://app.acme.com/api/session', { method: 'DELETE', credentials: 'include' }) }, }) export default defineConfig({ adapter }) ``` For a reusable adapter, such as one for Firebase or Clerk, see "Extend". ## Keep users signed in across reloads `tokenAdapter` keeps tokens in memory by default. That is the safest place, but the user is signed out when the page reloads. `localStorageTokens()` keeps the access token across reloads, under the key `kitr:token`: ```ts // config/auth.ts import { defineConfig, localStorageTokens, memoryStorage, tokenAdapter } from 'kitr/auth' import { type User, abilities } from '#app/abilities' import { env } from '#app/env' const adapter = tokenAdapter({ abilities, storage: localStorageTokens('acme:token'), refreshStorage: memoryStorage(), fetchUser: async (token) => { const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } }) return res.ok ? ((await res.json()) as User) : null }, }) export default defineConfig({ adapter }) ``` > **Warning** > Any script on your page can read `localStorage`, so an XSS bug exposes the token. Keep access tokens short-lived, keep refresh tokens in memory, or use a cookie session (Better Auth or `customAdapter`) for sensitive apps. Storage that throws, such as in a private window, is ignored: reads return `null`. To store tokens elsewhere, such as [Secure storage](/packages/secure-storage) in a mobile app, pass any object with `get()` and `set(token)`: ```ts // app/auth/secure_tokens.ts import type { TokenStorage } from 'kitr/auth' import { app } from '#app/app' export const secureTokens: TokenStorage = { get: () => app.container.use('secureStorage').get('acme.token'), set: (token) => token === null ? app.container.use('secureStorage').remove('acme.token') : app.container.use('secureStorage').set('acme.token', token), } ``` Both methods may return a promise. ## Refresh expired tokens Give `tokenAdapter` a `refresh` function, and `auth.refresh()` swaps the stored refresh token for a new access token: ```ts // config/auth.ts import { defineConfig, localStorageTokens, tokenAdapter } from 'kitr/auth' import { type User, abilities } from '#app/abilities' import { env } from '#app/env' const adapter = tokenAdapter({ abilities, storage: localStorageTokens(), fetchUser: async (token) => { const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } }) return res.ok ? ((await res.json()) as User) : null }, signIn: async (credentials) => { const res = await fetch(`${env.VITE_API_URL}/sign-in`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(credentials), }) if (!res.ok) throw new Error('Wrong email or password') return (await res.json()) as { token: string; refreshToken: string } }, refresh: async (refreshToken) => { const res = await fetch(`${env.VITE_API_URL}/refresh`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ refreshToken }), }) return res.ok ? ((await res.json()) as { token: string; refreshToken?: string }) : null }, }) export default defineConfig({ adapter }) ``` `auth.refresh()` resolves `true` when a new token was stored. It resolves `false`, and signs the user out, when there is no refresh token, when `refresh` returns `null` or throws, or when the adapter has no `refresh` at all. Don't call `refresh()` to check whether a user is signed in; read `auth.user`. You rarely call it yourself. Point the HTTP client at it, and a `401` triggers one refresh and a retry (next section). ## Send the token with every request [HTTP](/packages/http) takes the token and the refresh function from the `auth` service: ```ts // config/http.ts import { defineConfig } from 'kitr/http' import { app } from '#app/app' import { env } from '#app/env' export default defineConfig({ baseUrl: env.VITE_API_URL, getToken: () => app.container.use('auth').getToken(), refresh: () => app.container.use('auth').refresh(), }) ``` Every request sends `Authorization: Bearer `. On a `401`, the client calls `refresh()` once and retries; when refresh fails, the user is signed out and the request rejects with `UNAUTHORIZED`. The typed [API client](/packages/api) takes the same `getToken` option. `auth.getToken()` resolves `null` for adapters without tokens, such as Better Auth, whose cookie the browser sends for you. ## Protect pages Pages are guarded in `beforeLoad` with `requireAuth()` from [Router](/packages/router). It reads `session` from the router context and redirects anonymous visitors. First, put the live session in the router context: ```tsx // app/router.tsx import type { Session } from 'kitr/auth' import { createKitrRouter } from 'kitr/router' import type { User } from '#app/abilities' import { app } from './app' import { routeTree } from './routeTree.gen' export function getRouter() { return createKitrRouter({ routeTree, app, context: { // A live getter, so `requireAuth()` sees the session the auth provider restores during boot. get session() { return app.container.has('auth') ? (app.container.use('auth').session as Session) : null }, }, }) } declare module '@tanstack/react-router' { interface Register { router: ReturnType } } declare module '@kitr/router' { interface KitrRouterContext { session: Session | null } } ``` Then guard a layout route. Every page under `_app` needs a signed-in user: ```tsx // app/routes/_app.tsx import { createFileRoute, Outlet } from '@tanstack/react-router' import { requireAuth } from 'kitr/router' export const Route = createFileRoute('/_app')({ beforeLoad: requireAuth(), component: () => , }) ``` An anonymous visitor to `/projects` goes to `/sign-in`, with the `redirect` search parameter set to `/projects`. The sign-in page above sends them back after they sign in. `requireAuth({ to: '/login', redirectParam: 'next' })` changes the path and the parameter name; `redirectParam: false` leaves the parameter off. The router may copy its context object when it is created, which reads the getter once. When the session changes after the router exists, read it from the container in the guard so it is always current: ```tsx // app/routes/_app.tsx import { createFileRoute, Outlet } from '@tanstack/react-router' import type { Session } from 'kitr/auth' import { requireAuth } from 'kitr/router' import type { User } from '#app/abilities' export const Route = createFileRoute('/_app')({ beforeLoad: async ({ context, location }) => { await context.app.boot() const session = context.app.container.use('auth').session as Session return requireAuth()({ context: { session }, location }) }, component: () => , }) ``` The `saas` kit uses this form. With tokens in `localStorage` the server can't see the session, so run guarded routes on the client only, for example with `ssrRoute(false)` from [SEO](/packages/seo). ## Define permissions Write each rule once, as a function of the user and an optional subject, in `app/abilities.ts`: ```ts // app/abilities.ts import { defineAbilities } from 'kitr/auth' export interface User { id: string email: string role: 'admin' | 'member' teamId: string } export interface Project { id: string ownerId: string teamId: string } export const abilities = defineAbilities({ 'projects.update': (user: User, project: Project) => user.role === 'admin' || project.ownerId === user.id, 'projects.view': (user: User, project: Project) => project.teamId === user.teamId, 'invoices.view': (user: User) => user.role === 'admin', 'teams.invite': (user: User) => user.role === 'admin', }) ``` Annotate `user` in each rule and leave off the type argument. TypeScript then knows the ability names, so `abilities.can(user, 'projects.updte')` fails to compile. `defineAbilities({...})` also works, but the names widen to `string`. Check a rule anywhere, on the client or the server: ```ts // app/features/projects/permissions.ts import { abilities, type Project, type User } from '#app/abilities' const ada: User = { id: 'u_ada', email: 'ada@acme.com', role: 'admin', teamId: 't_core' } const grace: User = { id: 'u_grace', email: 'grace@acme.com', role: 'member', teamId: 't_core' } const roadmap: Project = { id: 'p_roadmap', ownerId: 'u_ada', teamId: 't_core' } abilities.can(ada, 'projects.update', roadmap) // => true abilities.can(grace, 'projects.update', roadmap) // => false abilities.can(null, 'projects.view', roadmap) // => false const graceCan = abilities.for(grace) graceCan('projects.view', roadmap) // => true ``` Three rules hold for every check: an anonymous user (`null` or `undefined`) never passes, an ability that doesn't exist returns `false`, and a rule that throws returns `false`. ## Check permissions in components `useCan()` returns the session's `can`, bound to the signed-in user. Use it to hide what the user can't do: ```tsx // app/features/projects/project_header.tsx import { useCan } from 'kitr/auth' import type { Project } from '#app/abilities' export function ProjectHeader({ project }: { project: Project }) { const can = useCan() return (

Roadmap

{can('projects.update', project) && } {can('teams.invite') && }
) } ``` Grace sees "Edit project" on her own projects only; Ada sees both buttons everywhere. Hiding a button is not security: check the same ability on the server too (see "Protect API routes"). ## Check permissions on routes `can()` from [Router](/packages/router) blocks a page for users without an ability. It reads `context.session` like `requireAuth()`: ```tsx // app/routes/_app/invoices.tsx import { createFileRoute } from '@tanstack/react-router' import { can } from 'kitr/router' export const Route = createFileRoute('/_app/invoices')({ beforeLoad: can('invoices.view'), component: () =>

Invoices

, }) ``` Grace gets the route's error screen with a `FORBIDDEN` error, `Missing permission "invoices.view"`. Pass `{ to: '/' }` to redirect instead, or `{ subject }` to check against one record. Don't confuse it with `can` from `kitr/auth/server`, which is API middleware. ## Use the session outside components The provider binds the `auth` service in the container. Use it in loaders, stores and services: ```ts // app/features/projects/loaders.ts import { app } from '#app/app' const auth = app.container.use('auth') auth.user // => { id: 'u_ada', email: 'ada@acme.com', role: 'admin', teamId: 't_core' } auth.session.can('invoices.view') // => true const stop = auth.subscribe((session) => { console.log(session.user ? `signed in as ${(session.user as { email: string }).email}` : 'signed out') }) await auth.signOut() // logs: signed out stop() ``` | Member | Description | | --- | --- | | `session` | The current session; anonymous is `{ user: null, can: () => false }`. | | `user` | `session.user`. | | `load()` | Asks the adapter for the session again. Errors become an anonymous session. | | `signIn(credentials)` | Signs in and resolves with the session. | | `signOut()` | Signs out and clears the local session. | | `refresh()` | Refreshes tokens; resolves `false` and signs out when it can't. | | `getToken()` | The bearer token, or `null`. | | `subscribe(listener)` | Calls `listener(session)` on every change, once per change. Returns an unsubscribe function. | | `toContext()` | `{ session }` with a live getter, for router context. | | `adapter` | The adapter from the config. | With `eager: false`, the session stays anonymous until something calls `auth.load()`, such as a guard on the first protected route. Use it when the session check is slow and most pages are public. ## Protect API routes On the server, `authMiddleware` from `kitr/auth/server` reads the token, finds the user and puts them on `ctx.locals`. It works with any [API routes](/packages/api) router: ```ts // server/routes.ts import { cors, defineRoutes } from 'kitr/api' import { authMiddleware } from 'kitr/auth/server' import { abilities, type User } from '#app/abilities' import ProjectsController from '#server/controllers/projects_controller' import { findUserByToken } from '#server/services/sessions' const auth = authMiddleware({ verify: (token) => findUserByToken(token), abilities }) export const routes = defineRoutes((router) => router.group({ prefix: '/api', middleware: [cors(), auth] }, (api) => api.resource('projects', ProjectsController), ), ) export type AppRoutes = typeof routes.$types ``` A request without a token, or with one `verify` rejects, answers `401`: ```json { "code": "UNAUTHORIZED", "message": "Authentication required", "status": 401 } ``` | Option | Default | Description | | --- | --- | --- | | `verify` | required | `(token, ctx) => user or null`. Look the token up, or verify a JWT. May be async. | | `from` | `'bearer'` | `'bearer'` reads `Authorization: Bearer `; `{ cookie: 'name' }` reads a cookie. | | `optional` | `false` | Let anonymous requests through with `ctx.locals.user = null`. | | `abilities` | none | Your abilities, for `ctx.locals.can`. Without it, `can()` is true for any signed-in user. | Import the same `abilities` from `app/abilities.ts` on both sides, so the browser and the API agree on every rule. ## Read the user in a controller `requireUser(ctx)` returns the user `authMiddleware` set, typed, or throws `401`. `currentUser(ctx)` returns `null` instead, for routes with `optional: true`: ```ts // server/controllers/projects_controller.ts import type { HttpContext } from 'kitr/api' import { KitrError } from 'kitr/app' import { currentUser, requireUser } from 'kitr/auth/server' import { abilities, type Project, type User } from '#app/abilities' const projects = new Map([['p_roadmap', { id: 'p_roadmap', ownerId: 'u_ada', teamId: 't_core' }]]) export default class ProjectsController { async index(ctx: HttpContext) { const user = currentUser(ctx) return [...projects.values()].filter((project) => abilities.can(user, 'projects.view', project)) } async update(ctx: HttpContext<{ id: string }>) { const user = requireUser(ctx) const project = projects.get(ctx.params.id) if (!project) throw KitrError.notFound('Project') if (!abilities.can(user, 'projects.update', project)) throw KitrError.forbidden() return { ...project, updatedBy: user.id } } } ``` `authMiddleware` also sets `ctx.locals.can(ability, subject)`, bound to the request's user, for code that has the context but not the user. ## Require a permission on an API route `can()` from `kitr/auth/server` is middleware. It loads the subject from the context, checks the ability, and answers `403` before the handler runs: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' import { authMiddleware, can } from 'kitr/auth/server' import { abilities, type User } from '#app/abilities' import InvoicesController from '#server/controllers/invoices_controller' import ProjectsController from '#server/controllers/projects_controller' import { findProject } from '#server/services/projects' import { findUserByToken } from '#server/services/sessions' const auth = authMiddleware({ verify: (token) => findUserByToken(token), abilities }) export const routes = defineRoutes((router) => router.group({ prefix: '/api', middleware: [auth] }, (api) => api .get('/invoices', [InvoicesController, 'index'], { name: 'invoices.index', middleware: [can('invoices.view')], }) .patch('/projects/:id', [ProjectsController, 'update'], { name: 'projects.update', middleware: [can('projects.update', (ctx) => findProject(ctx.params.id))], }), ), ) export type AppRoutes = typeof routes.$types ``` Grace calling `GET /api/invoices` gets: ```json { "code": "FORBIDDEN", "message": "Missing permission \"invoices.view\"", "status": 403 } ``` Put `can()` after `authMiddleware`; on its own it finds no user and answers `403` for everyone. ## Read the session from a cookie For cookie sessions, such as a cookie your sign-in endpoint sets, read the token from the cookie by name: ```ts // server/middleware/auth.ts import { authMiddleware } from 'kitr/auth/server' import { abilities, type User } from '#app/abilities' import { findUserBySession } from '#server/services/sessions' export const auth = authMiddleware({ from: { cookie: 'acme_session' }, verify: (sessionId) => findUserBySession(sessionId), abilities, }) export const optionalAuth = authMiddleware({ from: { cookie: 'acme_session' }, optional: true, verify: (sessionId) => findUserBySession(sessionId), abilities, }) ``` The cookie value is URL-decoded before `verify` sees it. A malformed value counts as no cookie, not as a server error. Use `optionalAuth` on public pages that show more to signed-in users, such as a project page with an edit link for its owner. > **Warning** > Cookie sessions need CSRF protection on writes, because the browser sends the cookie with requests other sites trigger. Turn on CSRF in [Shield](/packages/shield). ## Errors | Code | When | What to do | | --- | --- | --- | | `UNAUTHORIZED` (401) | `authMiddleware` found no token or `verify` returned `null`; `requireUser()` found no user. Message: `Authentication required`. | Sign in, or send the token with `getToken`. | | `FORBIDDEN` (403) | `can()` middleware or the router's `can()` guard failed. Message: `Missing permission ""`. | Check the user's role, or hide the action with `useCan()`. | | `UNAUTHORIZED` (401) | `tokenAdapter`'s `signIn` returned a token, but neither it nor `fetchUser` returned a user. Message: `Sign-in succeeded but no user was returned`. Stored tokens are cleared. | Make `/me` accept the new token, or return `user` from `signIn`. | | `UNAUTHORIZED` (401) | `betterAuthAdapter` signed in but Better Auth has no session. Message: `Sign-in failed`. | Check Better Auth's cookie settings and base URL. | | `INTERNAL` (500) | `signIn()` on a `tokenAdapter` without `signIn` (`tokenAdapter: signIn not configured`), or a `betterAuthAdapter` with no email sign-in (`betterAuthAdapter: no signIn configured`). | Configure `signIn` on the adapter. | `auth.signIn()` on a custom adapter without `signIn` throws a plain `Error`: `[kitr/auth] adapter has no signIn`. Errors your own `signIn` throws, such as `Invalid credentials` in the generated config, reach the caller unchanged. Throw `KitrError.unauthorized('Wrong email or password')` from `kitr/app` instead to get a kitr error with a `401` status. ## Testing Test with a fake adapter: `customAdapter` with an in-memory user, booted with `createTestApp` from [Testing](/packages/testing). No network, no storage: ```tsx // test/auth.test.tsx import { act, screen } from '@testing-library/react' import { createSession, customAdapter, useCan, useUser } from 'kitr/auth' import authProvider from 'kitr/auth/provider' import { createTestApp } from 'kitr/testing' import { renderWithKitr } from 'kitr/testing/react' import { describe, expect, it } from 'vitest' import { abilities, type User } from '#app/abilities' const ada: User = { id: 'u_ada', email: 'ada@acme.com', role: 'admin', teamId: 't_core' } const grace: User = { id: 'u_grace', email: 'grace@acme.com', role: 'member', teamId: 't_core' } function fakeAdapter(start: User | null) { let current = start const people: Record = { 'ada@acme.com': ada, 'grace@acme.com': grace } return customAdapter({ getSession: async () => (current ? createSession(current, abilities) : null), signIn: async ({ email }) => { current = people[email] ?? null if (!current) throw new Error('Wrong email or password') return createSession(current, abilities) }, signOut: async () => { current = null }, }) } function InvoicesLink() { const user = useUser() const can = useCan() if (!user) return

Signed out

return

{can('invoices.view') ? `${user.email} sees invoices` : `${user.email} has no invoices`}

} describe('auth', () => { it('restores the session on boot', async () => { const app = await createTestApp({ providers: [authProvider], config: { auth: { adapter: fakeAdapter(ada) } } }) expect(app.container.use('auth').user).toEqual(ada) }) it('follows sign-in and sign-out in components', async () => { const app = await createTestApp({ providers: [authProvider], config: { auth: { adapter: fakeAdapter(null) } } }) renderWithKitr(, { app }) expect(screen.getByText('Signed out')).toBeTruthy() await act(() => app.container.use('auth').signIn({ email: 'grace@acme.com' })) expect(screen.getByText('grace@acme.com has no invoices')).toBeTruthy() await act(() => app.container.use('auth').signOut()) await act(() => app.container.use('auth').signIn({ email: 'ada@acme.com' })) expect(screen.getByText('ada@acme.com sees invoices')).toBeTruthy() }) it('rejects unknown people', async () => { const app = await createTestApp({ providers: [authProvider], config: { auth: { adapter: fakeAdapter(null) } } }) await expect(app.container.use('auth').signIn({ email: 'linus@acme.com' })).rejects.toThrow('Wrong email or password') expect(app.container.use('auth').user).toBeNull() }) }) ``` Test the server middleware by calling it with a minimal context. It needs only `request.header()` and `locals`: ```ts // test/auth_middleware.test.ts import { authMiddleware, can, requireUser } from 'kitr/auth/server' import { describe, expect, it } from 'vitest' import { abilities, type User } from '#app/abilities' const grace: User = { id: 'u_grace', email: 'grace@acme.com', role: 'member', teamId: 't_core' } const auth = authMiddleware({ verify: (token) => (token === 'tok_grace' ? grace : null), abilities }) const ctx = (authorization: string | null) => ({ request: { header: (name: string) => (name === 'authorization' ? authorization : null) }, locals: {} as Record, }) describe('auth middleware', () => { it('rejects a request without a token', async () => { await expect(auth(ctx(null), async () => 'handler')).rejects.toMatchObject({ code: 'UNAUTHORIZED', status: 401 }) }) it('sets the user for a valid token', async () => { const request = ctx('Bearer tok_grace') expect(await auth(request, async () => 'handler')).toBe('handler') expect(requireUser(request).email).toBe('grace@acme.com') }) it('blocks members from invoices', async () => { const request = ctx('Bearer tok_grace') await auth(request, async () => null) await expect(can('invoices.view')(request, async () => 'handler')).rejects.toMatchObject({ code: 'FORBIDDEN', message: 'Missing permission "invoices.view"', }) }) }) ``` For full request tests through the server, send `authorization: Bearer ` with `createTestClient(server, { headers })` from [Testing](/packages/testing). ## Extend Write an adapter when your identity provider has its own client, such as Firebase, Clerk or a company SSO. An adapter implements the `AuthAdapter` interface; see [`AuthAdapter`](/packages/auth/api) in the API reference: | Member | Required | Description | | --- | --- | --- | | `getSession()` | yes | Resolves the current session, or `null` when anonymous. Called on boot and by `auth.load()`. | | `signIn(credentials)` | no | Signs in and resolves the new session. | | `signOut()` | no | Ends the session at the provider. | | `refresh()` | no | Renews tokens; resolves `true` when the session is still valid. | | `getToken()` | no | A bearer token for API calls, or `null`. | | `onChange(listener)` | no | Reports changes from elsewhere, such as another tab. Returns an unsubscribe function. | An adapter for Acme's single sign-on service, which keeps the session in another tab-aware client: ```ts // app/auth/acme_sso_adapter.ts import { type Abilities, type AuthAdapter, createSession } from 'kitr/auth' interface AcmeSsoClient { currentUser(): Promise<{ id: string; email: string; role: 'admin' | 'member'; teamId: string } | null> login(options: { email: string }): Promise logout(): Promise accessToken(): Promise onAuthStateChanged(listener: (user: unknown) => void): () => void } export function acmeSsoAdapter( client: AcmeSsoClient, abilities?: Abilities, ): AuthAdapter { const toSession = (user: unknown) => (user ? createSession(user as TUser, abilities) : null) return { getSession: async () => toSession(await client.currentUser()), async signIn({ email }) { await client.login({ email }) const session = toSession(await client.currentUser()) if (!session) throw new Error('Single sign-on did not return a user') return session }, signOut: () => client.logout(), getToken: () => client.accessToken(), onChange: (listener) => client.onAuthStateChanged((user) => listener(toSession(user))), } } ``` Register it in the config like any adapter: ```ts // config/auth.ts import { defineConfig } from 'kitr/auth' import { type User, abilities } from '#app/abilities' import { acmeSsoAdapter } from '#app/auth/acme_sso_adapter' import { ssoClient } from '#app/auth/sso_client' export default defineConfig({ adapter: acmeSsoAdapter(ssoClient, abilities) }) ``` Use `createSession(user, abilities, { expiresAt })` to build sessions, so `can()` follows your abilities. When `onChange` and `signIn` report the same session, listeners still run once. ## Devtools With [Devtools](/packages/devtools) installed, the Auth panel shows the session status (authenticated or anonymous), the user as JSON and `expiresAt`. Its badge reads `in` or `out`. Three actions run against the live app: Reload session (`auth.load()`), Refresh token (`auth.refresh()`) and Sign out. Every sign-in and sign-out is logged on the timeline as `signed in` or `signed out`, with the user. ## Next steps - [API reference](/packages/auth/api) - [Router](/packages/router): `requireAuth()`, `can()` and route context - [API routes](/packages/api): the routes `authMiddleware` protects - [HTTP](/packages/http): sending the token and refreshing on `401` - [Better Auth documentation](https://www.better-auth.com/docs) - [Building a full-stack app](/docs/fullstack) # Cache API Source: https://kitr.dev/packages/cache/api Every export of `kitr/cache`, `kitr/cache/provider`, read from the published type definitions. The [guide](/packages/cache) shows them in use. ## kitr/cache ### cached ```ts function cached(cache: Cache, fn: (...args: Args) => Promise | T, options: RememberOptions & { key: (...args: Args) => string; }): (...args: Args) => Promise; ``` Wrap a function so each distinct argument list is remembered under `key(...args)`. ### createCache ```ts function createCache(config?: CacheConfig): Cache; ``` Build a cache without an app: what the provider binds, and what tests and scripts call directly. ### createMemoryCacheDriver ```ts function createMemoryCacheDriver(): CacheDriver; ``` In-process cache with TTLs and tags. The default; fine for one server, wrong for several. ### useCache ```ts function useCache(app: KitrApp): Cache; ``` The app's cache, from the container. Pass `ctx.app` in a controller; throws if the cache provider is not registered. ### defineConfig ```ts const defineConfig: (config: C) => C; ``` Type the default export of `config/cache.ts`. Returns its argument unchanged. ### Cache ```ts interface Cache { /** The parsed value, or `undefined` on a miss or after expiry. */ get(key: string): Promise; /** Store a value as JSON (`undefined` is stored as `null`), with an optional ttl and tags. */ set(key: string, value: T, options?: RememberOptions): Promise; /** Whether a live entry exists, including one holding `null`. */ has(key: string): Promise; /** Remove one entry. */ delete(key: string): Promise; /** Return the cached value, or run `fn`, store its result and return it. Concurrent calls share one run. */ remember(key: string, fn: () => Promise | T, options?: RememberOptions): Promise; /** Remove every entry tagged with any of `tags`, whatever its key. */ invalidateTags(...tags: string[]): Promise; /** Remove every entry from the driver. */ clear(): Promise; /** The driver underneath, for its `name` or a raw read. */ readonly driver: CacheDriver; } ``` The cache service bound as `cache` in the container: JSON values over a driver, with TTLs and tags. ### CacheConfig ```ts interface CacheConfig { /** Where entries live. Default: `createMemoryCacheDriver()`. */ driver?: CacheDriver; /** Prepended to every key, so several apps can share one Redis. */ prefix?: string; /** Seconds, when `remember()` is called without a ttl. Default 300. */ defaultTtl?: number; } ``` The options in `config/cache.ts`, also accepted by `createCache()`. ### CacheDriver ```ts interface CacheDriver { /** Shown in devtools and logs, e.g. `memory` or `redis`. */ name: string; /** The stored string, or `undefined` when the key is missing or expired. */ get(key: string): Promise; /** `ttl` in seconds; `undefined` keeps the entry until deleted. */ set(key: string, value: string, ttl?: number): Promise; /** Remove one key and forget its tags. */ delete(key: string): Promise; /** Remove every key tagged with any of `tags`. */ invalidateTags(tags: string[]): Promise; /** Record the tags of a key so `invalidateTags` can find it. */ tag(key: string, tags: string[]): Promise; /** Remove every key and every tag. */ clear(): Promise; } ``` What a cache backend implements: six async methods over string keys and string values. Values arrive already serialised to JSON by the cache, and keys already carry the prefix. ### RememberOptions ```ts interface RememberOptions { /** Seconds the entry lives. Default: the cache's `defaultTtl`. */ ttl?: number; /** Labels to clear the entry by later with `invalidateTags()`. */ tags?: string[]; } ``` Per-entry options for `set()`, `remember()` and `cached()`. ## kitr/cache/provider ### cacheProvider ```ts const cacheProvider: import("@kitr/core").KitrProvider; ``` Binds `cache` from `config/cache.ts` (memory driver by default) on the server; does nothing in the browser. # Cache Source: https://kitr.dev/packages/cache Some reads are slow or expensive: a project report that joins six tables, a call to a rate-limited exchange-rate API, an invoice PDF summary rendered from a template. `kitr/cache` keeps the result for a while, so the next request gets it straight from memory or Redis. When the data changes, you clear every cached result about it with one tag instead of hunting down keys. The package is a small layer of its own, not a wrapper: values are stored as JSON through a six-method driver, with a memory driver built in and a [Redis](https://redis.io/docs/latest/) driver a few lines away. On top of a plain key-value store it adds expiry times, tags, a key prefix, and `remember()`, which runs slow work once even when fifty requests ask at the same moment. It runs on the server only. The provider does nothing in the browser. To cache what the API returns on the client, use [query](/packages/query), which already does. The examples on this page follow Acme's project dashboard at `app.acme.com`: a report per project, a list of a team's open invoices, and an exchange rate used to show order totals in euros. ## Install ```sh kitr add cache ``` What `kitr add` does: - Adds the [server](/packages/server) package first if the project does not have it yet (and [logger](/packages/logger), which the server needs). The cache only runs on the server. - Adds `@kitr/cache` to `dependencies` in `package.json` and installs it. Pass `--no-install` to skip the install. - Adds `() => import('@kitr/cache/provider')` to `providers` and `'@kitr/cache'` to `packages` in `kitrrc.ts`. The provider binds `cache` in the container. - Writes `config/cache.ts`, with `prefix` set to your package name. - Adds a `## kitr: cache` section to `AGENTS.md`: expensive reads go through `cache.remember()` or `cached()`, `useCache(ctx.app)` in controllers, and writes invalidate by tag instead of guessing keys. It adds no env variables, no scripts and asks no questions. No kit includes the cache, so every project adds it with this command. `kitr remove cache` reverses every step above: it takes the provider and the package out of `kitrrc.ts`, deletes `config/cache.ts`, removes the dependency and the `AGENTS.md` section, then lists the files that still import `kitr/cache`. ## Configure `kitr add cache` writes this file. In a project whose `package.json` is named `acme-app`, it reads: ```ts // config/cache.ts import { defineConfig } from 'kitr/cache' export default defineConfig({ // In-memory by default. For several instances pass a shared driver (Redis, Upstash) as `driver`. prefix: 'acme-app:', defaultTtl: 300, }) ``` | Option | Default | Description | | --- | --- | --- | | `driver` | `createMemoryCacheDriver()` | Where entries live. Any object that implements `CacheDriver`. See [Drivers](#drivers). | | `prefix` | `''` | Prepended to every key before it reaches the driver, so several apps can share one Redis. | | `defaultTtl` | `300` | Seconds an entry lives when `set()`, `remember()` or `cached()` is called without a `ttl`. | The cache reads no env variables itself. A Redis driver usually reads `REDIS_URL`; see [Redis](#redis). Config files are TypeScript, so you can choose values per environment in the file. To keep entries short-lived in development while you iterate on a report: ```ts // config/cache.ts import { defineConfig } from 'kitr/cache' export default defineConfig({ prefix: 'acme-app:', defaultTtl: process.env.NODE_ENV === 'production' ? 300 : 5, }) ``` ## Cache your first slow read On the server, get the cache with `useCache(app)` and wrap the slow call in `remember()`. This controller builds Acme's project report once every ten minutes: ```ts // server/controllers/reports_controller.ts import type { HttpContext } from 'kitr/api' import { useCache } from 'kitr/cache' import { buildProjectReport } from '#server/reports/project_report' export default class ReportsController { async show({ app, params }: HttpContext<{ id: string }>) { return useCache(app).remember( `report:project:${params.id}`, () => buildProjectReport(params.id), { ttl: 600, tags: ['reports', `project:${params.id}`] }, ) } } ``` Add the route next to the others: ```ts // server/routes.ts import { defineRoutes } from 'kitr/api' import ReportsController from '#server/controllers/reports_controller' export const routes = defineRoutes((router) => router.group({ prefix: '/api' }, (api) => api.get('/projects/:id/report', [ReportsController, 'show'], { name: 'projects.report' }), ), ) ``` The first `GET /api/projects/42/report` runs `buildProjectReport('42')`, stores the result under `acme-app:report:project:42` for 600 seconds and returns it. Every request in the next ten minutes gets the stored copy without touching the database. After a write to project 42, clear the `project:42` tag (see [Clear entries by tag](#clear-entries-by-tag)) and the next request builds a fresh report. ## Get the cache on the server The provider binds one cache as `cache` in the container. `useCache(app)` reads it, typed: ```ts // server/controllers/invoices_controller.ts import type { HttpContext } from 'kitr/api' import { useCache } from 'kitr/cache' export default class InvoicesController { async index({ app }: HttpContext) { const cache = useCache(app) return cache.get<{ id: string; total: number }[]>('invoices:open') } } ``` `app.container.use('cache')` returns the same object; `useCache` is a shorter, typed spelling. In a background job, the job context carries the app: ```ts // server/jobs/warm_reports.ts import { useCache } from 'kitr/cache' import { defineJob } from 'kitr/jobs' import { buildProjectReport } from '#server/reports/project_report' export const warmReportsJob = defineJob('warm-reports', { schedule: { every: '1h' }, async run(_payload, { app }) { const cache = useCache(app) for (const id of ['42', '43']) { await cache.set(`report:project:${id}`, await buildProjectReport(id), { ttl: 3600, tags: ['reports', `project:${id}`], }) } }, }) ``` If the provider is not registered, `useCache` throws `[kitr] No binding named "cache". Did you add the package that provides it?`. Run `kitr add cache`, or check that `() => import('@kitr/cache/provider')` is still in `kitrrc.ts`. The same error appears in browser code, because the provider binds nothing on the client. ## Remember a result `remember(key, fn, options)` returns the cached value when there is one. On a miss it runs `fn`, stores what it returns and returns that: ```ts // server/reports/team_summary.ts import type { Cache } from 'kitr/cache' import { countOpenInvoices } from '#server/repositories/invoices' export function teamSummary(cache: Cache, teamId: string) { return cache.remember( `summary:team:${teamId}`, async () => ({ teamId, openInvoices: await countOpenInvoices(teamId) }), { ttl: 120, tags: [`team:${teamId}`] }, ) } // await teamSummary(cache, 'design') // => { teamId: 'design', openInvoices: 3 } (runs countOpenInvoices) // await teamSummary(cache, 'design') // => { teamId: 'design', openInvoices: 3 } (from the cache, nothing runs) ``` Four rules decide what `remember()` stores: | `fn` returns or throws | Stored? | Next call | | --- | --- | --- | | A value, including `0`, `''`, `false` and `null` | Yes, for `ttl` seconds | Gets the stored value | | `undefined` | No | Runs `fn` again | | Throws or rejects | No; the error reaches every waiting caller | Runs `fn` again | | Still running when another call arrives with the same key | Once | Waits for the same run | The last row is the reason to prefer `remember()` over a `get()` then `set()` pair. When a popular entry expires, fifty requests can miss at the same moment. With `get()` and `set()`, all fifty rebuild the report. With `remember()`, the first call runs `fn` and the other forty-nine wait for its result: ```ts // server/reports/warm.ts import type { Cache } from 'kitr/cache' import { buildProjectReport } from '#server/reports/project_report' export async function loadReportForEveryone(cache: Cache) { const calls = Array.from({ length: 50 }, () => cache.remember('report:project:42', () => buildProjectReport('42'), { ttl: 600 }), ) return Promise.all(calls) } // buildProjectReport ran once; all 50 calls got its result. ``` Sharing a run works inside one server process. Two instances that miss together each run `fn` once. A failure is not cached, so a database blip does not leave a broken answer in place for ten minutes. Every caller that was waiting on the failed run gets the same error, and the next call tries again: ```ts // server/rates/eur_rate.ts import type { Cache } from 'kitr/cache' import { fetchRate } from '#server/rates/client' export async function eurRate(cache: Cache): Promise { try { return await cache.remember('rate:USD:EUR', () => fetchRate('USD', 'EUR'), { ttl: 3600 }) } catch { // The rates API is down. Nothing was stored; the next call asks again. return null } } // await eurRate(cache) // rates API down // => null // await eurRate(cache) // rates API back // => 0.92 ``` ## Choose a key A key names one answer. Put every input that changes the answer into it, from the most general to the most specific: ```ts // server/reports/keys.ts export const reportKey = (projectId: string) => `report:project:${projectId}` export const invoicesKey = (teamId: string, status: 'open' | 'paid', page: number) => `invoices:team:${teamId}:${status}:page:${page}` export const rateKey = (from: string, to: string) => `rate:${from}:${to}` ``` Keeping keys in one module means the code that reads an entry and the code that deletes it can't drift apart. Anything that differs per user belongs in the key. Don't cache "my invoices" under `invoices:mine`: the first person to load it, say Ada, would have her invoices served to Grace. Use `invoices:user:${user.id}` instead, or cache the shared part and filter per user after the read. The `prefix` from `config/cache.ts` is added in front of every key before it reaches the driver. You never write it yourself: `cache.get('rate:USD:EUR')` reads `acme-app:rate:USD:EUR` from the driver. ## Set expiry times Every entry written through the cache expires. `ttl` is in seconds; without one, the entry gets `defaultTtl` from the config (300 unless you change it): ```ts // server/rates/refresh.ts import { useCache } from 'kitr/cache' import type { KitrApp } from 'kitr/app' import { fetchRate } from '#server/rates/client' export async function refreshRates(app: KitrApp) { const cache = useCache(app) await cache.set('rate:USD:EUR', await fetchRate('USD', 'EUR'), { ttl: 3600 }) // one hour await cache.set('rate:USD:GBP', await fetchRate('USD', 'GBP')) // defaultTtl: 300 seconds } ``` An entry is gone at exactly `ttl` seconds: a `get()` one millisecond before returns the value, a `get()` at the deadline returns `undefined`. The memory driver also sweeps expired entries that nobody reads again, at most once a minute, so a cache full of one-off keys does not grow forever. Pick the ttl from how stale the answer may be, not from how slow it is to build. A team's invoice count can be two minutes old; an exchange rate for display can be an hour old; a permission check should not be cached at all. When you clear by tag after every write, a long ttl is safe, because the entry is replaced as soon as its data changes. ## Clear entries by tag Tag an entry with what it is about when you store it. After a write, clear the tag, and every entry about that thing is gone, whatever its key: ```ts // server/controllers/projects_controller.ts import type { HttpContext } from 'kitr/api' import { useCache } from 'kitr/cache' import { updateProject } from '#server/repositories/projects' export default class ProjectsController { async update({ app, params, body }: HttpContext<{ id: string }, {}, { name: string }>) { const project = await updateProject(params.id, body) await useCache(app).invalidateTags(`project:${params.id}`) return project } } ``` The report under `report:project:42` and anything else tagged `project:42` are cleared. Entries tagged only `project:43` stay. `invalidateTags()` takes several tags at once and clears an entry that carries any of them: ```ts // server/controllers/invoices_controller.ts import type { HttpContext } from 'kitr/api' import { useCache } from 'kitr/cache' import { markInvoicePaid } from '#server/repositories/invoices' export default class InvoicesController { async pay({ app, params }: HttpContext<{ id: string }>) { const invoice = await markInvoicePaid(params.id) await useCache(app).invalidateTags(`team:${invoice.teamId}`, `project:${invoice.projectId}`) return invoice } } ``` A tag that matches nothing is not an error. With the memory driver, overwriting a key with `set()` drops the old value's tags, so a value written later is never cleared by tags that belonged to the one it replaced. The Redis driver below keeps a key in its old tag sets until those tags are cleared, which can clear a newer value early but never serves a stale one. Tag by the records a result depends on (`project:42`, `team:design`) and by kind (`reports`, `invoices`). Kind tags let you clear a whole family after a deploy changes how it is built: `invalidateTags('reports')`. Don't guess keys after a write with a loop of `delete()` calls. Lists, pages and summaries hide under many keys, and the one you forget serves stale data. Clear the tag instead. ## Wrap a function `cached(cache, fn, options)` turns a function into one that remembers its results. Each distinct argument list gets its own entry, named by `options.key`: ```ts // server/rates/exchange_rate.ts import { type Cache, cached } from 'kitr/cache' import { fetchRate } from '#server/rates/client' export const exchangeRate = (cache: Cache) => cached(cache, (from: string, to: string) => fetchRate(from, to), { key: (from, to) => `rate:${from}:${to}`, ttl: 3600, tags: ['rates'], }) ``` Call it with the app's cache wherever you need a rate: ```ts // server/controllers/orders_controller.ts import type { HttpContext } from 'kitr/api' import { useCache } from 'kitr/cache' import { exchangeRate } from '#server/rates/exchange_rate' export default class OrdersController { async total({ app }: HttpContext) { const rate = exchangeRate(useCache(app)) const eur = await rate('USD', 'EUR') // fetches, stores under rate:USD:EUR await rate('USD', 'EUR') // from the cache return { totalEur: Math.round(1250 * eur) } // => { totalEur: 1150 } with a rate of 0.92 } } ``` The wrapped function has the same arguments and returns a promise of the same result. It follows every `remember()` rule: one run for concurrent callers, `undefined` and errors not stored. Use `cached()` when a function is called from many places; use `remember()` inline when it is called from one. Entries live in the driver, not in the wrapper, so building the wrapper per request costs nothing and every request shares the stored rates. Don't call `useCache(server.app)` at module level: the module loads before the server boots, and the provider has not bound `cache` yet. ## Read and write entries directly The cache also works as a typed key-value store. Values go in and come out as JSON: ```ts // server/teams/budget.ts import { useCache } from 'kitr/cache' import type { KitrApp } from 'kitr/app' export async function budgetExample(app: KitrApp) { const cache = useCache(app) await cache.set('budget:team:design', { spent: 1200, limit: 5000 }, { ttl: 60, tags: ['team:design'] }) await cache.get<{ spent: number; limit: number }>('budget:team:design') // => { spent: 1200, limit: 5000 } await cache.has('budget:team:design') // => true await cache.delete('budget:team:design') await cache.get('budget:team:design') // => undefined } ``` | Method | Returns | Description | | --- | --- | --- | | `get(key)` | `Promise` | The parsed value, or `undefined` on a miss or after expiry. | | `set(key, value, { ttl, tags })` | `Promise` | Store a value as JSON. Replaces any entry under the key, with its tags. | | `has(key)` | `Promise` | Whether a live entry exists, including one that holds `null`. | | `delete(key)` | `Promise` | Remove one entry. | | `remember(key, fn, { ttl, tags })` | `Promise` | Cached value, or run `fn` and store it. See [Remember a result](#remember-a-result). | | `invalidateTags(...tags)` | `Promise` | Remove every entry tagged with any of `tags`. | | `clear()` | `Promise` | Remove every entry from the driver. | | `driver` | `CacheDriver` | The driver underneath. | `get()` returns `undefined` for a miss and the stored value otherwise, so a stored `0`, `false` or `null` comes back as itself. Use `has()` when you need to know whether a key exists without reading it. ## Store values that survive JSON Every value passes through `JSON.stringify` on the way in and `JSON.parse` on the way out. Plain objects, arrays, strings, numbers, booleans and `null` round-trip exactly. Other types change shape: ```ts // server/orders/last_order.ts import { useCache } from 'kitr/cache' import type { KitrApp } from 'kitr/app' export async function lastOrderExample(app: KitrApp) { const cache = useCache(app) await cache.set('order:last', { id: 'ord_1', placedAt: new Date('2026-03-01T09:00:00Z') }) await cache.get('order:last') // => { id: 'ord_1', placedAt: '2026-03-01T09:00:00.000Z' } a string, not a Date await cache.set('order:none', undefined) await cache.get('order:none') // => null } ``` | Stored | Read back | | --- | --- | | `Date` | ISO string | | `undefined` (as the whole value) | `null` | | `undefined` inside an object | The key is missing | | `Map`, `Set` | `{}` | | `BigInt` | `set()` throws a `TypeError` | | Class instance | Plain object, no methods | Convert on the way out when you need the richer type: `new Date(order.placedAt)`. Or cache the plain data and build the object after the read. ## Drivers A driver decides where entries live. The cache talks to it through six methods over strings, so swapping drivers changes nothing else in your code. | Driver | Shared between instances | Survives a restart | Use for | | --- | --- | --- | --- | | Memory (default) | No | No | Development, tests, one server process | | Redis | Yes | Yes | Production with more than one instance | ### Memory The default. Nothing to install and no config: leave `driver` out of `config/cache.ts`. To pass it explicitly, for example to share one memory driver between two caches in a script: ```ts // config/cache.ts import { createMemoryCacheDriver, defineConfig } from 'kitr/cache' export default defineConfig({ driver: createMemoryCacheDriver(), prefix: 'acme-app:', defaultTtl: 300, }) ``` Each server process has its own copy, and it empties when the process restarts. Once you run two instances behind a load balancer, `invalidateTags()` on one instance leaves the other serving the old value until its ttl runs out. Move to a shared store before that happens. ### Redis Install a Redis client. This example uses [ioredis](https://github.com/redis/ioredis): ```sh pnpm add ioredis ``` Write the driver once, in its own file: ```ts // server/cache/redis_driver.ts import type { CacheDriver } from 'kitr/cache' import { Redis } from 'ioredis' export function createRedisCacheDriver(url: string): CacheDriver { const redis = new Redis(url) return { name: 'redis', get: async (key) => (await redis.get(key)) ?? undefined, async set(key, value, ttl) { if (ttl) await redis.set(key, value, 'EX', ttl) else await redis.set(key, value) }, delete: async (key) => void (await redis.del(key)), async tag(key, tags) { await Promise.all(tags.map((t) => redis.sadd(`tag:${t}`, key))) }, async invalidateTags(tags) { for (const t of tags) { const keys = await redis.smembers(`tag:${t}`) if (keys.length) await redis.del(...keys) await redis.del(`tag:${t}`) } }, clear: async () => void (await redis.flushdb()), } } ``` Use it in the config: ```ts // config/cache.ts import { defineConfig } from 'kitr/cache' import { createRedisCacheDriver } from '#server/cache/redis_driver' export default defineConfig({ driver: createRedisCacheDriver(process.env.REDIS_URL ?? 'redis://localhost:6379'), prefix: 'acme-app:', defaultTtl: 300, }) ``` Add the connection string to `.env`: ```sh REDIS_URL=redis://localhost:6379 ``` Redis expires keys itself with `EX`, so a Redis cache needs no sweeping. See the [ioredis docs](https://github.com/redis/ioredis#readme) for TLS, clusters and Sentinel. > **Warning** > `clear()` on this driver runs `FLUSHDB`, which deletes every key in the Redis database, including other apps' entries and anything that is not a cache entry. Give the cache its own Redis database (`redis://localhost:6379/1`) or replace `clear` with a scan-and-delete over your prefix. Tags are stored under `tag:` without the prefix, so two apps that share one Redis and use the same tag name clear each other's entries. Give each app its own database, or put your app name in tag names (`acme-app:project:42`). ## Use the cache in production - **More than one instance needs a shared driver.** With the memory driver, each instance has its own entries, and a tag cleared on one instance stays on the others. - **The memory driver empties on deploy.** The first requests after a restart all miss. Warm the slowest entries from a job (see [Get the cache on the server](#get-the-cache-on-the-server)) if a cold start hurts. - **A cache is not a store.** Anything in it can vanish at any time: a restart, an eviction, a `clear()`. Never keep the only copy of data in the cache. - **Driver errors are not caught.** If Redis is down, `get()`, `set()` and `remember()` reject with the driver's error. Catch it in the caller when the page should still load without the cache. ## Devtools When the [devtools](/packages/devtools) package is installed, the cache provider logs the driver it booted with to the `cache` channel at start-up, for example `driver: memory`. That line is the quickest way to confirm that production picked up the Redis driver. ## Testing `createCache()` builds a cache with the memory driver and no app, which is all most tests need. Vitest's fake timers move the clock past a ttl without waiting: ```ts // server/reports/team_summary.test.ts import { createCache } from 'kitr/cache' import { afterEach, describe, expect, it, vi } from 'vitest' afterEach(() => { vi.useRealTimers() }) describe('team summary cache', () => { it('builds once, serves from the cache, and rebuilds after a tag is cleared', async () => { const cache = createCache({ prefix: 'test:', defaultTtl: 60 }) const countOpenInvoices = vi.fn(async (teamId: string) => (teamId === 'design' ? 3 : 0)) const summary = (teamId: string) => cache.remember( `summary:team:${teamId}`, async () => ({ teamId, openInvoices: await countOpenInvoices(teamId) }), { tags: [`team:${teamId}`] }, ) expect(await summary('design')).toEqual({ teamId: 'design', openInvoices: 3 }) expect(await summary('design')).toEqual({ teamId: 'design', openInvoices: 3 }) expect(countOpenInvoices).toHaveBeenCalledTimes(1) await cache.invalidateTags('team:design') await summary('design') expect(countOpenInvoices).toHaveBeenCalledTimes(2) }) it('expires entries after the ttl', async () => { vi.useFakeTimers() const cache = createCache() await cache.set('rate:USD:EUR', 0.92, { ttl: 10 }) vi.advanceTimersByTime(9_999) expect(await cache.get('rate:USD:EUR')).toBe(0.92) vi.advanceTimersByTime(1) expect(await cache.get('rate:USD:EUR')).toBeUndefined() }) }) ``` Code that calls `useCache(app)` needs an app with the provider. `createTestApp` from [testing](/packages/testing) boots one with your config: ```ts // server/reports/report_cache.test.ts import { useCache } from 'kitr/cache' import cacheProvider from 'kitr/cache/provider' import { createTestApp } from 'kitr/testing' import { expect, it } from 'vitest' it('binds the cache from config', async () => { const app = await createTestApp({ providers: [cacheProvider], config: { cache: { prefix: 'acme-test:' } }, }) const cache = useCache(app) await cache.set('report:project:42', { total: 12 }) expect(await cache.driver.get('acme-test:report:project:42')).toBe('{"total":12}') }) ``` `createTestApp` boots the app with `environment: 'test'`, which the cache provider treats like the server. Only `environment: 'client'` makes it bind nothing. Each `createCache()` and each test app gets its own memory driver, so tests never see each other's entries. ## Extend A driver is an object that implements `CacheDriver`: a `name` and six async methods over string keys and string values. The cache has already added the prefix to every key and serialised every value to JSON by the time the driver sees it. | Member | Called by | Must | | --- | --- | --- | | `name` | devtools | Name the driver, e.g. `redis`. | | `get(key)` | `get`, `has`, `remember` | Return the string, or `undefined` when missing or expired. | | `set(key, value, ttl)` | `set`, `remember` | Store the string; expire it after `ttl` seconds (`undefined` means never). | | `delete(key)` | `delete` | Remove the key. | | `tag(key, tags)` | `set` with `tags` | Record that `key` carries each tag. | | `invalidateTags(tags)` | `invalidateTags` | Remove every key that carries any of `tags`. | | `clear()` | `clear` | Remove everything the driver holds. | The Redis driver under [Redis](#redis) is a complete example. Drivers also compose. This one wraps another driver and counts hits and misses, which helps tune ttls: ```ts // server/cache/counting_driver.ts import type { CacheDriver } from 'kitr/cache' export function countingDriver(inner: CacheDriver) { const stats = { hits: 0, misses: 0 } const driver: CacheDriver = { name: `counting(${inner.name})`, async get(key) { const value = await inner.get(key) if (value === undefined) stats.misses++ else stats.hits++ return value }, set: (key, value, ttl) => inner.set(key, value, ttl), delete: (key) => inner.delete(key), tag: (key, tags) => inner.tag(key, tags), invalidateTags: (tags) => inner.invalidateTags(tags), clear: () => inner.clear(), } return { driver, stats } } ``` Register it in the config like any other driver: ```ts // config/cache.ts import { createMemoryCacheDriver, defineConfig } from 'kitr/cache' import { countingDriver } from '#server/cache/counting_driver' export const cacheStats = countingDriver(createMemoryCacheDriver()) export default defineConfig({ driver: cacheStats.driver, prefix: 'acme-app:', defaultTtl: 300, }) ``` `cacheStats.stats` now reads `{ hits: 41, misses: 3 }` after a busy minute. See [`CacheDriver`](/packages/cache/api#cachedriver) in the API reference for the exact signatures. ## Next steps - [API reference](/packages/cache/api): every export with its signature. - [Query](/packages/query): cache API responses in the browser. - [Jobs](/packages/jobs): warm slow entries on a schedule. - [Server](/packages/server): the runtime the cache provider binds into. - [ioredis](https://github.com/redis/ioredis): the Redis client used in the driver above. # Consent API Source: https://kitr.dev/packages/consent/api Every export of `kitr/consent`, `kitr/consent/react`, `kitr/consent/provider`, read from the published type definitions. The [guide](/packages/consent) shows them in use. ## kitr/consent ### createConsent ```ts function createConsent(config?: ConsentConfig, app?: KitrApp): Consent; ``` Build a consent service backed by a cookie. The provider calls it with the app (for devtools logging); tests and server code can call it without one. ### useConsent ```ts function useConsent(app: KitrApp): Consent; ``` The consent service from an app's container. Not a hook; components use `useConsent()` from `kitr/consent/react`. ### defineConfig ```ts const defineConfig: (config: C) => C; ``` Types the default export of `config/consent.ts`. ### Consent ```ts interface Consent { /** The record, with `decidedAt` unset while the banner should show. */ get(): ConsentRecord; /** Has the visitor decided (for this version)? */ decided(): boolean; /** Whether a category is granted for the current version. Always true for `necessary`. */ has(category: ConsentCategory): boolean; /** Grant exactly these categories (plus `necessary`). */ grant(categories: ConsentCategory[]): void; /** Grant every configured category. */ acceptAll(): void; /** Decline everything but `necessary`. */ rejectAll(): void; /** Forget the choice so the banner shows again. */ reset(): void; /** Call `listener` after every change to the choice. Returns the unsubscribe function. */ subscribe(listener: (record: ConsentRecord) => void): () => void; /** The categories from config, in order. */ readonly categories: ConsentCategory[]; /** The storage cookie that holds the record; `cookie.get(request.headers)` reads it on the server. */ readonly cookie: CookieItem; } ``` The consent service bound as `consent` in the container. Reads and writes the visitor's choice. ### ConsentConfig ```ts interface ConsentConfig { /** What the banner offers. Default `['analytics', 'marketing']`. */ categories?: ConsentCategory[]; /** Re-ask when this changes. Default 1. */ version?: number; /** Cookie name. Default `consent`. */ cookie?: string; /** Days the choice is kept. Default 365. */ maxAge?: number; /** Which category analytics waits for. Default `analytics`; false never gates it. */ analytics?: ConsentCategory | false; /** Which category error monitoring waits for. Default: none (monitoring runs; set `'analytics'` to gate it). */ monitoring?: ConsentCategory | false; } ``` Options for `config/consent.ts`: the categories offered, the version, the cookie and what to gate. ### ConsentRecord ```ts interface ConsentRecord { /** Categories the visitor agreed to. `necessary` is always in. */ granted: ConsentCategory[]; /** ISO time of the choice; undefined until they chose. */ decidedAt?: string; /** Bump `config.version` to ask everyone again. */ version: number; } ``` What a visitor agreed to, stored as JSON in the consent cookie. ### ConsentCategory ```ts type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'preferences' | (string & {}); ``` A consent category. `necessary` is always granted; the others are whatever `config.categories` lists. ## kitr/consent/react ### useConsent ```ts function useConsent(): { record: ConsentRecord; decided: boolean; has: (category: ConsentCategory) => boolean; categories: ConsentCategory[]; grant: Consent['grant']; acceptAll: () => void; rejectAll: () => void; reset: () => void; }; ``` The consent record and the actions a banner needs; re-renders when the choice changes. The server always sees "undecided" while the browser reads the cookie on its first render, so in server-rendered apps render the banner only after mount. ## kitr/consent/provider ### consentProvider ```ts const consentProvider: import("@kitr/core").KitrProvider; ``` Binds `consent` and gates the trackers: analytics stays queued until its category is granted (and is disabled again when it is withdrawn); monitoring is gated only when `config.monitoring` names a category. Put the provider after the analytics and monitoring ones in kitrrc.ts. # Consent Source: https://kitr.dev/packages/consent In much of the world you need a visitor's permission before you load analytics, marketing pixels or session replay. `kitr/consent` keeps one record per visitor of what they agreed to, by category, in a cookie that the server can read too. While a category is not granted, [analytics](/packages/analytics) keeps its calls in a queue and, if you ask it to, [monitoring](/packages/monitoring) drops its reports. Your banner is a few lines over `useConsent()`, so it looks like the rest of your app. The examples on this page follow one scenario: Acme's app at `app.acme.com` asks Ada for consent on her first visit, holds Google Analytics until she agrees, loads a marketing pixel only for the `marketing` category, and lets Grace change her mind from the settings page. `kitr/consent` runs in the browser: the provider only gates analytics and monitoring on the client. The record lives in a cookie made with [`kitr/storage`](/packages/storage), so server code can read it from the request. It is not a legal template: you decide the categories and the wording, and your lawyer decides whether they are enough. ## Install ```sh kitr add consent ``` What `kitr add` does: - Adds [storage](/packages/storage) first when the project does not have it, because the record is a storage cookie. - Installs `@kitr/consent`. - Adds `() => import('@kitr/consent/provider')` to the end of `providers` and `'@kitr/consent'` to `packages` in `kitrrc.ts`. - Writes `config/consent.ts` (shown in [Configure](#configure)) and regenerates `config/index.ts`. - Writes `app/components/consent-banner.tsx`, a banner with "Only necessary" and "Accept all" buttons, styled with Tailwind classes. - Adds a `## kitr: consent` section to `AGENTS.md` telling coding agents that the banner is the one place consent is asked, never to call `analytics.enable()` by hand, and to check `useConsent().has('marketing')` before loading third-party scripts. `kitr add consent` asks no questions and writes no env variables. To edit `package.json` without running the package manager, pass the global flag: ```sh kitr add consent --no-install ``` No kit includes consent. It is a client package, so in a server-only project `kitr add consent` prints a warning and skips it. `kitr remove consent` removes the provider, the `packages` entry, the dependency and the `AGENTS.md` section, and deletes `config/consent.ts`. It leaves `app/components/consent-banner.tsx` and the place you mounted it, which you delete by hand. Visitors keep their `consent` cookie until it expires. ## Configure `kitr add consent` writes this file: ```ts // config/consent.ts import { defineConfig } from 'kitr/consent' export default defineConfig({ categories: ['analytics', 'marketing'], version: 1, // bump to ask everyone again analytics: 'analytics', // analytics waits for this category monitoring: false, // error reports are not gated }) ``` | Option | Default | Description | | --- | --- | --- | | `categories` | `['analytics', 'marketing']` | The categories your banner offers. `necessary` is always granted and never needs listing. `grant()` ignores any category not in this list. | | `version` | `1` | The version of your consent text. A stored choice with another version counts as undecided, so bumping it asks everyone again. | | `cookie` | `'consent'` | The cookie name, before the [storage](/packages/storage) prefix is added. | | `maxAge` | `365` | Days the browser keeps the choice. | | `analytics` | `'analytics'` | The category [analytics](/packages/analytics) waits for. `false` leaves analytics alone. | | `monitoring` | `false` | The category [monitoring](/packages/monitoring) waits for. `false` (or leaving it out) never holds error reports back. | The cookie is written with `SameSite=Lax`, the `maxAge` above, and the cookie defaults from `config/storage.ts` (`path` and `secure`). Its name gets the storage `prefix`, so with `prefix: 'acme-app:'` the browser stores `acme-app:consent`. The record inside it is JSON: ```json { "granted": ["necessary", "analytics"], "decidedAt": "2026-11-07T09:30:00.000Z", "version": 1 } ``` `kitr add analytics` writes its config with `enabled` left at `true`. Before you ship a consent banner, set it to `false`: ```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, globalProps: { app: env.VITE_APP_NAME }, }) ``` > **Warning** > With `enabled` left at `true`, analytics enables itself while the app boots, before the consent provider runs. That loads the vendor's script (`gtag.js`, Plausible or PostHog), which may set cookies, even though consent disables sending a moment later. Set `enabled: false` in `config/analytics.ts` so no tracking script loads before the visitor agrees. ## Show your first banner `kitr add` wrote this banner: ```tsx // app/components/consent-banner.tsx import { useConsent } from 'kitr/consent/react' /** Mount once in the root layout. Renders nothing once a choice is recorded. */ export function ConsentBanner() { const { decided, acceptAll, rejectAll } = useConsent() if (decided) return null return (
We use cookies for analytics. You can change your mind in settings.
) } ``` Mount it once, in the root layout. Keep the rest of your root route as it is and render the banner after the ``: ```tsx // app/routes/__root.tsx import { Outlet } from 'kitr/router' import { ConsentBanner } from '#app/components/consent-banner' // ... function RootLayout() { return ( <> ) } ``` Open `app.acme.com` in a private window. The banner sits at the bottom of the page. Click "Accept all" and: 1. The banner disappears, because `decided` is now `true`. 2. A `consent` cookie holds `{ granted: ['necessary', 'analytics', 'marketing'], decidedAt, version: 1 }`. 3. The provider calls `analytics.enable()`, which loads the analytics scripts and sends every call that was queued since the page loaded, the first page view included. Reload the page and the banner stays hidden: the choice is read back from the cookie. Click "Only necessary" instead and the cookie records `['necessary']`; analytics keeps queuing and never sends. ## Offer a choice per category `grant()` records exactly the categories you pass, plus `necessary`. Use it for a banner with checkboxes: ```tsx // app/components/consent-banner.tsx import { useConsent } from 'kitr/consent/react' import { useState } from 'react' const labels: Record = { analytics: 'Usage statistics, so we know which pages help', marketing: 'Marketing, so our ads reach people like you', } export function ConsentBanner() { const { decided, categories, grant, acceptAll, rejectAll } = useConsent() const [chosen, setChosen] = useState([]) if (decided) return null return (

Acme uses cookies. Pick what you allow.

{categories.map((category) => ( ))}
) } ``` `categories` comes from `config/consent.ts`, so adding a category there adds a checkbox. What each action records: | Call | `granted` afterwards | | --- | --- | | `acceptAll()` | `['necessary', 'analytics', 'marketing']` | | `rejectAll()` | `['necessary']` | | `grant(['analytics'])` | `['necessary', 'analytics']` | | `grant(['analytics', 'chat'])` | `['necessary', 'analytics']`, because `chat` is not in `categories` | | `grant([])` | `['necessary']`, same as `rejectAll()` | Every one of them sets `decidedAt` to the current time, writes the cookie and notifies subscribers. ## Add a category Categories are plain strings. Add one to `config/consent.ts` and it appears in `categories`, so a banner that maps over them shows it: ```ts // config/consent.ts import { defineConfig } from 'kitr/consent' export default defineConfig({ categories: ['analytics', 'marketing', 'preferences'], version: 2, // new category: ask everyone again analytics: 'analytics', monitoring: false, }) ``` Then check it where the feature lives. Here the projects list only remembers Linus's sort order when he allowed `preferences`: ```tsx // app/features/projects/use-sort-order.ts import { useConsent } from 'kitr/consent/react' import { useEffect, useState } from 'react' export function useSortOrder() { const { has } = useConsent() const remember = has('preferences') const [order, setOrder] = useState<'name' | 'updated'>('updated') useEffect(() => { if (!remember) return const saved = localStorage.getItem('acme:project-sort') if (saved === 'name' || saved === 'updated') setOrder(saved) }, [remember]) const change = (next: 'name' | 'updated') => { setOrder(next) if (remember) localStorage.setItem('acme:project-sort', next) } return [order, change] as const } ``` Bump `version` whenever you add a category, so people who already chose are asked about the new one. ## Hold analytics for a different category By default analytics waits for `analytics`. Point it at another category, or turn the gate off: ```ts // config/consent.ts import { defineConfig } from 'kitr/consent' export default defineConfig({ categories: ['statistics', 'marketing'], version: 1, analytics: 'statistics', monitoring: false, }) ``` With `analytics: false`, consent never calls `analytics.enable()` or `disable()`. Use it when you run a cookieless service you are allowed to use without consent, and set `enabled: true` in `config/analytics.ts` again. ## Load a second tracker for one category Some trackers belong to a different category from your page views, such as a marketing service that sees every event. Add its adapter only after that category is granted, with `analytics.use()`: ```tsx // app/components/marketing-tracker.tsx import { posthog } from 'kitr/analytics' import { useAnalytics } from 'kitr/analytics/react' import { useConsent } from 'kitr/consent/react' import posthogJs from 'posthog-js' import { useEffect } from 'react' export function MarketingTracker() { const { has } = useConsent() const analytics = useAnalytics() const allowed = has('marketing') useEffect(() => { if (!allowed || analytics.adapters.some((a) => a.name === 'posthog')) return analytics.use(posthog(posthogJs, { apiKey: 'phc_acme_marketing' })) }, [allowed, analytics]) return null } ``` The adapter receives calls made after it was added. Withdrawing `marketing` does not remove it; if analytics itself stays enabled, the adapter keeps receiving events until the next page load. Gate such a tracker on the same category as analytics if that matters to you. ## Keep the choice for less time Some regulators expect you to ask again after six months. Set `maxAge` in days, and rename the cookie if another app on the same domain already uses `consent`: ```ts // config/consent.ts import { defineConfig } from 'kitr/consent' export default defineConfig({ categories: ['analytics', 'marketing'], version: 1, cookie: 'acme_consent', maxAge: 180, analytics: 'analytics', monitoring: false, }) ``` When the cookie expires the browser deletes it, the record reads as undecided, and the banner shows again. ## Show the banner without a flash On the server, the consent service has no cookie to read, so server-rendered HTML always contains the banner. For a visitor who already chose, the client then reads the cookie, renders nothing, and React reports a hydration mismatch while the banner flickers away. Render the banner only after the component has mounted: ```tsx // app/components/consent-banner.tsx import { useConsent } from 'kitr/consent/react' import { useEffect, useState } from 'react' export function ConsentBanner() { const { decided, acceptAll, rejectAll } = useConsent() const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) if (!mounted || decided) return null return (

We use cookies for analytics. You can change your mind in settings.

) } ``` The server and the first client render both produce nothing, so hydration matches. Right after mount, the banner appears only for visitors who have not decided. In a single-page app (web `spa` mode), there is no server HTML and the generated banner works as it is. ## Check a category before loading a script For a script that is not a kitr package, such as an ad pixel or a chat widget, check its category first. `useConsent()` re-renders when the choice changes, so the script loads the moment the visitor agrees: ```tsx // app/components/marketing-pixel.tsx import { useConsent } from 'kitr/consent/react' import { useEffect } from 'react' export function MarketingPixel() { const { has } = useConsent() const allowed = has('marketing') useEffect(() => { if (!allowed || document.getElementById('acme-pixel')) return const script = document.createElement('script') script.id = 'acme-pixel' script.async = true script.src = 'https://pixel.acme.com/p.js' document.head.appendChild(script) }, [allowed]) return null } ``` Mount it next to the banner. `has('necessary')` is always `true`; any other category is `true` only when granted in the current version. Don't put the vendor's ` ``` Values are escaped (`<` becomes `<`), so a string flag can't close the tag. The client treats the embedded JSON as input: it keeps only declared keys, coerces each to its declared type, and falls back to the default for anything it can't read. A broken tag is ignored, and the provider resolves at boot instead. Outside React, `flagsScript(values)` from `kitr/flags/server` returns the same tag as a string, for a server that writes HTML by hand: ```ts // server/pages/invoice_print.ts import { flagsScript } from 'kitr/flags/server' const head = `Invoice${flagsScript({ 'invoice-layout': 'compact' })}` // => Invoice ``` During server rendering, `useFlag` answers from the app that renders the page. When that app has not resolved flags for this request, it renders defaults, and the browser's first render from the embedded snapshot can differ. For UI that must match on the first paint, read the value from the root loader's data on both sides. In a single-page app with no server rendering, there is nothing to embed. The provider calls `resolve()` at boot in the browser with no context, so env variables and percentage rollouts don't apply until you resolve again with a user (next section). ## Resolve again after sign-in In the browser, `resolve(context)` updates the snapshot, and every `useFlag` re-renders with the new answer. Call it once you know who the user is: ```tsx // app/features/auth/after_sign_in.tsx import { useApp } from 'kitr/react' import { useEffect } from 'react' export function ResolveFlagsFor({ userId }: { userId: string }) { const app = useApp() useEffect(() => { void app.container.use('flags').resolve({ userId }) }, [app, userId]) return null } ``` Render `` inside the signed-in layout. When Linus signs in, the percentage provider places him in or out of the 10%, and the dashboard switches without a reload. Call `resolve({})` on sign-out to drop back to the anonymous answer. ## Providers A provider answers flags from one source. List them in `config/flags.ts` in priority order: the first one with an answer wins. | Provider | Answers from | Silent when | | --- | --- | --- | | `envFlagsProvider(env?, prefix?)` | `FLAG_*` env variables | The variable is unset, or can't be read as the flag's type. | | `percentageFlagsProvider(shares, salt?)` | A stable share of user ids | The flag is not in `shares`. | | `staticFlagsProvider(values)` | A map, or a function of the context | The map has no entry for the flag. | | `openFeatureFlagsProvider(client, flags)` | Any OpenFeature client | Never: the client returns its own default. | ### Env variables Built in; nothing to install. Good for turning a flag on per environment, or forcing it in staging: ```ts // config/flags.ts import { defineConfig, envFlagsProvider } from 'kitr/flags' import { flags } from '#app/flags' export default defineConfig({ definitions: flags, providers: [envFlagsProvider()], }) ``` ```sh FLAG_NEW_DASHBOARD=true FLAG_MAX_UPLOAD_MB=25 ``` Pass a map as the first argument to read from somewhere other than `process.env`, and a prefix as the second: `envFlagsProvider(process.env, 'ACME_FLAG_')` reads `ACME_FLAG_NEW_DASHBOARD`. Changing an env variable needs a server restart. ### Percentage rollout Built in. Turns a flag on for a share of users, from 0 to 100: ```ts // config/flags.ts import { defineConfig, envFlagsProvider, percentageFlagsProvider } from 'kitr/flags' import { flags } from '#app/flags' export default defineConfig({ definitions: flags, providers: [envFlagsProvider(), percentageFlagsProvider({ 'new-dashboard': 25 }, 'dashboard-2026')], }) ``` The answer is a hash of the salt, the flag key and the user id, so Ada gets the same answer on every request and every server. Raising 25 to 50 keeps everyone who already had the feature and adds more. Two flags at 50% reach different, independent halves of your users. Change the salt to draw a new sample. With no `userId` in the context, the provider answers `false`, and since that is an answer, providers after it are not asked. Put overrides such as `envFlagsProvider()` before it. ### Static rules Built in. A fixed map, or a function of the context for rules you write in code: ```ts // config/flags.ts import { defineConfig, envFlagsProvider, staticFlagsProvider } from 'kitr/flags' import { flags } from '#app/flags' export default defineConfig({ definitions: flags, providers: [ envFlagsProvider(), staticFlagsProvider((context) => ({ 'new-dashboard': context.teamId === 'design', 'max-upload-mb': context.plan === 'pro' ? 100 : undefined, })), ], }) ``` The design team sees the new dashboard. Pro teams get 100 MB; for everyone else `undefined` means "no opinion", so the default of 10 answers. The context holds `userId` plus whatever you passed to `resolveFlags` or `resolve`. ### OpenFeature Connect any vendor that has an [OpenFeature provider](https://openfeature.dev/ecosystem): LaunchDarkly, Flagsmith, Unleash, GrowthBook, ConfigCat and others. Install the SDK for the side you resolve on, plus your vendor's provider: ```sh pnpm add @openfeature/server-sdk ``` ```ts // config/flags.ts import { InMemoryProvider, OpenFeature } from '@openfeature/server-sdk' import { defineConfig, envFlagsProvider, openFeatureFlagsProvider } from 'kitr/flags' import { flags } from '#app/flags' // Replace InMemoryProvider with your vendor's provider, configured with its SDK key. OpenFeature.setProvider( new InMemoryProvider({ 'new-dashboard': { variants: { on: true, off: false }, defaultVariant: 'on', disabled: false }, }), ) export default defineConfig({ definitions: flags, providers: [envFlagsProvider(), openFeatureFlagsProvider(OpenFeature.getClient(), flags)], }) ``` The adapter calls `getBooleanValue`, `getNumberValue` or `getStringValue` depending on each flag's declared default, passing the default and the context with `targetingKey` set to `userId`. Your vendor's key goes in `.env` as the vendor's docs say, for example `LAUNCHDARKLY_SDK_KEY=sdk-...`. Because an OpenFeature client always answers (with the default when it has no rule), put it last. ## Values a provider can return Providers often answer with strings, from env files or remote JSON. Each answer is coerced to the flag's declared type; an answer that can't be read as that type counts as no answer, and the next provider is asked: | Declared type | Accepted answers | Ignored answers | | --- | --- | --- | | `boolean` | `true`/`false`; `'true'`, `'1'`, `'on'`, `'yes'`; `'false'`, `'0'`, `'off'`, `'no'`, `''`; numbers (0 is false) | `'maybe'` | | `number` | Numbers; strings that parse to a finite number, such as `' 7.5 '` | `'lots'`, `''`, `'12px'`, `'Infinity'`, booleans | | `string` | Strings; numbers and booleans, turned into strings | None | ```ts // scripts/coercion_demo.ts import { createFlags, envFlagsProvider, staticFlagsProvider } from 'kitr/flags' import { flags } from '#app/flags' const demo = createFlags(flags, { providers: [ envFlagsProvider({ FLAG_NEW_DASHBOARD: 'yes', FLAG_MAX_UPLOAD_MB: 'lots' }), staticFlagsProvider({ 'max-upload-mb': 50 }), ], }) await demo.resolve() // => { 'new-dashboard': true, 'max-upload-mb': 50, 'invoice-layout': 'classic' } ``` `'lots'` is not a number, so the env provider's answer is ignored and the static provider's `50` wins. ## Retire a flag A flag is finished when the feature has shipped to everyone. Delete it from `app/flags.ts`: ```ts // app/flags.ts import { defineFlags } from 'kitr/flags' export const flags = defineFlags({ 'max-upload-mb': { default: 10, description: 'Largest invoice PDF a team may upload' }, 'invoice-layout': { default: 'classic', description: "'classic' or 'compact' invoice PDFs" }, }) ``` Every typed `useFlag` and `resolveFlags` read of it is now a type error, which lists the code to clean up. Remove the old branch, then the `FLAG_NEW_DASHBOARD` line and the `'new-dashboard'` share in `config/flags.ts`. ## Devtools When the [devtools](/packages/devtools) package is installed, every resolve or hydrate that updates the snapshot logs `resolved` with all values to the `flags` channel. It shows which values the browser booted with, and whether they came from the server's tag. ## Testing `createFlags(definitions, config)` builds a flags service with no app. Give it a `staticFlagsProvider` to test both sides of a flag: ```ts // app/features/invoices/validate_upload.test.ts import { createFlags, staticFlagsProvider } from 'kitr/flags' import { describe, expect, it } from 'vitest' import { flags } from '#app/flags' describe('upload limit flag', () => { it('answers the default until resolved, then the provider', async () => { const service = createFlags(flags, { providers: [staticFlagsProvider({ 'max-upload-mb': 25 })] }) expect(service.value('max-upload-mb')).toBe(10) expect(service.resolved).toBe(false) expect(await service.resolve({ userId: 'ada' })).toEqual({ 'new-dashboard': false, 'max-upload-mb': 25, 'invoice-layout': 'classic', }) expect(service.value('max-upload-mb')).toBe(25) }) it('targets by context', async () => { const service = createFlags(flags, { providers: [staticFlagsProvider((ctx) => ({ 'new-dashboard': ctx.userId === 'grace' }))], }) expect((await service.resolve({ userId: 'grace' }))['new-dashboard']).toBe(true) expect((await service.resolve({ userId: 'linus' }))['new-dashboard']).toBe(false) }) }) ``` To render a component with a flag set, boot a client app with the flags provider and render it with `renderWithKitr` from [testing](/packages/testing). The provider resolves at boot on the client, so the first render already has the value: ```tsx // app/features/dashboard/dashboard_page.test.tsx import { defineConfig, staticFlagsProvider } from 'kitr/flags' import flagsProvider from 'kitr/flags/provider' import { createTestApp } from 'kitr/testing' import { renderWithKitr } from 'kitr/testing/react' import { screen } from '@testing-library/react' import { expect, it } from 'vitest' import { flags } from '#app/flags' import { DashboardPage } from './dashboard_page' // NewDashboard renders

Your new dashboard

. it('shows the new dashboard when the flag is on', async () => { const app = await createTestApp({ environment: 'client', providers: [flagsProvider], config: { flags: defineConfig({ definitions: flags, providers: [staticFlagsProvider({ 'new-dashboard': true })] }), }, }) renderWithKitr(, { app }) expect(screen.getByRole('heading', { name: 'Your new dashboard' })).toBeTruthy() }) ``` Pass `environment: 'client'`. In the default `test` environment the provider does not resolve at boot, and the root app does not keep a resolved snapshot, as on the server. To flip a flag mid-test, call `app.container.use('flags').hydrate({ 'new-dashboard': false })` inside `act()`; subscribed components re-render. ## Extend A provider is an object with a `name`, an optional `init(context)` and `resolve(key, context)`: | Member | Called | Returns | | --- | --- | --- | | `name` | In warnings | A label, such as `database`. | | `init(context)` | Once per `resolve()`, before any key | Nothing. A throw skips the provider for this resolve. | | `resolve(key, context)` | Once per declared flag | A value, or `undefined` for "no opinion". May be async. | `init` is the place to load everything a provider needs for one context in a single request, so `resolve` can answer from memory. This provider reads per-team overrides that Acme's support staff publish as JSON at `https://flags.acme.com/teams/.json`: ```ts // app/flag_providers/team_overrides.ts import type { FlagsProvider, FlagValue } from 'kitr/flags' /** Overrides by team, from https://flags.acme.com/teams/.json: { "new-dashboard": true }. */ export function teamOverridesProvider(baseUrl = 'https://flags.acme.com/teams'): FlagsProvider { const byTeam = new Map>() return { name: 'team-overrides', async init(context) { const teamId = context.teamId as string | undefined if (!teamId || byTeam.has(teamId)) return const response = await fetch(`${baseUrl}/${encodeURIComponent(teamId)}.json`) if (!response.ok) throw new Error(`flag overrides for ${teamId}: HTTP ${response.status}`) byTeam.set(teamId, (await response.json()) as Record) }, resolve(key, context) { const teamId = context.teamId as string | undefined return teamId ? byTeam.get(teamId)?.[key] : undefined }, } } ``` When the file can't be fetched, `init` throws, the provider is skipped for that resolve with a warning, and the next provider answers. This sketch keeps loaded teams for the life of the process; clear `byTeam` on a timer when overrides must change without a restart. Register the provider in priority order: ```ts // config/flags.ts import { defineConfig, envFlagsProvider, percentageFlagsProvider } from 'kitr/flags' import { flags } from '#app/flags' import { teamOverridesProvider } from '#app/flag_providers/team_overrides' export default defineConfig({ definitions: flags, providers: [envFlagsProvider(), teamOverridesProvider(), percentageFlagsProvider({ 'new-dashboard': 10 })], }) ``` The same config runs in the browser, which is why this provider uses `fetch` and an absolute URL rather than a database client. Keep server-only imports out of `config/flags.ts`. See [`FlagsProvider`](/packages/flags/api#flagsprovider) in the API reference. ## Next steps - [API reference](/packages/flags/api): every export with its signature. - [OpenFeature](https://openfeature.dev/docs/reference/intro): the vendor-neutral flag standard the adapter speaks. - [Auth](/packages/auth): the signed-in user whose id targets rollouts. - [Cache](/packages/cache): remember slow server reads between requests. - [Configuration](/docs/configuration): how `config/flags.ts` is loaded. # Forms API Source: https://kitr.dev/packages/forms/api `kitr/forms` re-exports everything from `react-hook-form` (its `Form` as `RhfForm`) and adds the following. ## useSchemaForm ```ts function useSchemaForm( schema: StandardSchemaV1, options?: Omit, 'resolver'>, ): UseFormReturn ``` `useForm` with a Standard Schema resolver and `mode: 'onTouched'`. Every React Hook Form option is accepted. `useZodForm` is an alias. ## Form ```tsx
``` | Prop | Type | Description | | --- | --- | --- | | `form` | `UseFormReturn` | From `useSchemaForm` or `useForm`. | | `onSubmit` | `(values: TOut) => unknown` | Runs with validated values. Throw or reject to report errors. | | `onSuccess` | `(result, values) => void` | Runs after `onSubmit` resolves. | | `onError` | `(error: KitrError) => void` | Runs after errors are applied to fields. | | `resetOnSuccess` | `boolean` | Reset to default values after success. | Any other prop goes to the `` element, which always has `noValidate`. ## FormField ```tsx {children | ({ id, name, invalid, describedBy }) => ReactNode} ``` Renders a wrapper with `data-kitr="field"` (and `data-invalid` when it has an error), a `