Quick start
This page builds a one-endpoint API: GET /api/v1/users/:id, with an OpenAPI document, Swagger UI, a health check and the standard response envelope. It assumes you finished Installation.
1. Write a route
Routes are chanfana OpenAPIRoute classes. The schema documents and validates the request; jsonCrudResponses documents the envelope for 200, 400, 401, 404 and 500.
import {OpenAPIRoute} from 'chanfana';
import type {Context} from 'hono';
import {z} from 'zod';
import {ApiError, jsonCrudResponses} from '@ltv/cwb';
const User = z.object({id: z.string(), name: z.string()});
export class UserGet extends OpenAPIRoute {
schema = {
tags: ['Users'],
request: {params: z.object({id: z.string()})},
responses: jsonCrudResponses('User', User),
};
async handle(c: Context) {
const {params} = await this.getValidatedData<typeof this.schema>();
if (params.id !== '1') throw ApiError.notFound('User');
return c.respond({id: params.id, name: 'Jane'});
}
}
c.respond(data)wrapsdatain the success envelope. It is added to every HonoContextbycreateApp.throw ApiError.notFound('User')becomes a 404 error envelope through the global error handler.
2. Group routes in a module and build the app
import {fromHono} from 'chanfana';
import {createApp, defineModule} from '@ltv/cwb';
import {UserGet} from './modules/users/user-get.route';
const users = defineModule({
version: 1, // mounted under /v1
routes: {
'GET /users/:id': UserGet,
},
});
const app = createApp({
path: '/api',
cors: true,
healthCheck: true,
modules: [users],
// must return the chanfana router
openApi: (hono) => fromHono(hono, {docs_url: '/docs'}),
});
export default app;
The app now serves:
| Route | Source |
|---|---|
GET /api/v1/users/:id | the users module (path + version + route path) |
GET /health | healthCheck: true |
GET /docs | Swagger UI from chanfana |
GET /openapi.json | OpenAPI document from chanfana |
| anything else | JSON 404 NOT_FOUND envelope |
3. Run it
bunx wrangler dev
A successful request
curl -i localhost:8787/api/v1/users/1
HTTP/1.1 200 OK
content-type: application/json
x-request-id: tz4a98xxat96iws9zmbrgj3a
access-control-allow-origin: *
{
"success": true,
"data": {"id": "1", "name": "Jane"},
"meta": {"timestamp": 1757829600000, "requestId": "tz4a98xxat96iws9zmbrgj3a"}
}
X-Request-Id and meta.requestId carry the same id: a valid incoming X-Request-Id, else a well-formed cf-ray, else a generated CUID2.
A thrown ApiError
curl localhost:8787/api/v1/users/2
{
"success": false,
"error": {"id": "k3x9m2p8q1w7e4r6t5y0u2i8", "code": "NOT_FOUND", "message": "User not found"},
"meta": {"timestamp": 1757829600000, "requestId": "p0o9i8u7y6t5r4e3w2q1a2s3"}
}
Status: 404. error.id is generated per error and also appears in the logs, so a client can quote it in a support request.
An unknown route
curl localhost:8787/api/v2/users/1
{
"success": false,
"error": {"id": "c1v2b3n4m5l6k7j8h9g0f1d2", "code": "NOT_FOUND", "message": "Route not found: GET /api/v2/users/1"},
"meta": {"timestamp": 1757829600000, "requestId": "a9s8d7f6g5h4j3k2l1z0x9c8"}
}
The log line
Each request writes one JSON line to the console (Workers Logs picks it up):
{"time":"2026-09-14T07:21:37.186Z","level":"INFO","env":"production","service":"@ltv/cwb","runtime":"cloudflare-workers","requestId":"tz4a98xxat96iws9zmbrgj3a","method":"GET","path":"/api/v1/users/1","status":200,"duration":3,"msg":"GET /api/v1/users/1 200 3ms"}
Set SERVICE_NAME in wrangler vars to replace the default service value. See Logging.
Without a NODE_ENV var, the Worker runs in production mode: unexpected errors return Internal server error and Server-Timing is off. For local debugging, set "NODE_ENV": "development" in vars (or in .dev.vars).
4. Add authentication the right way
Middleware must be registered before the routes. createApp registers module routes before it returns, so app.use() on the returned app runs too late to guard them. Pass middleware through the config instead:
import type {MiddlewareHandler} from 'hono';
// API_KEY is a secret: `wrangler secret put API_KEY` (or `.dev.vars` locally)
const requireApiKey: MiddlewareHandler<{Bindings: {API_KEY: string}}> = async (
c,
next
) => {
if (c.req.header('x-api-key') !== c.env.API_KEY) {
return c.respondUnauthorized();
}
await next();
};
const app = createApp({
path: '/api',
healthCheck: true, // /health is registered before app middleware, so it stays public
middleware: [requireApiKey], // runs before every module route and the docs routes
modules: [users],
openApi: (hono) => fromHono(hono, {docs_url: '/docs'}),
});
Next steps
- App builder: every
createAppoption and the middleware order. - Responses: pagination and the other
c.respond*helpers. - Error handling: what clients see in production.
- Basic Worker example: D1, KV and tenants.