Guide · Build an app

Build an API server without React

A JSON API with controllers, validation, a database, auth, realtime events, OpenAPI and a typed client, and no React anywhere.

Not every project has a UI. A billing service, a webhook receiver, the backend for an iPhone app: these are HTTP servers with routes, a database and tests, and nothing to render. kitr builds them from the same packages a full-stack app uses, minus everything that touches the browser. kitr doctor keeps it that way.

You will build acme-billing, Acme's invoices API: REST routes for invoices with validation, a Postgres table behind them, bearer-token auth, an event pushed over WebSocket when an invoice is paid, an OpenAPI document for partners, and a typed client the frontend team imports without generating code.

Before you start

You need Node.js 22 or newer (or Bun or Deno; see "Pick a runtime" below), the kitr CLI (npm install -g kitr) and a Postgres database for the database step. This starts one with Docker:

docker run -d --name acme-billing-db -e POSTGRES_PASSWORD=acme -e POSTGRES_DB=billing -p 5432:5432 postgres:17

Create the project

kitr new acme-billing --kit server
cd acme-billing

The server kit is a backend whose dependency graph has no React, no TanStack and no Vite. It installs Server, API routes, Logger, DB, Realtime and Testing:

acme-billing/
├── server/
│   ├── main.ts             # createServer() + routes + realtime + listen()
│   ├── routes.ts           # defineRoutes(...)
│   ├── realtime.ts         # createRealtimeServer({ channels })
│   └── controllers/        # one class per resource
├── app/
│   ├── channels/           # defineChannel(...): shared with any client
│   └── env.ts              # validated environment variables
├── config/                 # api, db, logger, realtime, server; index.ts lists them
├── test/                   # createTestClient(server)
└── kitrrc.ts               # platforms: { api: server() }

Run it:

cp .env.example .env
npm run dev

npm run dev runs kitr dev, which starts the api platform with tsx watch server/main.ts on port 3001. The kit wrote one route:

curl http://localhost:3001/api/health
{ "ok": true, "requestId": "3106a213-994f-4363-b099-eb8c19dc7299" }

Every response carries an x-request-id header, and every log line for that request carries the same id.

Add a resource

A controller is a plain class; each method takes the request context and returns the response body. Let the CLI write the five REST methods:

kitr make:controller invoices --resource

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

Replace the generated body with Acme's invoices. They live in memory for now; the next section moves them to Postgres:

// server/controllers/invoices_controller.ts
import type { HttpContext } from 'kitr/api'
import { KitrError } from 'kitr/app'
import { z } from 'zod'

export const createInvoice = z.object({
  customerEmail: z.email(),
  amountCents: z.number().int().positive(),
})

export interface Invoice {
  id: string
  customerEmail: string
  amountCents: number
  status: 'open' | 'paid'
}

const invoices = new Map<string, Invoice>()

export default class InvoicesController {
  static validation = {
    store: { body: createInvoice },
  }

  async index() {
    return [...invoices.values()]
  }

  async show({ params }: HttpContext<{ id: string }>) {
    const invoice = invoices.get(params.id)
    if (!invoice) throw KitrError.notFound('Invoice')
    return invoice
  }

  async store({ body, response, logger }: HttpContext<{}, {}, z.infer<typeof createInvoice>>) {
    const invoice: Invoice = { id: `inv_${2041 + invoices.size}`, status: 'open', ...body }
    invoices.set(invoice.id, invoice)
    logger.info('invoice created', { id: invoice.id })
    response.status(201)
    return invoice
  }
}

Register it. resource() adds a route for each REST method the controller has, named invoices.<method>:

// server/routes.ts
import { defineRoutes } from 'kitr/api'
import HealthController from '#server/controllers/health_controller'
import InvoicesController from '#server/controllers/invoices_controller'

export const routes = defineRoutes((router) =>
  router.group({ prefix: '/api' }, (api) =>
    api
      .get('/health', [HealthController, 'show'], { name: 'health' })
      .resource('invoices', InvoicesController),
  ),
)

