api

Build JSON API routes with validation, and call them from the browser fully typed.

BackendClient ServerBuilt on h3

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 api

First 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.ts in a React app, start/routes.ts in a pure backend
  • a HealthController as 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 validation maps a method to schemas for its body, query or params; single routes take { validate: { body } } in their options. Any Standard Schema works. A failure responds 422 with fieldErrors, which Forms shows on the right fields.
  • Errors. Throw KitrError.notFound(), .forbidden(), .unauthorized() or .validation({...}). Anything else becomes a 500; in production its message is replaced with "Internal server error" so internals don't leak.
  • Context. ctx has params, query, body, request, response, locals (set by middleware), logger, requestId and app.

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:sync

First 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