Back to Blog
Automatic OpenAPI Documentation with Chanfana and Hono

Automatic OpenAPI Documentation with Chanfana and Hono

January 3, 2026
Stefan Mentović
openapihonochanfanaapi-documentationtypescript

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:

  1. Export spec: curl http://localhost:8787/openapi.json > api-spec.json
  2. In Postman: Import → Upload Files → Select api-spec.json
  3. 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

Enjoyed this article? Stay updated: