Adding API Routes
This guide covers how to add API routes in Cascadia using Hono route modules and the apiHandler() wrapper.
Route Architecture
API routes are defined in src/server/routes/, one file per domain. Each file creates a Hono app, defines routes using adapt() + apiHandler(), and exports the app. The routes are mounted in src/server/index.ts.
| File | Mounted At |
|---|---|
src/server/routes/parts.ts | /api/parts |
src/server/routes/programs.ts | /api/programs |
src/server/routes/designs.ts | /api/designs |
src/server/routes/change-orders.ts | /api/change-orders |
Route parameters use the :param naming convention (e.g., /:id, /:designId/branches).
Basic Route Structure
Every API route file creates a Hono app, uses adapt() to bridge Hono's context to the apiHandler() signature, and wraps handlers with apiHandler():
// src/server/routes/widgets.ts
import { Hono } from 'hono'
import { adapt } from '../adapter'
import { apiHandler } from '@/lib/api/handler'
import { ItemService } from '@/lib/items/services/ItemService'
import { NotFoundError } from '@/lib/errors'
import '@/lib/items/registerItemTypes.server'
const app = new Hono()
// GET /api/widgets/:id
app.get(
'/:id',
adapt(
apiHandler(
{ permission: ['widgets', 'read'] },
async ({ params }) => {
const widget = await ItemService.findById(params.id)
if (!widget) throw new NotFoundError('Widget', params.id)
return { widget }
},
),
),
)
// PUT /api/widgets/:id
app.put(
'/:id',
adapt(
apiHandler(
{ permission: ['widgets', 'update'] },
async ({ params, request, user }) => {
const data = await request.json()
const widget = await ItemService.update(params.id, data, user.id)
return { widget }
},
),
),
)
// DELETE /api/widgets/:id
app.delete(
'/:id',
adapt(
apiHandler(
{ permission: ['widgets', 'delete'] },
async ({ params }) => {
await ItemService.delete(params.id)
return { success: true }
},
),
),
)
export default app
Then mount the route in src/server/index.ts:
import widgets from './routes/widgets'
app.route('/api/widgets', widgets)
The adapt() Bridge
adapt() from src/server/adapter.ts bridges Hono's Context to the apiHandler() signature. It extracts params and request from the Hono context and passes them to the legacy handler:
export function adapt(handler: LegacyHandler) {
return async (c: Context) => {
const params = c.req.param()
const request = c.req.raw
return await handler({ params, request })
}
}
You always wrap apiHandler() calls with adapt() when defining Hono routes.
The apiHandler() Wrapper
apiHandler() from src/lib/api/handler.ts wraps every API handler. It provides:
- Authentication — verifies session or API key, extracts user
- Authorization — checks permissions if specified
- CSRF protection — validates Origin header on state-changing requests
- Error handling — catches all thrown errors, returns proper HTTP responses
- Security headers — X-Content-Type-Options, X-Frame-Options, CORS
- Response serialization — wraps return values in
{ data: ... }envelope
Signature
apiHandler(options: HandlerOptions, handler: HandlerFn)
Auth Options
The first argument controls authentication and authorization:
// Public route — no auth required
app.get('/public', adapt(
apiHandler({ public: true }, async ({ request }) => { ... })
))
// Auth-only — requires valid session, no specific permission
app.get('/protected', adapt(
apiHandler({}, async ({ user }) => { ... })
))
// Permission-required — requires specific permission
app.get('/items', adapt(
apiHandler({ permission: ['parts', 'read'] }, async ({ user }) => { ... })
))
app.post('/items', adapt(
apiHandler({ permission: ['parts', 'create'] }, async ({ user }) => { ... })
))
app.delete('/items/:id', adapt(
apiHandler({ permission: ['parts', 'delete'] }, async ({ user }) => { ... })
))
Handler Context
The handler function receives a context object:
interface HandlerContext {
request: Request // Raw HTTP request
params: TParams // URL parameters (e.g., { id: '...' })
user: SessionUser // Authenticated user (empty for public routes)
requestId: string // Unique request ID for tracing
}
Return Values
Return an object — auto-wrapped as { data: { ... } } with 200 status:
app.get('/:id', adapt(
apiHandler({}, async ({ params }) => {
const widget = await ItemService.findById(params.id)
return { widget }
})
))
// Response: { "data": { "widget": { ... } } }
Return a Response — passed through directly (for custom status codes, streaming, cookies):
import { created } from '@/lib/api/handler'
app.post('/', adapt(
apiHandler(
{ permission: ['parts', 'create'] },
async ({ request, user }) => {
const data = await request.json()
const part = await ItemService.create('Part', data, user.id)
return created({ part })
},
)
))
// Response: 201 Created, { "data": { "part": { ... } } }
Request Parsing
JSON Body
app.post('/', adapt(
apiHandler({}, async ({ request }) => {
const data = await request.json()
// data is unknown — validate with Zod or pass to service
})
))
Query Parameters
Use parseQuery() with a Zod schema for validated, typed query parameters:
import { apiHandler, parseQuery } from '@/lib/api/handler'
import { paginationSchema } from '@/lib/api/schemas'
app.get('/', adapt(
apiHandler({}, async ({ request }) => {
const query = parseQuery(request, paginationSchema)
// query.limit is number (default 50), query.offset is number (default 0)
})
))
Common query schemas from src/lib/api/schemas.ts:
// Pagination
const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
})
// Version context (for querying items at specific versions)
const versionContextSchema = z.object({
designId: z.string().uuid().optional(),
branch: z.string().optional(),
commitId: z.string().uuid().optional(),
tag: z.string().optional(),
})
// Combined item list query
const itemListSchema = paginationSchema.merge(versionContextSchema).extend({
itemType: z.string().optional(),
state: z.string().optional(),
search: z.string().optional(),
})
URL Parameters
URL parameters come from the params object. For a route at /api/parts/:id:
app.get('/:id', adapt(
apiHandler({}, async ({ params }) => {
const { id } = params // string
})
))
Error Handling
Do not use try/catch in routes. Just throw errors — apiHandler catches them:
app.get('/:id', adapt(
apiHandler({}, async ({ params }) => {
const part = await ItemService.findById(params.id)
if (!part) throw new NotFoundError('Part', params.id)
return { part }
})
))
The service layer can also throw errors, and they propagate up:
// In the service — throws ValidationError if branch is locked
static async checkout(data, userId) {
if (branch.isLocked) {
throw new ValidationError('Cannot checkout items on a locked branch')
}
}
// In the route — no try/catch needed
app.post('/checkout', adapt(
apiHandler({}, async ({ request, user }) => {
const data = await request.json()
return await CheckoutService.checkout(data, user.id)
// If service throws, apiHandler converts to proper HTTP error response
})
))
Response Helpers
For responses that need custom status codes, use helpers from src/lib/api/handler.ts:
import { apiHandler, created, jsonResponse } from '@/lib/api/handler'
// 201 Created
return created({ part })
// Custom status code
return jsonResponse({ results }, 207) // Multi-status
Or use response builders from src/lib/api/response.ts for more control:
import {
createCollectionResponse,
createCreatedResponse,
} from '@/lib/api/response'
// Collection with pagination
return createCollectionResponse(
parts,
{ total: 100, limit: 20, offset: 0 },
{
resourceName: 'parts',
},
)
// Created with Location header
return createCreatedResponse(widget, {
resourceName: 'widget',
location: `/api/widgets/${widget.id}`,
})
Access Control Helpers
For routes that need design-level or branch-level access checks beyond simple permissions:
import { requireDesignAccess, requireBranchAccess } from '@/lib/auth/access'
app.get('/designs/:designId/items', adapt(
apiHandler({}, async ({ params, user, request }) => {
await requireDesignAccess(request, params.designId, user)
// ... user has access to this design
})
))
app.post('/branches/:branchId/items', adapt(
apiHandler({}, async ({ params, user, request }) => {
await requireBranchAccess(request, params.branchId, user)
// ... user has access to this branch
})
))
Important Notes
Mounting New Routes
After creating a new route file, you must import and mount it in src/server/index.ts:
import widgets from './routes/widgets'
// ... other route mounts ...
app.route('/api/widgets', widgets)
Item Type Registration
API routes that work with items must import the server-side item type registration:
import '@/lib/items/registerItemTypes.server'
This ensures the ItemTypeRegistry knows about all item types when the route handler runs.
Server-Only Imports
Keep database imports strictly in API routes, services, and server-only files. Importing database modules in client-side code causes build errors:
error: "performance" is not exported by "__vite-browser-external"
Use import type for types, and dynamic imports for server-only services when needed in shared files.
Complete Examples
Collection Endpoint (List + Search)
// src/server/routes/widgets.ts
import { Hono } from 'hono'
import { adapt } from '../adapter'
import { apiHandler, parseQuery, created } from '@/lib/api/handler'
import { itemListSchema } from '@/lib/api/schemas'
import { ItemService } from '@/lib/items/services/ItemService'
import '@/lib/items/registerItemTypes.server'
const app = new Hono()
// GET /api/widgets
app.get(
'/',
adapt(
apiHandler(
{ permission: ['widgets', 'read'] },
async ({ request }) => {
const query = parseQuery(request, itemListSchema)
const result = await ItemService.search({
itemType: 'Widget',
limit: query.limit,
offset: query.offset,
search: query.search,
designId: query.designId,
})
return { widgets: result.items, total: result.total }
},
),
),
)
// POST /api/widgets
app.post(
'/',
adapt(
apiHandler(
{ permission: ['widgets', 'create'] },
async ({ request, user }) => {
const data = await request.json()
const widget = await ItemService.create('Widget', data, user.id)
return created({ widget })
},
),
),
)
export default app
Action Endpoint (Non-CRUD)
// src/server/routes/change-orders.ts (excerpt)
import { Hono } from 'hono'
import { adapt } from '../adapter'
import { apiHandler } from '@/lib/api/handler'
const app = new Hono()
// POST /api/change-orders/:id/workflow/transition
app.post(
'/:id/workflow/transition',
adapt(
apiHandler(
{ permission: ['change_orders', 'update'] },
async ({ params, request, user }) => {
const { targetState } = await request.json()
const result = await ChangeOrderService.transition(
params.id,
targetState,
user.id,
)
return { changeOrder: result }
},
),
),
)
export default app