export type AppRoutes = typeof routes.$types
kitr routes:list

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

  GET     /api/health                              health
  GET     /api/invoices                            invoices.index
  POST    /api/invoices                            invoices.store
  GET     /api/invoices/:id                        invoices.show

Create one, then send a bad one:

curl -X POST http://localhost:3001/api/invoices \
  -H 'content-type: application/json' \
  -d '{"customerEmail":"[email protected]","amountCents":12000}'
# => {"id":"inv_2041","status":"open","customerEmail":"[email protected]","amountCents":12000}

curl -X POST http://localhost:3001/api/invoices \
  -H 'content-type: application/json' \
  -d '{"customerEmail":"grace","amountCents":-5}'
{
  "code": "VALIDATION",
  "message": "Validation failed",
  "status": 422,
  "fieldErrors": {
    "customerEmail": ["Invalid email address"],
    "amountCents": ["Too small: expected number to be >0"]
  }
}

static validation ran the schema before store, and the body inside store is already typed and parsed. Every error the server sends has this one shape, whether it is a validation failure, a KitrError.notFound() you threw, or a crash (which becomes 500 Internal server error, logged with its stack). Clients handle one shape.

Don't keep request data on this: kitr creates one instance of each controller and reuses it for every request.

Store invoices in Postgres

The kit registered the DB provider and wrote config/db.ts with nothing connected yet. Bring your ORM; this guide uses Drizzle:

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
// server/db/schema.ts
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'

