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, and a browser client that knows every route's inputs and outputs without generating code.
It runs on h3 through Server, inside a TanStack Start app or standalone. Validation takes any Standard Schema library, and kitr api:sync writes an OpenAPI 3.1 document from the same routes. Routes run on the server; the typed client (@kitr/api/client) runs anywhere.
The examples build the Acme orders API at app.acme.com/api.
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: no URLs to design, and the same validation and error shape. Both can live in one app.
Install
kitr add apiFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
What kitr add api does:
- Adds the Server package first if it is missing, which also adds Logger.
- Installs
@kitr/apiand adds it topackagesinkitrrc.ts. API routes register no provider. - Writes
config/api.ts(shown under Configure) and adds it toconfig/index.ts. - Writes
server/routes.ts, the server route table, withapifrom@kitr/api/routesin its extension list and oneGET /api/healthroute. When another package created the table first, it only addsapito the list. - Writes
server/controllers/health-controller.ts, the controller that route calls. - When
server/main.tsexists and does not pass the table tocreateServer()yet, addsimport { routes } from './routes'and theroutesoption. - In a React project, writes
app/api/routes.gen.ts, the route table with thehealthroute, andapp/api/client.ts, the typed client that registers it. Without the table every named call throwsUnknown route. - Adds an
api:syncscript (kitr api:sync) topackage.json, which regenerates the route table andopenapi.jsonfromserver/routes.ts. - Adds a
kitr: apisection toAGENTS.md: where routes, controllers and validators go, when to use API routes instead of actions, and how the client gets its route table.
The generated routes file:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import HealthController from '#server/controllers/health-controller'
/**
* Endpoints and sockets of this app. `kitr add` keeps the extension list current; `createServer({ routes })`
* in server/main.ts serves it.
*/
export const routes = defineRoutes([api], (root) =>
root.group('/api', (r) =>
r.get('/health', [HealthController, 'show'], { name: 'health' }),
// r.resource('projects', () => import('#server/controllers/projects-controller'))
),
)
/** Pass to createApiClient<ServerRoutes>() on the client. */
export type ServerRoutes = typeof routes.$types
The api extension from @kitr/api/routes gives the table .get, .post, .resource and friends. realtime adds .ws and .sse to the same table.
The generated controller:
// 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; add one with kitr add server api. The fullstack, server, saas, site and realtime kits include API routes.
Removing it
kitr remove api removes @kitr/api from package.json and kitrrc.ts, the api:sync script, config/api.ts and 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:
// 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 | The path the web app forwards to the server, such as '/api'. kitr api:sync warns about routes outside it. |
openapi.path |
'openapi.json' |
Where kitr api:sync writes the OpenAPI document. |
openapi.title |
'<package name> API' |
info.title in the document. |
openapi.version |
package.json version |
info.version in the document. |
openapi.description |
none | info.description in the document. |
client.path |
'app/api/client.ts' |
The typed client's file. kitr api:sync writes routes.gen.ts next to it. |
Build your first endpoint
A controller for orders. Each method receives the request context and returns the response body:
// server/controllers/orders-controller.ts
import type { HttpContext } from '@kitr/api'
import { KitrError } from '@kitr/kitr/app'
export interface Order {
id: string
customer: string
total: number
status: 'open' | 'paid'
}
const orders = new Map<string, Order>([
['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:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import HealthController from '#server/controllers/health-controller'
import OrdersController from '#server/controllers/orders-controller'
export const routes = defineRoutes([api], (router) =>
router.group({ prefix: '/api' }, (r) =>
r
.get('/health', [HealthController, 'show'], { name: 'health' })
.resource('orders', OrdersController),
),
)
export type ServerRoutes = typeof routes.$types
Start the server with kitr dev api (or kitr dev in a React app) and ask for an order:
curl http://localhost:3001/api/orders/ord_1042
{ "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 a NOT_FOUND error.
Define routes
One method per HTTP verb, each taking a path, a handler and options, and chaining:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import InvoicesController from '#server/controllers/invoices-controller'
export const routes = defineRoutes([api], (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 ServerRoutes = 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.
Group routes
router.group() shares a prefix, middleware and a name prefix across routes:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import OrdersController from '#server/controllers/orders-controller'
import TeamsController from '#server/controllers/teams-controller'
import { audit } from '#server/middleware/audit'
export const routes = defineRoutes([api], (router) =>
router.group({ prefix: '/api' }, (r) =>
r
.resource('orders', OrdersController)
.group({ prefix: '/admin', middleware: [audit], as: 'admin' }, (admin) =>
admin.resource('teams', TeamsController),
),
),
)
export type ServerRoutes = 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', ...) is short for { prefix: '/api' }. router.use(audit) adds middleware to routes registered after the call.
Create a resource
resource(name, Controller) registers the five REST routes for the methods the controller has, and names them <name>.<action>:
| 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:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import InvoicesController from '#server/controllers/invoices-controller'
import ProjectsController from '#server/controllers/projects-controller'
import { audit } from '#server/middleware/audit'
export const routes = defineRoutes([api], (router) =>
router.group('/api', (r) =>
r
.resource('invoices', InvoicesController, { only: ['index', 'show'] })
.resource('projects', ProjectsController, {
except: ['destroy'],
param: 'projectId',
middleware: [audit],
}),
),
)
export type ServerRoutes = 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. It loads on the first request, which keeps start-up fast:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
export const routes = defineRoutes([api], (router) =>
router.group('/api', (r) =>
r
.resource('orders', () => import('#server/controllers/orders-controller'))
.get('/orders/:id/receipt', [() => import('#server/controllers/receipts-controller'), 'show'], {
name: 'orders.receipt',
}),
),
)
export type ServerRoutes = typeof routes.$types
A lazy controller can't be inspected, so resource() registers all five routes; a missing method answers 404.
Write a controller
A controller is a plain class with a default export; kitr make:controller orders --resource writes one. Type HttpContext's three parameters for params, query and body:
// server/controllers/orders-controller.ts
import type { HttpContext } from '@kitr/api'
import { KitrError } from '@kitr/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<string, Order>([
['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<typeof createOrder>>) {
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<typeof updateOrder>>) {
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 sets user and can. |
logger |
The request's logger, which adds the request id. |
requestId |
The request id from 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. |
A new controller instance is built per request from that request's container. List the bindings its constructor needs in static inject:
// server/controllers/projects_controller.ts
import type { Db } from '#server/db'
export default class ProjectsController {
static inject = ['db'] as const
constructor(private db: Db) {}
async index() {
return this.db.projects.all()
}
}
Validate input
Declare schemas per method in static validation. kitr validates before the method runs, and the typed client picks up the same types:
// 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:
curl -X POST http://localhost:3001/api/orders \
-H 'content-type: application/json' \
-d '{"customer":"A","total":-1}'
{
"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 puts fieldErrors on the matching fields.
For function handlers, pass validate in the route options. params and query schemas also convert values, so page below is a number:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import { z } from 'zod'
const invoicePage = z.object({ page: z.coerce.number().int().min(1).default(1) })
export const routes = defineRoutes([api], (router) =>
router.get('/api/invoices', ({ query }) => ({ page: query.page }), {
name: 'invoices.index',
validate: { query: invoicePage },
}),
)
export type ServerRoutes = typeof routes.$types
?page=2 answers { "page": 2 }; ?page=0 answers 422.
When the shape depends on the request, call ctx.validate(); it throws the same 422:
// 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 }
}
}
Route-option validators win over static validation. Without any, body is the raw parsed body on writes and undefined on reads.
Read the request
ctx.request wraps the incoming request:
// 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 BAD_REQUEST, not a 500.
Shape the response
Whatever the handler returns becomes the JSON body with status 200. Use ctx.response to change that:
// 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. |
Returning undefined or null answers 204 (or the status you set). You can also return any web Response, such as a file stream.
Return errors
Throw a KitrError from @kitr/kitr/app to answer with an error. The status follows the code:
// server/controllers/invoices-controller.ts
import type { HttpContext } from '@kitr/api'
import { KitrError } from '@kitr/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')
}
}
{ "code": "CONFLICT", "message": "Invoice INV-1042 is already paid", "status": 409 }
Write middleware
Middleware takes the context and next. Call next() to continue the chain, or return without it to answer early:
// server/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 server/middleware/audit.ts. Attach middleware to a group, a resource or one route:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import InvitesController from '#server/controllers/invites-controller'
import { audit } from '#server/middleware/audit'
import { requireTeam } from '#server/middleware/require-team'
export const routes = defineRoutes([api], (router) =>
router.group({ prefix: '/api', middleware: [audit] }, (r) =>
r.post('/teams/:teamId/invites', [InvitesController, 'store'], {
name: 'invites.store',
middleware: [requireTeam],
}),
),
)
export type ServerRoutes = typeof routes.$types
Pass data to the handler through ctx.locals, and stop a request by throwing a KitrError:
// server/middleware/require-team.ts
import { defineMiddleware } from '@kitr/api'
import { KitrError } from '@kitr/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 group first, then route, then the handler, and back out in reverse. Calling next() twice throws. compose([a, b]) combines several into one.
Allow cross-origin requests
cors() answers preflight requests and sets CORS headers for the origins you list; it takes the same options as cors() in @kitr/shield. The generated routes file leaves it out because the app and its API share an origin. Add it when another site calls the API:
// server/routes.ts
import { cors } from '@kitr/api'
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import OrdersController from '#server/controllers/orders-controller'
export const routes = defineRoutes([api], (router) =>
router.group(
{
prefix: '/api',
middleware: [cors({ origin: ['https://app.acme.com', 'https://admin.acme.com'], credentials: true })],
},
(r) => api.resource('orders', OrdersController),
),
)
export type ServerRoutes = typeof routes.$types
| Option | Default | Description |
|---|---|---|
origin |
none | Allowed origins: one, a list, a function, or '*' for a public API. Unset allows none. '*' with credentials throws. |
methods |
GET, POST, PUT, PATCH, DELETE, OPTIONS |
access-control-allow-methods on preflight. |
headers |
Content-Type, Authorization, X-CSRF-Token, X-Requested-With |
Request headers a cross-origin caller may send. |
credentials |
false |
Sends access-control-allow-credentials: true, needed for cookies. |
maxAge |
600 |
Seconds a browser may cache the preflight. |
kitr registers the OPTIONS route for every route behind cors().
Rate limit a route
rateLimit() counts requests per client IP in a window and answers 429 past the limit:
// server/routes.ts
import { rateLimit } from '@kitr/api'
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import InvitesController from '#server/controllers/invites-controller'
export const routes = defineRoutes([api], (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 ServerRoutes = 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. |
store |
memory | Where counters live. Any RateLimitStore; createRedisRateLimitStore from Shield shares them between instances. |
Every response carries ratelimit-limit, ratelimit-remaining and ratelimit-reset; a 429 also carries retry-after.
// server/security/invite_limit.ts
import { rateLimit } from '@kitr/api'
import { createRedisRateLimitStore } from '@kitr/shield'
import { Redis } from 'ioredis'
const store = createRedisRateLimitStore(new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'), {
prefix: 'acme-app:invites:',
})
/** Five invites per team per minute, counted across every instance. */
export const inviteLimit = rateLimit({
max: 5,
windowMs: 60_000,
key: (ctx) => ctx.request.header('x-acme-team') ?? 'anonymous',
store,
})
Use inviteLimit in the route's middleware list. The store needs ioredis (pnpm add ioredis).
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:
// server/routes.ts
import { requireJson } from '@kitr/api'
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import OrdersController from '#server/controllers/orders-controller'
export const routes = defineRoutes([api], (router) =>
router.group({ prefix: '/api', middleware: [requireJson()] }, (r) =>
r.resource('orders', OrdersController),
),
)
export type ServerRoutes = typeof routes.$types
Call the API from the browser
In a React project, kitr add api writes the client and a route table that maps names such as orders.show to a method and path:
// app/api/client.ts
import { createApiClient, registerRoutes } from '@kitr/api/client'
import type { ServerRoutes } from '#server/routes'
import { routeTable } from './routes.gen'
/** Typed, no codegen: route names, params, bodies and outputs come from the router. */
export const api = createApiClient<ServerRoutes>({ baseUrl: typeof window === 'undefined' ? `http://localhost:${process.env.PORT ?? 3000}` : '' })
registerRoutes(api, [...routeTable])
Only the type is imported from server/routes.ts, so no server code reaches the browser. Regenerate the table whenever you add or rename a route:
kitr api:syncFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Every call is checked against the route:
// app/lib/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 has up to three keys: params (required when the path has parameters), query and body (sent as JSON on everything but GET and HEAD). The return type is what the controller returns; a 204 resolves to undefined.
Each endpoint also exposes helpers:
// app/lib/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 <token> when it returns one. |
credentials |
Passed to fetch: 'include' sends cookies cross-origin. |
Use endpoints with TanStack Query
Every endpoint produces Query contracts; mutation() takes cache effects:
// app/components/orders/orders-page.tsx
import { mutationFrom, queryFrom, useMutation, useSuspenseQuery } from '@kitr/query'
import { api } from '#app/api/client'
const ordersQuery = queryFrom(api.orders.index.query())
const createOrder = mutationFrom(
api.orders.store.mutation({ effects: { invalidate: [['api', 'orders.index']] } }),
)
export function OrdersPage() {
const { data: orders } = useSuspenseQuery(ordersQuery.options({}))
const create = useMutation(createOrder)
return (
<section>
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.customer}: {order.total}
</li>
))}
</ul>
<button type="button" onClick={() => create.mutate({ body: { customer: 'Linus', total: 75 } })}>
New order for Linus
</button>
</section>
)
}
The query key is ['api', '<route name>', params, query], so invalidate: [['api', 'orders.index']] refetches every orders list. 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:
// app/lib/orders/create.ts
import { KitrError } from '@kitr/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:
// app/api/client.ts
import { createApiClient, registerRoutes } from '@kitr/api/client'
import type { ServerRoutes } from '#server/routes'
import { app } from '#app/app'
import { routeTable } from './routes.gen'
export const api = createApiClient<ServerRoutes>({
baseUrl: 'https://app.acme.com',
getToken: () => app.container.use('auth').getToken(),
headers: () => ({ 'x-acme-team': 'team_7' }),
})
registerRoutes(api, [...routeTable])
getToken comes from Auth. For cookie sessions, pass credentials: 'include' instead. On the server, authMiddleware from @kitr/auth/server sets ctx.locals.user.
Call routes in-process
On the server, or in tests, pass the server's fetch so calls skip the network:
// server/jobs/nightly-report.ts
import { createApiClient, registerRoutes } from '@kitr/api/client'
import { server } from '#server/main'
import { type ServerRoutes, routes } from '#server/routes'
const api = createApiClient<ServerRoutes>({ 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
createServer({ routes }) mounts the table, so endpoints and sockets go live together. server/main.ts does this for you:
// server/main.ts
import { createServer } from '@kitr/server'
import { routes } from './routes'
import { config } from '../config'
import rc from '../kitrrc'
export const server = createServer({ providers: rc.providers, config, routes })
server.handles(request) says whether a path belongs to the table. To mount a second table under a base path, use mountRoutes(server, partnerRoutes, '/partners'); for your own h3 app, call toH3(routes, app).
Build URLs for named routes
routes.url() builds a path from a route name, with encoded parameters and an optional query string:
// 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
kitr api:syncFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
kitr api:sync writes two files:
openapi.json(oropenapi.pathfromconfig/api.ts): an OpenAPI 3.1 document. Every route becomes an operation with its name asoperationId, path parameters, query parameters and request body from the validators, and theKitrErrorschema for422.app/api/routes.gen.ts(orserver/routes.gen.tswhen there is noapp/api/folder): the route table forregisterRoutes.
Zod 4 and arktype schemas convert automatically; valibot needs @valibot/to-json-schema. Schemas that can't be converted are left out.
Describe operations with meta:
// server/routes.ts
import { defineRoutes } from '@kitr/kitr/app'
import api from '@kitr/api/routes'
import OrdersController from '#server/controllers/orders-controller'
export const routes = defineRoutes([api], (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 ServerRoutes = typeof routes.$types
Build the document in code with toOpenApi(), for example to serve it from a route:
// 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.
Commands
| Command | What it does |
|---|---|
kitr make:controller <name> |
Writes server/controllers/<name>_controller.ts with one handle method. |
kitr make:controller <name> --resource |
Writes the controller with index, show, store, update, destroy and zod validators. |
kitr make:middleware <name> |
Writes server/middleware/<name>.ts with defineMiddleware. |
kitr make:validator <name> |
Writes app/validators/<name>.ts: a zod schema with create and update variants and its input type, to share between a form, an action and a controller. |
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:
// server/controllers/orders-controller.ts
import type { HttpContext } from '@kitr/api'
import { KitrError } from '@kitr/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<typeof createOrders>>) {
response.status(201)
return { id: '1', ...body }
}
async update({ params, body }: HttpContext<{ id: string }, {}, z.infer<typeof updateOrders>>) {
return { id: params.id, ...body }
}
async destroy({ params }: HttpContext<{ id: string }>) {
void params
}
}
kitr make:middleware audit writes:
// server/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 make:validator order writes:
// app/validators/order.ts
import { z } from 'zod'
/** Shared between the form, the action and the controller, so one schema decides what is valid. */
export const orderSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
})
export const createOrderSchema = orderSchema
export const updateOrderSchema = orderSchema.partial()
export type OrderInput = z.infer<typeof orderSchema>
kitr routes:list prints:
GET /api/health health
GET /api/orders orders.index
POST /api/orders orders.store
GET /api/orders/:id orders.show
Routes file lookup and custom stubs
The commands look for server/routes.ts, then app/api/routes.ts, then start/routes.ts. Put a stubs/controller.ts in your project (or run kitr eject stub controller) to change what make:controller writes; stubs/middleware.ts and stubs/validator.ts work the same way. Every generator takes --force to overwrite.
Errors
Errors reach the client as JSON, and the typed client turns them 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 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
Use createTestClient from Testing, or the typed client pointed at server.fetch. No port is opened:
// 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 ServerRoutes, routes } from '#server/routes'
const api = createApiClient<ServerRoutes>({ 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:
// test/order-client.test.ts
import { createApiClient, registerRoutes } from '@kitr/api/client'
import { expect, it, vi } from 'vitest'
import type { ServerRoutes } 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<ServerRoutes>({ 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.