Hono API Stack
Generate code for a Hono API that runs on Bun and uses @hono/zod-openapi for route-level schema definitions with automatic OpenAPI spec generation.
Defaults and Deviations
These are the team defaults. Multiple people work on these prototypes, so consistency matters. In execution mode, follow these defaults unless the project's CLAUDE.md explicitly overrides them.
In Plan mode, suggest alternatives using the deviation protocol from the
prototyping-skills:team-conventions skill: state the default, name the alternative,
explain the trade-off, flag the blast radius, let the human decide.
Team Defaults — Follow Unless Explicitly Overridden
- Framework: Hono. Not Express, Fastify, Koa, or any other HTTP framework.
- Validation + OpenAPI:
@hono/zod-openapiwithcreateRoute()+app.openapi(). Nothono/validatordirectly, not rawapp.get(). - URL paths: Follow JSON:API spec (
https://jsonapi.org/format/). Resource-centric, plural nouns, relationships as nested paths. - Runtime: Bun-native APIs. Not Node.js
http,fs, etc. UseBun.envnotprocess.env. - Middleware style: Hono context (
c), not Express-style(req, res, next). - Types: From
@repo/types. Never duplicate type definitions in the API package. - Business logic: In
@repo/core. API handlers are thin wrappers. - Linting + formatting: Biome. No ESLint or Prettier.
- Database:
bun:sqlitewhen persistence is needed. Not better-sqlite3, not Prisma, not Drizzle (unless deviation approved).
Package Setup
packages/api/
├── src/
│ ├── index.ts # App entry point, mounts route groups
│ ├── routes/ # One file per resource/domain
│ │ ├── health.ts
│ │ └── [resource].ts
│ ├── middleware/ # Custom Hono middleware
│ └── lib/ # Shared utilities (error formatting, etc.)
├── package.json
└── tsconfig.json
package.json must include:
{
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts"
},
"dependencies": {
"hono": "^4",
"@hono/zod-openapi": "^0.18",
"zod": "^3",
"@repo/types": "workspace:*",
"@repo/core": "workspace:*"
}
}
JSON:API Path Conventions
Follow the JSON:API specification for URL design:
| Pattern | Example | Purpose |
|---|---|---|
/{resource} |
/articles |
Collection (plural nouns) |
/{resource}/{id} |
/articles/1 |
Individual resource |
/{resource}/{id}/{related} |
/articles/1/comments |
Related resources |
/{resource}/{id}/relationships/{relation} |
/articles/1/relationships/author |
Relationship linkage |
Rules:
- Resource names are plural, kebab-case:
/articles,/blog-posts(not/article,/blogPosts). - No verbs in paths: use HTTP methods (
POST /articles, notPOST /articles/create). - Nested resources for relationships, not deep nesting:
/articles/1/comments(not/authors/1/articles/2/comments).
Route Definition Pattern
Every route MUST use createRoute + app.openapi(). This is non-negotiable.
import { createRoute, z } from "@hono/zod-openapi";
import { OpenAPIHono } from "@hono/zod-openapi";
const app = new OpenAPIHono();
const getItemRoute = createRoute({
method: "get",
path: "/items/{id}",
request: {
params: z.object({
id: z.string().openapi({ description: "Item ID", example: "abc-123" }),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({ id: z.string(), name: z.string() }),
},
},
description: "Item found",
},
404: {
content: {
"application/json": {
schema: z.object({ error: z.string() }),
},
},
description: "Item not found",
},
},
tags: ["Items"],
});
app.openapi(getItemRoute, async (c) => {
const { id } = c.req.valid("param");
// Call into @repo/core for business logic
return c.json({ id, name: "Example" }, 200);
});
POST / PUT with request body:
const createItemRoute = createRoute({
method: "post",
path: "/items",
request: {
body: {
content: {
"application/json": {
schema: z.object({
name: z.string().min(1),
description: z.string().optional(),
}),
},
},
required: true,
},
},
responses: {
201: {
content: {
"application/json": {
schema: z.object({ id: z.string(), name: z.string() }),
},
},
description: "Created",
},
},
tags: ["Items"],
});
app.openapi(createItemRoute, async (c) => {
const body = c.req.valid("json");
return c.json({ id: "new-id", name: body.name }, 201);
});
Entry Point Pattern
// src/index.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
import { cors } from "hono/cors";
import itemRoutes from "./routes/items";
import healthRoutes from "./routes/health";
const app = new OpenAPIHono();
app.use("/*", cors());
app.route("/api", itemRoutes);
app.route("/", healthRoutes);
app.doc("/doc", {
openapi: "3.1.0",
info: { title: "API", version: "0.1.0" },
});
app.get("/swagger", swaggerUI({ url: "/doc" }));
export default {
port: Bun.env.PORT ?? 3001,
fetch: app.fetch,
};
Error Handling Pattern
import { HTTPException } from "hono/http-exception";
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
console.error(err);
return c.json({ error: "Internal server error" }, 500);
});
Key Conventions
- Each route file exports an
OpenAPIHonoinstance, mounted inindex.tsviaapp.route(). - Business logic lives in
@repo/core, not in route handlers. Handlers: validate, call core, format response. - Zod schemas for request/response go at the top of route files or in a
schemas.tsif shared. - Reusable Zod schemas that appear in multiple routes belong in
@repo/types(add.openapi()refinements in the API package). - Environment variables via
Bun.env.VARIABLE_NAME, neverprocess.env.