export const invoices = pgTable('invoices', {
  id: text('id').primaryKey(),
  customerEmail: text('customer_email').notNull(),
  amountCents: integer('amount_cents').notNull(),
  status: text('status', { enum: ['open', 'paid'] }).notNull().default('open'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
// config/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { defineDb } from 'kitr/db'
import postgres from 'postgres'
import * as schema from '#server/db/schema'

const createDb = () => drizzle(postgres(process.env.DATABASE_URL!), { schema })

export type AppDb = ReturnType<typeof createDb>

export default defineDb({
  connect: () => createDb(),
  disconnect: (db) => db.$client.end(),
})

Set DATABASE_URL=postgres://postgres:acme@localhost:5432/billing in .env, then create the table with drizzle-kit (the DB guide shows drizzle.config.ts and the scripts). In the controller, ctx.app is the app for this request; useDb returns the pooled client:

// server/controllers/invoices_controller.ts
import { desc, eq } from 'drizzle-orm'
import type { HttpContext } from 'kitr/api'
import { KitrError } from 'kitr/app'
import { useDb } from 'kitr/db'
import { z } from 'zod'
import type { AppDb } from '#config/db'
import { invoices } from '#server/db/schema'

export const createInvoice = z.object({
  customerEmail: z.email(),
  amountCents: z.number().int().positive(),
})

export default class InvoicesController {
  static validation = {
    store: { body: createInvoice },
  }

  async index({ app }: HttpContext) {
    return useDb<AppDb>(app).select().from(invoices).orderBy(desc(invoices.createdAt))
  }

  async show({ app, params }: HttpContext<{ id: string }>) {
    const [invoice] = await useDb<AppDb>(app).select().from(invoices).where(eq(invoices.id, params.id))
    if (!invoice) throw KitrError.notFound('Invoice')
    return invoice
  }

  async store({ app, body, response }: HttpContext<{}, {}, z.infer<typeof createInvoice>>) {
    const [invoice] = await useDb<AppDb>(app)
      .insert(invoices)
      .values({ id: `inv_${crypto.randomUUID().slice(0, 8)}`, ...body })
      .returning()
    response.status(201)
    return invoice
  }
}

The provider connects once during boot, before the first request, and closes the pool on SIGINT or SIGTERM after in-flight requests finish. If Postgres is down at start-up, the server refuses to start and logs the driver's error, rather than failing on the first request.

Require a token

authMiddleware from kitr/auth/server reads Authorization: Bearer <token>, looks the user up with your function and puts them on ctx.locals. In a project without React, install the package yourself (kitr add auth skips client packages here):

pnpm add @kitr/auth
// server/auth.ts
import { authMiddleware } from 'kitr/auth/server'

export interface ApiUser {
  id: string
  email: string
  role: 'admin' | 'member'
}

const tokens = new Map<string, ApiUser>([
  ['tok_ada', { id: 'u_ada', email: '[email protected]', role: 'admin' }],
])

/** Swap the Map for your token table or a JWT verify. */
export const auth = authMiddleware<ApiUser>({ verify: async (token) => tokens.get(token) ?? null })

Put it on the group, so every invoice route needs a user:

// server/routes.ts
import { defineRoutes } from 'kitr/api'
import { auth } from '#server/auth'
import HealthController from '#server/controllers/health_controller'
import InvoicesController from '#server/controllers/invoices_controller'

export const routes = defineRoutes((router) =>
  router.group({ prefix: '/api' }, (api) =>
    api
      .get('/health', [HealthController, 'show'], { name: 'health' })
      .group({ middleware: [auth] }, (secured) => secured.resource('invoices', InvoicesController)),
  ),
)

export type AppRoutes = typeof routes.$types

Without a token, GET /api/invoices answers 401 with { "code": "UNAUTHORIZED", "message": "Authentication required", "status": 401 }. Inside a controller, requireUser<ApiUser>(ctx) returns the typed user. Add sign-in, end to end covers permissions with can() and cookie sessions.

Allow the frontend to call it

When the API and the web app live on different origins (api.acme.com and app.acme.com), the browser asks first. cors() answers:

// server/routes.ts
import { cors, defineRoutes, rateLimit } from 'kitr/api'
import { auth } from '#server/auth'
import HealthController from '#server/controllers/health_controller'
import InvoicesController from '#server/controllers/invoices_controller'

export const routes = defineRoutes((router) =>
  router.group(
    { prefix: '/api', middleware: [cors({ origin: ['https://app.acme.com'] }), rateLimit({ max: 300 })] },
    (api) =>
      api
        .get('/health', [HealthController, 'show'], { name: 'health' })
        .group({ middleware: [auth] }, (secured) => secured.resource('invoices', InvoicesController)),
  ),
)

export type AppRoutes = typeof routes.$types

rateLimit({ max: 300 }) answers 429 past 300 requests a minute per IP. Its counters live in this process's memory: with several instances, use Shield's limiter with a shared store.

Push an event when an invoice is paid

A dashboard showing invoices should not poll. Realtime channels are defined once, in a file any client can import, and the server emits to everyone subscribed:

// app/channels/invoices.ts
import { defineChannel } from 'kitr/realtime'
import { z } from 'zod'

export const invoicesChannel = defineChannel({
  name: 'invoices',
  events: {
    paid: {
      schema: z.object({ id: z.string(), amountCents: z.number() }),
      effects: (invoice) => ({ invalidate: [['api', 'invoices.index']] }),
    },
  },
})
// server/realtime.ts
import { createRealtimeServer } from 'kitr/realtime/server'
import { invoicesChannel } from '#app/channels/invoices'

export const realtime = createRealtimeServer({ channels: [invoicesChannel] })

Add a pay route and emit from it:

// server/controllers/invoices_controller.ts
// ...
import { invoicesChannel } from '#app/channels/invoices'
import { realtime } from '#server/realtime'

export default class InvoicesController {
  // ...index, show, store as before

  async pay({ app, params }: HttpContext<{ id: string }>) {
    const [invoice] = await useDb<AppDb>(app)
      .update(invoices)
      .set({ status: 'paid' })
      .where(eq(invoices.id, params.id))
      .returning()
    if (!invoice) throw KitrError.notFound('Invoice')
    realtime.use(invoicesChannel, {}).emit('paid', { id: invoice.id, amountCents: invoice.amountCents })
    return invoice
  }
}
// server/routes.ts
// ...
        .group({ middleware: [auth] }, (secured) =>
          secured
            .resource('invoices', InvoicesController)
            .post('/invoices/:id/pay', [InvoicesController, 'pay'], { name: 'invoices.pay' }),
        ),
// ...

server/main.ts already attaches the WebSocket server at /ws. A kitr frontend subscribes with useChannel, and its effects refetch the invoice list; any other client can speak the protocol described in the Realtime guide.

Publish OpenAPI and a typed client

Partners want an OpenAPI document. Your own frontend wants types without a code generator:

kitr api:sync
# 5 routes → openapi.json, server/routes.gen.ts

openapi.json describes every named route, with request bodies and parameters converted from the zod schemas. server/routes.gen.ts is a plain table of names, methods and paths.

A kitr frontend in another project imports only the AppRoutes type and the table. Share them through a workspace package (see Run kitr in a monorepo) or publish them as a small types package. On the frontend:

// app/api/billing.ts
import { createApiClient, registerRoutes } from 'kitr/api/client'
import type { AppRoutes } from '@acme/billing-api'
import { routeTable } from '@acme/billing-api/routes'

export const billing = createApiClient<AppRoutes>({
  baseUrl: 'https://api.acme.com',
  getToken: async () => localStorage.getItem('acme:token'),
})
registerRoutes(billing, [...routeTable])

const invoice = await billing.invoices.show({ params: { id: 'inv_2041' } })
// => { id: 'inv_2041', customerEmail: '[email protected]', amountCents: 12000, status: 'open', ... }

billing.invoices.store({ body: { customerEmail: '[email protected]' } }) is a type error, because amountCents is required. A failed call rejects with a KitrError carrying the server's code and fieldErrors. billing.invoices.index.query() and .mutation({ effects }) plug straight into Query.

Pick a runtime

The same server/main.ts runs on four runtimes. Switch with one command:

Runtime Switch kitr dev api runs
Node kitr config api runtime node (the default) tsx watch server/main.ts
Bun kitr config api runtime bun bun --watch server/main.ts
Deno kitr config api runtime deno deno run --watch -A server/main.ts
Cloudflare Workers kitr add server edge --runtime workers wrangler dev

server/main.ts exports the server as its default export, which is the fetch handler Workers and Deno Deploy call. Change the port with kitr config api port 8080, or for one run with KITR_API_PORT=8080 kitr dev.

Test it

The server is a fetch handler, so tests call it without opening a port and run in parallel:

// test/invoices.test.ts
import { createTestClient } from 'kitr/testing'
import { describe, expect, it } from 'vitest'
import { server } from '#server/main'

const client = createTestClient(server, { headers: { authorization: 'Bearer tok_ada' } })

describe('invoices', () => {
  it('rejects a request without a token', async () => {
    const res = await createTestClient(server).get('/api/invoices')
    expect(res.status).toBe(401)
  })

  it('validates a new invoice', async () => {
    const res = await client.post('/api/invoices', { customerEmail: 'grace', amountCents: -5 })
    expect(res.status).toBe(422)
    expect(res.body).toMatchObject({ code: 'VALIDATION' })
  })
})
npm test

The first request boots the app with every provider in kitrrc.ts, database included. For tests that should not touch Postgres, bind a fake client as the DB guide shows.

Keep React out

kitr doctor

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

Among its other checks, kitr doctor fails when a server-only project starts importing React, a router or Vite, for example after someone runs kitr add forms. Client-only packages refuse to install here in the first place. Run it in CI next to npm test.

Next steps

  • Deploy to production: Docker, Node hosts, Bun, and Cloudflare Workers.
  • API routes: lazy controllers, middleware, rate limits, raw responses and every client option.
  • Server: request-scoped services, health checks, graceful shutdown and tracing.
  • Mail: email the invoice to the customer when it is created.
  • Run kitr in a monorepo: share AppRoutes with the frontend through a workspace package.