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 client for the browser that knows every route's inputs and outputs without generating any code.
It runs on h3 through Server, inside a TanStack Start app or as a standalone backend with no React at all. Validation takes any Standard Schema library, such as zod, valibot or arktype, and kitr api:sync writes an OpenAPI 3.1 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: 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 once. Inside a project, the global kitr runs your project's own version.
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/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
defineRoutes is the core's route table (kitr/app); the api extension from @kitr/api/routes gives it .get, .post, .resource and friends. realtime adds .ws and .sse to the same table, and a React app keeps its pages on a second table in app/routes.ts, so server/routes.ts never imports React.
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 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 add api asks no questions. 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 this file, with your package name in the title:
// 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 prefix every route shares, the part the web app forwards to the server. Set it on the routes with router.group({ prefix: '/api' }) in server/routes.ts; kitr api:sync warns about any route outside it, because requests to it never reach the server. |
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 the route table, routes.gen.ts, next to it. Without it the table goes to app/api/, or server/ in a project without one. |
kitr api:sync loads config/api.ts the way the app does, so the values can come from variables or imports. 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:
// 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<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/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 { "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:
// server/routes.ts
import { defineRoutes } from '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. 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:
// server/routes.ts
import { defineRoutes } from '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', ...) 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 <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/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 instead of the class. The file loads on the first request, which keeps server start-up fast when there are many controllers:
// server/routes.ts
import { defineRoutes } from '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 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:
// 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<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. |
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:
// 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. 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:
// server/routes.ts
import { defineRoutes } from '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
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:
// 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:
// 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:
// 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:
// 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')
}
}
{ "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 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:
// 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/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/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 for the origins you list. The generated routes file leaves it out: the app and its API share an origin, so no CORS is needed. Add it when another site calls the API:
// server/routes.ts
import { cors } from '@kitr/api'
import { defineRoutes } from '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 | One origin, a list, a function (origin) => boolean, or '*' for a public API. With none, no cross-origin caller is allowed. '*' with credentials throws, because it would let any site act as the user. |
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: '*'withcredentials: truethrows when the middleware is created: it would let any site 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:
// server/routes.ts
import { rateLimit } from '@kitr/api'
import { defineRoutes } from '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 (seconds); a 429 also carries retry-after. The sixth request in a minute gets { "code": "RATE_LIMITED", "message": "Too many requests", "status": 429 }.
By default 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. Pass a shared store for one limit across instances:
// 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 in place of the inline rateLimit(...) above. The store is the same one the shield uses; it needs ioredis installed (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/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 the runtime route table it needs, which 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 bundle. Regenerate the table whenever you add or rename a route:
kitr api:syncFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
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. On the server you can register straight from the router with registerRoutes(api, routes.toJSON()).
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 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:
// 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. query() uses the endpoint's key and fetch, and 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 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:
// app/lib/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:
// 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 here comes from 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:
// 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 }) compiles the table for h3: every extension with an h3 target mounts its entries, 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) then says whether a path belongs to the table, which is how app/server.ts splits requests between the kitr server and TanStack Start in a React app. To mount a second table, or one under a base path, mountRoutes(server, partnerRoutes, '/partners') is still there; to serve the endpoints 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:
// 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 once. Inside a project, the global kitr runs your project's own version.
kitr api:sync loads server/routes.ts and 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.
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:
// server/routes.ts
import { defineRoutes } from '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 (orders). Every operation documents a 200 response, including those that answer 201.
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/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
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 (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 an existing file.
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 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, or with 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. Each test file gets a fresh module, so in-memory rateLimit() counters start at zero.
Next steps
- API reference
- Server: ports, request context, errors and shutdown
- Auth:
authMiddlewareand permissions for these routes - Query: the contracts the client produces
- Forms: showing
fieldErrorson fields - Build an API server without React