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.
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.
| File | Endpoint |
|---|---|
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['*'] |
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,optionsandhead. Agethandler also answersHEADrequests; 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 HTTPHEADmethod is theheadkey of the default export. They do not interact. - One
handler.tscan 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.
| data | Body | Default Content-Type |
|---|---|---|
| Object or array | JSON.stringify(data) | application/json; charset=utf-8 |
Uint8Array | The bytes as given | application/octet-stream |
| Any other truthy value | String(data) | text/plain |
| Empty | The status text, such as “Not Found”, or the code itself | text/plain |
A Content-Type that is already set, or passed in headers, wins over the default:
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:
| Field | Contents |
|---|---|
method | Upper-case HTTP method. |
path | The path without the query string. |
originalUrl | The request target, including the query string. |
query | Query parameters. A repeated key becomes an array of strings. |
params | Values of dynamic segments. |
headers | Request headers with lower-case names. |
body | In actions and API handlers, the parsed JSON body, or {} when there is none. |
user, session, token | Set 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.
| Export | Behavior |
|---|---|
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. |
locale | The 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.
| Class | Status | Default message |
|---|---|---|
new Failure(status, message) | Any | |
new Missing(message?) | 404 | Page not found |
new Forbidden(message?) | 403 | Access denied |
new Denied(message?) | 401 | Authentication required |
new Invalid(fields, message?) | 400 | Validation 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.
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[]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. Callnext()to continue. Throwing or callingnext(error)hands the error to Kit; ending the response stops the chain. - Route groups count as directories:
src/(app)/wares.tscovers 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.
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[]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. configcarries the configuredDATABASE_PATH(default./database.sqlite),HOST(default0.0.0.0) andPORT(default8080).- 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 devdoes not call it; use migrations and seeds for local data. - Exporting
bootstrapmarks the build as needing a data root, like importing the database module does.
- ActionsHandle browser writes with route actions and validation.
- Live updatesTrack topics and emit them after writes.
- DatabaseQueries, migrations and data on disk.
- AuthenticationSessions, tokens, CSRF and guards.
Source of truth: ajo-kit README