Skip to main content

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.

src/modules/users/user-get.route.ts
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) wraps data in the success envelope. It is added to every Hono Context by createApp.
  • throw ApiError.notFound('User') becomes a 404 error envelope through the global error handler.

2. Group routes in a module and build the app

src/index.ts
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:

RouteSource
GET /api/v1/users/:idthe users module (path + version + route path)
GET /healthhealthCheck: true
GET /docsSwagger UI from chanfana
GET /openapi.jsonOpenAPI document from chanfana
anything elseJSON 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.

Production is the default

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:

src/index.ts
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