
Automatic OpenAPI Documentation with Chanfana and Hono
Stop maintaining API docs manually. Learn how Chanfana generates production-ready OpenAPI specs from your Hono routes with validation schemas, security, and Swagger UI.
Automatic OpenAPI Documentation with Chanfana and Hono
You just shipped a new API endpoint. Your backend code works perfectly. The frontend team asks for the documentation. You update the Swagger spec manually, paste in the new request schema, add response examples, update the security section... and immediately notice the request body in your docs doesn't match what you actually deployed.
This happens constantly. API documentation gets out of sync with code because they're maintained separately. Chanfana eliminates this problem by generating OpenAPI documentation directly from your route definitions and validation schemas.
This guide covers implementing production-ready OpenAPI documentation with Chanfana and Hono, including automatic schema generation, security integration, environment-specific configuration, and generating TypeScript types for API clients.
The Documentation Problem
Traditional API documentation workflows suffer from fundamental issues:
Manual maintenance — Every endpoint change requires updating both code and documentation. Teams that maintain them separately eventually diverge, making the docs worse than useless — they're actively misleading.
Schema duplication — You define request validation in your route handler. Then you define the exact same schema again in your OpenAPI spec. One gets updated, the other doesn't.
No compile-time safety — Your OpenAPI spec is a YAML or JSON file disconnected from your TypeScript types. There's no way to know if they match until runtime.
Testing complexity — Without auto-generated docs that reflect actual request/response schemas, you can't auto-generate API clients or use tools like Postman collections reliably.
Chanfana solves these by making your route definitions the single source of truth for both behavior and documentation.
What is Chanfana?
Chanfana is a lightweight OpenAPI framework built specifically for Hono. It wraps Hono's routing layer and automatically generates OpenAPI 3.1 specifications from your endpoint definitions.
Unlike traditional documentation tools that require separate spec files, Chanfana uses TypeScript validation schemas (via Zod) as the source for both runtime validation and documentation generation. Define your schema once, get validation and docs automatically.
Originally built for Cloudflare Workers, Chanfana works anywhere Hono runs — Node.js, Deno, Bun, Cloudflare Workers, or serverless platforms.
Why Chanfana Works Well with Hono
Hono is designed for lightweight, high-performance APIs. It's framework-agnostic runtime (works on Node, Bun, Cloudflare Workers, Deno) makes it ideal for modern TypeScript APIs.
Chanfana extends Hono without fighting its design:
- Minimal overhead — Adds OpenAPI generation without impacting request performance
- Type-safe — Leverages Hono's type inference for compile-time safety
- Schema-driven — Uses Zod schemas for both validation and documentation
- Plugin architecture — Integrates as a Hono plugin, not a framework replacement
If you're already using Hono, adding Chanfana is a natural extension rather than an architectural change.
Setting Up Chanfana with Hono
Installation
npm install hono chanfana zod
Three dependencies:
- hono — The API framework
- chanfana — OpenAPI generation layer
- zod — Schema validation (required by Chanfana)
Basic Setup
// src/index.ts
import { Hono } from 'hono';
import { fromHono } from 'chanfana';
const app = new Hono();
// Wrap Hono app with Chanfana
const openapi = fromHono(app, {
base: '/v1/api',
docs_url: '/docs',
openapiVersion: '3.1',
schema: {
info: {
title: 'E-Commerce API',
version: '1.0.0',
description: 'Production e-commerce API with inventory, orders, and payments',
},
},
});
export default openapi;
This creates an OpenAPI-enabled Hono app. The docs_url option automatically serves interactive Swagger UI at /docs.
Key Configuration Options
base — Prefix for all OpenAPI-documented routes. Routes defined with openapi.get() automatically include this prefix.
docs_url — Path where Swagger UI is served. Setting this to null disables automatic UI serving.
openapiVersion — OpenAPI spec version. Use '3.1' for the latest features like better JSON Schema support.
schema.info — API metadata displayed in Swagger UI. Include title, version, and description at minimum.
Defining Endpoints with Validation Schemas
The core pattern: define request/response schemas with Zod, use them for route validation, and Chanfana automatically generates OpenAPI docs.
Simple GET Endpoint
// src/routes/health.ts
import { z } from 'zod';
import { OpenAPIRoute } from 'chanfana';
import { Context } from 'hono';
const HealthResponseSchema = z.object({
status: z.string().openapi({
description: 'Service health status',
example: 'healthy',
}),
timestamp: z.string().openapi({
description: 'ISO 8601 timestamp',
example: '2026-01-03T12:00:00Z',
}),
version: z.string().openapi({
description: 'API version',
example: '1.0.0',
}),
});
export class HealthRoute extends OpenAPIRoute {
schema = {
summary: 'Health check endpoint',
description: 'Returns API health status and version information',
responses: {
200: {
description: 'Service is healthy',
content: {
'application/json': {
schema: HealthResponseSchema,
},
},
},
},
};
async handle(c: Context) {
return c.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '1.0.0',
});
}
}
// Register route
openapi.get('/health', HealthRoute);
POST Endpoint with Request Validation
// src/routes/orders.ts
import { z } from 'zod';
import { OpenAPIRoute } from 'chanfana';
import { Context } from 'hono';
const CreateOrderRequestSchema = z.object({
customerId: z.string().uuid().openapi({
description: 'Customer UUID',
example: '550e8400-e29b-41d4-a716-446655440000',
}),
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
price: z.number().positive(),
}),
)
.min(1)
.openapi({
description: 'Order line items (at least one required)',
}),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().default('US'),
}),
});
const CreateOrderResponseSchema = z.object({
orderId: z.string().uuid(),
status: z.enum(['pending', 'processing', 'completed', 'failed']),
totalAmount: z.number(),
estimatedDelivery: z.string(),
});
export class CreateOrderRoute extends OpenAPIRoute {
schema = {
summary: 'Create new order',
description: 'Creates a new order for the specified customer',
tags: ['Orders'],
request: {
body: {
content: {
'application/json': {
schema: CreateOrderRequestSchema,
},
},
},
},
responses: {
201: {
description: 'Order created successfully',
content: {
'application/json': {
schema: CreateOrderResponseSchema,
},
},
},
// Common error responses - define once in a shared object for real projects
400: { description: 'Invalid request data' },
401: { description: 'Authentication required' },
},
};
async handle(c: Context) {
// Chanfana automatically validates request against schema
const body = await this.getValidatedData<typeof CreateOrderRequestSchema>();
// Process order (simplified)
const orderId = crypto.randomUUID();
const totalAmount = body.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return c.json(
{
orderId,
status: 'pending',
totalAmount,
estimatedDelivery: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
201,
);
}
}
openapi.post('/orders', CreateOrderRoute);
Path Parameters and Query Strings
const GetOrderParamsSchema = z.object({
orderId: z.string().uuid().openapi({
description: 'Order UUID',
example: '123e4567-e89b-12d3-a456-426614174000',
}),
});
// Helper for optional include flags
const optionalInclude = (desc: string) => z.boolean().optional().default(false).openapi({ description: desc });
const GetOrderQuerySchema = z.object({
includeItems: optionalInclude('Include order line items in response'),
includeCustomer: optionalInclude('Include customer details in response'),
});
export class GetOrderRoute extends OpenAPIRoute {
schema = {
summary: 'Get order by ID',
tags: ['Orders'],
request: {
params: GetOrderParamsSchema,
query: GetOrderQuerySchema,
},
responses: {
200: {
description: 'Order details',
content: {
'application/json': {
schema: OrderDetailSchema,
},
},
},
404: {
description: 'Order not found',
},
},
};
async handle(c: Context) {
const { orderId } = await this.getValidatedData<typeof GetOrderParamsSchema>('params');
const query = await this.getValidatedData<typeof GetOrderQuerySchema>('query');
// Fetch order with optional includes
const order = await fetchOrder(orderId, query);
if (!order) {
return c.json({ error: 'Order not found' }, 404);
}
return c.json(order);
}
}
openapi.get('/orders/:orderId', GetOrderRoute);
Using .openapi() for Rich Documentation
Zod's .openapi() method adds OpenAPI-specific metadata without affecting runtime validation:
const ProductSchema = z.object({
name: z.string().min(1).max(100).openapi({
description: 'Product name (1-100 characters)',
example: 'Wireless Bluetooth Headphones',
}),
price: z.number().positive().openapi({
description: 'Price in USD',
example: 79.99,
}),
category: z.enum(['electronics', 'clothing', 'home', 'books']).openapi({
description: 'Product category',
example: 'electronics',
}),
inStock: z.boolean().openapi({
description: 'Availability status',
example: true,
}),
tags: z
.array(z.string())
.optional()
.openapi({
description: 'Optional product tags for search',
example: ['wireless', 'bluetooth', 'audio'],
}),
});
These descriptions and examples appear in the generated Swagger UI, making your API self-documenting.
Security Schema Integration
OpenAPI supports multiple authentication schemes. Chanfana integrates with Hono's authentication middleware.
Bearer Token Authentication
// Define security scheme in OpenAPI config
const openapi = fromHono(app, {
base: '/v1/api',
docs_url: '/docs',
schema: {
info: {
title: 'E-Commerce API',
version: '1.0.0',
},
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'http://localhost:8787', description: 'Local dev' },
],
// Define security schemes
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT bearer token authentication',
},
},
},
// Apply globally
security: [{ bearerAuth: [] }],
},
});
// Apply bearer auth middleware
import { bearerAuth } from 'hono/bearer-auth';
openapi.use(
'/v1/api/*',
bearerAuth({
token: process.env.API_KEY!,
noAuthenticationHeaderMessage: () => ({
success: false,
error: 'Missing Authorization header',
}),
invalidTokenMessage: () => ({
success: false,
error: 'Invalid API key',
}),
}),
);
Routes registered under /v1/api/* automatically require bearer authentication, and Swagger UI includes an "Authorize" button for entering tokens.
API Key Authentication
components: {
securitySchemes: {
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API key for authentication',
},
},
},
security: [{ apiKey: [] }],
// Middleware for API key auth
openapi.use('/v1/api/*', async (c, next) => {
const apiKey = c.req.header('X-API-Key');
if (!apiKey || apiKey !== process.env.API_KEY) {
return c.json({ error: 'Invalid API key' }, 401);
}
await next();
});
Per-Route Security Overrides
Some routes (like health checks or public endpoints) shouldn't require authentication:
export class PublicHealthRoute extends OpenAPIRoute {
schema = {
summary: 'Public health check',
security: [], // Override global security for this route
responses: {
200: {
description: 'Service status',
},
},
};
async handle(c: Context) {
return c.json({ status: 'ok' });
}
}
openapi.get('/health', PublicHealthRoute);
Setting security: [] in the route schema removes authentication requirements for that specific endpoint.
Multiple Security Schemes
Support both API keys and OAuth:
components: {
securitySchemes: {
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
},
oauth2: {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: 'https://auth.example.com/oauth/authorize',
tokenUrl: 'https://auth.example.com/oauth/token',
scopes: {
'read:orders': 'Read order data',
'write:orders': 'Create and modify orders',
},
},
},
},
},
},
security: [
{ apiKey: [] },
{ oauth2: ['read:orders', 'write:orders'] },
],
This configuration allows either authentication method.
Environment-Specific Server Configuration
Different environments (local, staging, production) need different API base URLs. Configure servers based on environment:
const environment = process.env.NODE_ENV;
function getServerConfig(env: string) {
switch (env) {
case 'production':
return { url: 'https://api.example.com', description: 'Production' };
case 'staging':
return { url: 'https://api-staging.example.com', description: 'Staging' };
default:
return { url: 'http://localhost:8787', description: 'Local dev' };
}
}
const openapi = fromHono(app, {
schema: {
info: { title: 'E-Commerce API', version: '1.0.0' },
servers: [getServerConfig(environment)],
},
});
Swagger UI includes a server dropdown allowing users to test against different environments.
Dynamic Server URLs
For multi-tenant applications:
servers: [
{
url: 'https://{tenant}.api.example.com',
description: 'Tenant-specific API',
variables: {
tenant: {
default: 'demo',
description: 'Tenant subdomain',
},
},
},
],
Users can specify their tenant when testing the API in Swagger UI.
Serving Swagger UI
Chanfana automatically serves Swagger UI when you specify docs_url:
const openapi = fromHono(app, {
docs_url: '/docs', // Swagger UI at http://localhost:8787/docs
});
Customizing Swagger UI
Disable automatic UI if you want to serve it separately:
const openapi = fromHono(app, {
docs_url: null, // Disable automatic Swagger UI
});
// Serve raw OpenAPI spec
openapi.get('/openapi.json', async (c) => {
return c.json(openapi.schema);
});
Then use external tools like Swagger UI, Redoc, or Stoplight Elements to render the spec.
Development vs Production
Disable Swagger UI in production for security:
const docsUrl = process.env.NODE_ENV === 'production' ? null : '/docs';
const openapi = fromHono(app, {
docs_url: docsUrl,
});
Alternatively, protect it with authentication:
import { basicAuth } from 'hono/basic-auth';
if (process.env.DOCS_USERNAME && process.env.DOCS_PASSWORD) {
app.use(
'/docs/*',
basicAuth({
username: process.env.DOCS_USERNAME,
password: process.env.DOCS_PASSWORD,
}),
);
}
const openapi = fromHono(app, {
docs_url: '/docs',
});
Integrating with API Clients
Auto-generated OpenAPI specs enable generating type-safe API clients. The openapi-react-query package provides first-class TanStack Query integration.
Setup with TanStack Query
Install the required packages:
npm install openapi-react-query openapi-fetch @tanstack/react-query
npm install -D openapi-typescript
Generate TypeScript types from your running API:
npx openapi-typescript http://localhost:8787/openapi.json -o ./src/types/api.d.ts
Create a type-safe API client:
// src/lib/api.ts
import createFetchClient from 'openapi-fetch';
import createClient from 'openapi-react-query';
import type { paths } from '../types/api';
const fetchClient = createFetchClient<paths>({
baseUrl: 'https://api.example.com',
headers: { Authorization: `Bearer ${API_TOKEN}` },
});
export const $api = createClient(fetchClient);
Type-Safe Queries
Use the generated client in React components:
import { $api } from '../lib/api';
function OrderDetails({ orderId }: { orderId: string }) {
const { data, error, isLoading } = $api.useQuery('get', '/v1/api/orders/{orderId}', {
params: { path: { orderId } },
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
// TypeScript knows exact shape of data
return (
<div>
<h1>Order {data.orderId}</h1>
<p>Status: {data.status}</p>
<p>Total: ${data.totalAmount}</p>
</div>
);
}
Type-Safe Mutations
function CreateOrderForm() {
const mutation = $api.useMutation('post', '/v1/api/orders');
const handleSubmit = (formData: FormData) => {
mutation.mutate({
body: {
// TypeScript enforces the exact schema from your Chanfana routes
customerId: formData.get('customerId') as string,
items: [{ productId: '...', quantity: 1, price: 29.99 }],
shippingAddress: {
street: formData.get('street') as string,
city: formData.get('city') as string,
state: formData.get('state') as string,
zipCode: formData.get('zipCode') as string,
},
},
});
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(new FormData(e.currentTarget)); }}>
{/* Form fields */}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Order'}
</button>
{mutation.isSuccess && <p>Order {mutation.data.orderId} created!</p>}
</form>
);
}
The key benefit: your Zod schemas in Chanfana become the single source of truth for both backend validation and frontend types. Change a schema, regenerate types, and TypeScript catches any mismatches at compile time.
Generating Full SDK with openapi-generator
For more complete client SDKs:
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:8787/openapi.json \
-g typescript-fetch \
-o ./src/sdk
This generates a complete client with type-safe methods:
import { OrdersApi, Configuration } from './sdk';
const api = new OrdersApi(
new Configuration({
basePath: 'https://api.example.com',
apiKey: process.env.API_KEY,
}),
);
const order = await api.createOrder({
customerId: '550e8400-e29b-41d4-a716-446655440000',
items: [{ productId: '...', quantity: 1, price: 29.99 }],
shippingAddress: {
/* ... */
},
});
Postman Collections
Import OpenAPI specs directly into Postman:
- Export spec:
curl http://localhost:8787/openapi.json > api-spec.json - In Postman: Import → Upload Files → Select
api-spec.json - Postman auto-generates collection with all endpoints, examples, and authentication
Production Best Practices
Versioning
Include API version in the base path:
const openapi = fromHono(app, {
base: '/v1/api',
schema: {
info: {
version: '1.0.0',
},
},
});
When releasing breaking changes, create a new version:
const v2Api = fromHono(app, {
base: '/v2/api',
schema: {
info: {
version: '2.0.0',
},
},
});
Schema Reusability
Define common schemas once and reuse:
// src/schemas/common.ts
export const PaginationQuerySchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20),
});
export const ErrorResponseSchema = z.object({
success: z.boolean().default(false),
error: z.string(),
code: z.string().optional(),
});
// src/routes/products.ts
import { PaginationQuerySchema, ErrorResponseSchema } from '../schemas/common';
export class ListProductsRoute extends OpenAPIRoute {
schema = {
request: {
query: PaginationQuerySchema,
},
responses: {
400: {
content: {
'application/json': {
schema: ErrorResponseSchema,
},
},
},
},
};
}
Response Examples
Add examples for better documentation:
responses: {
200: {
description: 'Order created successfully',
content: {
'application/json': {
schema: CreateOrderResponseSchema,
example: {
orderId: '123e4567-e89b-12d3-a456-426614174000',
status: 'pending',
totalAmount: 59.98,
estimatedDelivery: '2026-01-10T12:00:00Z',
},
},
},
},
}
Tags for Organization
Group related endpoints with tags:
export class CreateOrderRoute extends OpenAPIRoute {
schema = {
tags: ['Orders'],
summary: 'Create order',
// ...
};
}
// Additional routes follow the same pattern with their respective tags
export class GetOrderRoute extends OpenAPIRoute {
schema = { tags: ['Orders'], summary: 'Get order' /* ... */ };
}
export class CreateProductRoute extends OpenAPIRoute {
schema = { tags: ['Products'], summary: 'Create product' /* ... */ };
}
Swagger UI groups endpoints by tags, improving navigation.
Deprecation Warnings
Mark deprecated endpoints:
export class LegacyOrderRoute extends OpenAPIRoute {
schema = {
deprecated: true,
summary: 'Create order (deprecated)',
description: 'Use POST /v2/api/orders instead',
// ...
};
}
Swagger UI visually indicates deprecated endpoints.
Key Takeaways
- Single source of truth — Zod schemas drive both validation and documentation, eliminating sync issues
- Type safety — TypeScript types flow from schemas to handlers to generated clients
- Automatic Swagger UI — Chanfana serves interactive documentation without manual configuration
- Security integration — OpenAPI security schemes integrate seamlessly with Hono middleware
- Client generation — Auto-generated specs enable type-safe SDK generation for any language
- Production-ready — Environment-specific servers, versioning, and authentication make Chanfana suitable for production APIs
Chanfana transforms API documentation from a maintenance burden into a zero-effort byproduct of writing well-validated routes. Define schemas, implement handlers, get production-quality docs automatically.
Ready to build with us? Check out our API development services or get in touch to discuss your project.
Further Reading
- Chanfana documentation - Official Chanfana framework docs
- Hono documentation - The lightweight web framework Chanfana extends
- openapi-react-query - Type-safe TanStack Query integration for OpenAPI
- openapi-typescript - Generate TypeScript types from OpenAPI schemas
- Zod documentation - Schema validation library used by Chanfana