api
Build JSON API routes with validation, and call them from the browser fully typed.
When your app needs its own backend, such as saving projects, sending invites or charging cards, you write API routes. kitr/api gives you a router, controllers and validation 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.
Install
kitr add apiFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
This adds Server if it isn't there, installs @kitr/api, and writes:
config/api.ts- the routes file:
app/api/routes.tsin a React app,start/routes.tsin a pure backend - a
HealthControlleras a first example
Routes
// app/api/routes.ts
import { cors, defineRoutes } from 'kitr/api'
import ProjectsController from '#app/controllers/projects_controller'
export const routes = defineRoutes((router) =>
router.group({ prefix: '/api', middleware: [cors()] }, (api) =>
api
.resource('projects', ProjectsController)
.get('/me', ({ locals }) => locals.user, { name: 'me' }),
),
)
export type AppRoutes = typeof routes.$types
resource('projects', Controller) creates the five standard routes for whichever methods the controller has:
| Method | Path | Controller method | Client call |
|---|---|---|---|
| GET | /api/projects |
index |
api.projects.index() |
| POST | /api/projects |
store |
api.projects.store({ body }) |
| GET | /api/projects/:id |
show |
api.projects.show({ params: { id } }) |
| PATCH | /api/projects/:id |
update |
api.projects.update({ params, body }) |
| DELETE | /api/projects/:id |
destroy |
api.projects.destroy({ params }) |
Single routes take a path, a handler and options: get, post, put, patch and delete. The handler is a function or a [Controller, 'method'] pair. Give a route a name to call it from the client.
Controllers
A controller is a plain class. Each method receives the request context and returns the response body:
// app/controllers/projects_controller.ts
import type { HttpContext } from 'kitr/api'
import { KitrError } from 'kitr/app'
import { z } from 'zod'
const createProject = z.object({ name: z.string().min(2) })
export default class ProjectsController {
static validation = { store: { body: createProject } }
async index({ query }: HttpContext) {
return db.projects.list({ page: Number(query.page ?? 1) })
}
async show({ params }: HttpContext<{ id: string }>) {
const project = await db.projects.find(params.id)
if (!project) throw KitrError.notFound('Project')
return project
}
async store({ body, response }: HttpContext<{}, {}, z.infer<typeof createProject>>) {
response.status(201)
return db.projects.create(body)
}
}
kitr make:controller projects --resource writes this skeleton.
- Validation.
static validationmaps a method to schemas for itsbody,queryorparams; single routes take{ validate: { body } }in their options. Any Standard Schema works. A failure responds422withfieldErrors, which Forms shows on the right fields. - Errors. Throw
KitrError.notFound(),.forbidden(),.unauthorized()or.validation({...}). Anything else becomes a500; in production its message is replaced with "Internal server error" so internals don't leak. - Context.
ctxhasparams,query,body,request,response,locals(set by middleware),logger,requestIdandapp.
The typed client
// app/api/client.ts
import { createApiClient } from 'kitr/api/client'
import type { AppRoutes } from './routes'
export const api = createApiClient<AppRoutes>({ baseUrl: '/' })
Only the type is imported, so no server code reaches the browser. Every call is checked:
const project = await api.projects.show({ params: { id: '1' } }) // Project
await api.projects.store({ body: { name: 'Apollo' } }) // body is type-checked
Each endpoint also produces query contracts, with keys derived from the route name and input:
const { data } = useSuspenseQuery(defineQuery(api.projects.index.query()).options({}))
const create = useKitrMutation(
defineMutation(api.projects.store.mutation({ effects: { invalidate: [['api', 'projects.index']] } })),
)
Middleware
import { defineMiddleware, rateLimit, requireJson } from 'kitr/api'
const audit = defineMiddleware(async (ctx, next) => {
ctx.logger.info('request', { path: ctx.request.url.pathname })
return next()
})
router.group({ prefix: '/api', middleware: [audit, requireJson()] }, (api) =>
api.post('/invites', [InvitesController, 'store'], { middleware: [rateLimit({ max: 5 })] }),
)
Built in: cors(), rateLimit(), requireJson() and compose(). Authentication comes from authMiddleware in Auth.
OpenAPI
kitr api:syncFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
Writes an OpenAPI 3.1 document from your routes, with validators converted to JSON Schema (zod 4 automatically). Use it for API docs, Postman or clients in other languages. toOpenApi(routes) does the same in code.
Next steps
- API reference
- Server: ports, health checks and shutdown