Ajo Kit

API routes and middleware

HTTP method handlers under /api, send(), wares.ts and the bootstrap hook.

Pages and actions serve the browser. When another program needs the same data, such as a script, a mobile client or a webhook, a route’s handler.ts can also answer plain HTTP methods under /api. This page covers those handlers, the response and request helpers, middleware in wares.ts, and the startup hook the engine runs before it accepts requests.

Method handlers

The default export of a handler.ts maps HTTP methods to functions. Kit mounts them at /api/ followed by the route’s URL pattern, so this file answers GET and POST on /api/notes, next to the /notes page it may also serve.

src/notes/handler.ts
import type { Request, Response } from 'ajo-kit'
import { emit, send } from 'ajo-kit/server'
import { maxLength, minLength, object, parse, pipe, string, trim } from 'ajo-kit/validate'
import { db } from '../database'

const Note = object({
  title: pipe(string(), trim(), minLength(1), maxLength(120)),
  body: string(),
})

export default {
  async get(_req: Request, res: Response) {
    const notes = await db()
      .selectFrom('notes')
      .select(['id', 'title', 'created'])
      .orderBy('id', 'desc')
      .limit(50)
      .execute()

    send(res, 200, { notes })
  },

  async post(req: Request, res: Response) {
    const input = parse(Note, req.body)
    const note = await db()
      .insertInto('notes')
      .values(input)
      .returning(['id', 'title', 'created'])
      .executeTakeFirstOrThrow()

    emit('notes')
    send(res, 201, note)
  },
}

Dynamic segments become parameters, and route groups stay out of the URL, exactly as they do for pages.

