API Conventions
Standardized API response format, error handling, and middleware patterns.
Overview
The Hono API server follows consistent conventions for responses, errors, middleware, and route organization. Understanding these patterns is essential for extending the API.
Response Format
All API responses use a standardized JSON envelope:
Success
{
"data": { ... }
}Error
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message"
}
}Error Codes
| Code | Status | When |
|---|---|---|
NOT_FOUND | 404 | Route does not exist |
UNAUTHORIZED | 401 | Missing or invalid session |
FORBIDDEN | 403 | Insufficient permissions (e.g., /headers in production) |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
VALIDATION_ERROR | 400 | Zod schema validation failed |
INTERNAL_SERVER_ERROR | 500 | Unhandled server error |
Validation Errors
When a Zod validation fails, the response includes the full issues array:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"issues": [{ "path": ["name"], "message": "Required" }]
}
}Environment-Aware Errors
- Local/Development: Error messages include the actual error details for debugging
- Production: Generic
"Internal Server Error"message to avoid leaking internals
Middleware Stack
Every request passes through these global middlewares in order:
- CORS — Allows configured trusted origins with credentials
- Logger — Logs request/response to console
- Rate Limiter — Enforces per-key rate limits
CORS Configuration
Configured in api/hono/src/index.ts:
- Origins: From
HONO_TRUSTED_ORIGINS(comma-separated URLs) - Methods: GET, OPTIONS, POST, PUT
- Headers: content-type, authorization
- Credentials: Enabled (for cookies)
- Max Age: 600 seconds
Rate Limiting
Rate limit keys are resolved in priority order:
- User ID — If authenticated (highest priority)
- API Key — Hash of
Authorizationheader - IP Address — Detected via Arcjet IP
- Random UUID — Fallback if IP detection fails
| Context | Limit | Window | Environment Variable |
|---|---|---|---|
| Unauthenticated | 60 req | 60s | HONO_RATE_LIMIT / HONO_RATE_LIMIT_WINDOW_MS |
| Authenticated | 120 req (2x) | 60s | Same variables, doubled automatically |
When a rate limit is exceeded, the API returns:
{
"error": {
"code": "TOO_MANY_REQUESTS",
"message": "Too Many Requests"
}
}Auth Middleware
Applied to protected routes (e.g., /api/v1/*):
- Extracts session from request headers via Better Auth
- Returns
401 UNAUTHORIZEDif no valid session - Sets
sessionandusercontext variables for downstream handlers - Creates a user-specific rate limiter with 2x the global limit
Route Organization
/ → Version and environment info
/headers → Request headers (local/dev only)
/api/health → Health check with OpenAPI docs
/api/openapi.json → OpenAPI specification
/api/docs → Scalar API documentation UI
/api/auth/* → Better Auth handlers (session, OAuth, etc.)
/api/v1/* → Protected routes (requires auth middleware)
/api/v1/session → Current session data
/api/v1/user → Current user dataAdding a New Route
- Create or edit a router in
api/hono/src/routers/:
import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import { z } from "zod"
import { authMiddleware } from "@/middlewares"
export const v1Router = new Hono().use("/*", authMiddleware).get(
"/items",
describeRoute({
tags: ["Items"],
description: "List items",
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: resolver(z.object({ data: z.array(z.object({ id: z.string() })) })),
},
},
},
},
}),
(c) => {
const user = c.get("user")
return c.json({ data: [] })
},
)- Register in
api/hono/src/index.tsif it's a new router:
.route("/api/v1", v1Router)- The route is automatically available in the frontend via
apiClient.v1.items.$get()
OpenAPI Documentation
Routes decorated with describeRoute appear in the Scalar API docs at /api/docs. You can add code samples:
describeRoute({
tags: ["v1"],
description: "Get session",
...({
"x-codeSamples": [{
lang: "typescript",
label: "hono/client",
source: `const res = await apiClient.v1.session.$get()
const { data } = await res.json()`,
}],
} as object),
responses: { ... },
})