FileEndpoint
src/notes/handler.ts/api/notes
src/notes/[id]/handler.ts/api/notes/:id with req.params.id
src/(app)/tokens/handler.ts/api/tokens
src/files/[...]/handler.ts/api/files/* with req.params['*']
src/notes/[id]/handler.ts
import { Missing, type Request, type Response } from 'ajo-kit'
import { emit, send } from 'ajo-kit/server'
import { db } from '../../database'

export default {
  async delete(req: Request, res: Response) {
    const id = Number(req.params.id)
    const deleted = await db()
      .deleteFrom('notes')
      .where('id', '=', id)
      .returning('id')
      .executeTakeFirst()

    if (!deleted) throw new Missing('Note not found')

    emit('notes')
    send(res, 200, { deleted: id })
  },
}

A few rules keep these handlers predictable:

  • The supported keys are get, post, put, patch, delete, options and head. A get handler also answers HEAD requests; the body is dropped.
  • A method handler owns its response. Kit ignores the return value, so every path must write a response, normally with send(), or throw.
  • The named head() export of a handler is the document head loader. The HTTP HEAD method is the head key of the default export. They do not interact.
  • One handler.ts can hold loaders, actions and a default export side by side.

API writes broadcast with the server-level emit(), which revalidates live pages that track the topic but never adds anything to a response. Live updates explains the difference from action.emit().

Writing a response

send(res, code = 200, data = '', headers = {}) from ajo-kit/server applies the headers you pass, serializes data, sets Content-Length and ends the response.

dataBodyDefault Content-Type
Object or arrayJSON.stringify(data)application/json; charset=utf-8
Uint8ArrayThe bytes as givenapplication/octet-stream
Any other truthy valueString(data)text/plain
EmptyThe status text, such as “Not Found”, or the code itselftext/plain

A Content-Type that is already set, or passed in headers, wins over the default:

TypeScript
send(res, 200, csv, { 'Content-Type': 'text/csv; charset=utf-8' })

The request

Handlers receive the same host-neutral request under kit dev and on the engine. The fields you will use most:

FieldContents
methodUpper-case HTTP method.
pathThe path without the query string.
originalUrlThe request target, including the query string.
queryQuery parameters. A repeated key becomes an array of strings.
paramsValues of dynamic segments.
headersRequest headers with lower-case names.
bodyIn actions and API handlers, the parsed JSON body, or {} when there is none.
user, session, tokenSet by authentication middleware such as ajo-kit-auth.

API handlers and page actions parse the body as JSON when its Content-Type is JSON or absent. Other content types are not parsed and leave req.body empty. The limit is 100 KiB: a larger body fails with 413 “Content Too Large”, and malformed JSON with 422 “Invalid content”. Page GET requests never parse a body.

Request helpers

These helpers come from ajo-kit and work in loaders, actions, middleware and API handlers.

ExportBehavior
ajax(req)True when the Accept header includes application/json.
api(req)True when the path starts with /api/.
ip(req)The client address from the connection. With TRUST_PROXY=1 or true, the first valid X-Forwarded-For entry instead. Loopback becomes localhost; an unavailable address is unknown.
origin(req)The canonical application origin: APP_URL’s origin when set. Without it, development uses the Host header, and production fails closed for non-local hosts. X-Forwarded-Proto counts only with TRUST_PROXY. Use it for links that leave the request, such as email.
requestOrigin(req)The origin of this exact request, after checking its Host against the canonical origin or the host’s managed origin list. A host outside that list fails with 421. Use it when a form or redirect must stay on the address the visitor used.
localeThe fixed server rendering locale, 'en-US'.
date(iso, options?)Formats an ISO timestamp in that locale, by default as Sep 25, 2026.
normalize(error)Turns any thrown value into a Failure, keeping a status or statusCode from 400 to 599 and using 500 otherwise.

Adding origins does not redirect requests or share cookies, sessions or passkeys between them. Managed origins are covered in Build and configuration.

Errors

Throw an HTTP error from any handler, loader, action or middleware. Kit catches it and answers with its status.

ClassStatusDefault message
new Failure(status, message)Any
new Missing(message?)404Page not found
new Forbidden(message?)403Access denied
new Denied(message?)401Authentication required
new Invalid(fields, message?)400Validation failed, with per-field messages. parse() from ajo-kit/validate throws it.

Under /api/ an error becomes a JSON body such as { "message": "Note not found", "status": 404 }, plus fields for Invalid. Page requests render the error route instead, or return { "error": … } when the client asked for route data. An unmatched /api/ path answers 404 in the same JSON shape.

Middleware

A wares.ts file exports one middleware or an array of them. Kit collects the files of every ancestor directory, outermost first, and runs them in order. This is where authentication and subtree-wide checks belong, because page loads, actions and API handlers under that directory all pass through the same chain.

src/wares.ts
import type { Middleware } from 'ajo-kit'
import { configure, wares } from 'ajo-kit-auth'
import { db } from './database'

configure(() => db())

export default [wares.session(), wares.csrf] satisfies Middleware[]
src/notes/wares.ts
import { Denied, type Middleware } from 'ajo-kit'

const signedIn: Middleware = (req, _res, next) => {
  if (!req.user) throw new Denied()
  next()
}

export default signedIn
  • A middleware receives (req, res, next) and may be async. Call next() to continue. Throwing or calling next(error) hands the error to Kit; ending the response stops the chain.
  • Route groups count as directories: src/(app)/wares.ts covers everything inside (app) without the group appearing in any URL.
  • On a page load, middleware runs before the loaders and before a cached route can be confirmed. On actions and API calls it runs after the JSON body is parsed.
  • Live connections run the route’s middleware again before every update, so a revoked session stops receiving data. See the live stream.

The authentication package provides ready-made guards such as protect() and auth(); prefer them to hand-written checks. Authentication describes them, including bearer tokens for /api/* and CSRF protection for cookie requests.

The bootstrap hook

The root src/wares.ts may also export bootstrap, a function the engine awaits once at startup. Use it for idempotent setup that needs the migrated database.

src/wares.ts
import type { Bootstrap, Middleware } from 'ajo-kit'
import type { Database } from './database'

export const bootstrap: Bootstrap<Database> = async ({ db }) => {
  const existing = await db.selectFrom('notes').select('id').limit(1).executeTakeFirst()
  if (!existing) await db.insertInto('notes').values({ title: 'Welcome', body: 'Your first note.' }).execute()
}

export default [] satisfies Middleware[]
TypeScript
type Bootstrap<Database = any> = (context: {
  db: Kysely<Database>
  config: Readonly<{ database: string; host: string; port: number }>
}) => Promise<void>

When it runs

  • The engine validates its environment, runs the artifact’s compiled migrations, awaits bootstrap, then creates the request handler and opens the listener.
  • config carries the configured DATABASE_PATH (default ./database.sqlite), HOST (default 0.0.0.0) and PORT (default 8080).
  • It runs on every start, so it must be safe to repeat. A rejection is fatal: the engine closes the database and does not start.
  • It is an engine-only hook. kit dev does not call it; use migrations and seeds for local data.
  • Exporting bootstrap marks the build as needing a data root, like importing the database module does.

Source of truth: ajo-kit README