OpenAPI + Hono: Building Type-Safe REST APIs with Automatic Documentation

Somebody who likes to code
Search for a command to run...

Somebody who likes to code
No comments yet. Be the first to comment.
Today marks the beginning of a series of posts about Node.js and Express, where we will explore various aspects to consider when developing an API. Setting up a robust development environment is crucial for building maintainable Node.js applications....
Google processes tens of thousands of code changes every day across a codebase of hundreds of millions of lines. The secret isn't magic tooling — it's a documented, principled approach to code review

Amazon Simple Notification Service (SNS) looks straightforward on the surface — publish a message, deliver it to subscribers — but the gap between a topic that "works in dev" and one that is productio

Amazon Simple Queue Service (SQS) is deceptively simple to get started with, but surprisingly easy to misconfigure in ways that cause silent message loss, runaway costs, duplicate processing storms, o

HTTP API is the right default choice for the majority of .NET serverless workloads. At 70% less cost than REST API, lower latency, and native JWT authorization, it covers most production requirements

AWS Lambda has become a cornerstone of modern serverless architectures, but writing a function that "just works" is very different from writing one that is performant, cost-effective, secure, and read

On this page
Modern API development requires balancing multiple concerns: runtime validation, compile-time type safety, and comprehensive documentation. Traditionally, these have been separate concerns, leading to documentation drift and type mismatches between specification and implementation.
This article demonstrates how to build a production-ready REST API using Hono with the @hono/zod-openapi library, which bridges the gap between Zod validation schemas and OpenAPI specifications, enabling a single source of truth for types, validation, and documentation.
In traditional API development, you typically maintain three separate artifacts:
TypeScript types for compile-time safety
Runtime validators (like Joi, Yup, Zod, or manual validation)
OpenAPI/Swagger specification for documentation
This creates several problems:
Duplication: Same structure defined three times
Drift: Changes in one place don't automatically update others
Maintenance burden: Keeping all three in sync is error-prone
Source of bugs: Mismatches between types and validation
@hono/zod-openapi solves this by:
Using Zod schemas as the single source of truth
Automatically generating TypeScript types from schemas
Automatically generating OpenAPI specifications from schemas
Providing type-safe route handlers based on schema definitions
Before diving into implementation, let's understand the key components of @hono/zod-openapi.
OpenAPIHono is an extended version of Hono's base class that adds OpenAPI-specific functionality.
import { OpenAPIHono } from '@hono/zod-openapi';
const app = new OpenAPIHono();
What OpenAPIHono Adds:
.openapi() method: Registers routes with OpenAPI configuration
.doc() method: Generates OpenAPI JSON specification endpoint
Built-in validation: Automatically validates requests using Zod schemas
Type inference: Derives TypeScript types from route configurations
Constructor Options:
const app = new OpenAPIHono({
strict: boolean, // URL path matching strictness
defaultHook: Hook, // Global validation error handler
});
strict: When false, allows flexible path matching (e.g., trailing slashes)
defaultHook: Function called when validation fails, customizing error responses
Comparison with Standard Hono:
// Standard Hono - no validation or OpenAPI
import { Hono } from 'hono';
const app = new Hono();
app.post('/todos', async (c) => {
const body = await c.req.json(); // ❌ No validation, any type
// Manual validation required
});
// OpenAPIHono - validation and OpenAPI included
import { OpenAPIHono } from '@hono/zod-openapi';
const app = new OpenAPIHono();
app.openapi(config, async (c) => {
const body = c.req.valid('json'); // ✅ Validated and typed
// Type: { title: string }
});
The createRoute function in @hono/zod-openapi takes a route configuration object and returns an enhanced route object with a path conversion method.
import { createRoute } from '@hono/zod-openapi';
const route = createRoute({
method: 'post',
path: '/todos',
request: { /* ... */ },
responses: { /* ... */ },
});
createRoute accepts one parameter, which includes:
method: HTTP method (e.g., 'get', 'post')
path: OpenAPI path pattern (e.g., '/users/{id}'). Path parameters use curly braces: {paramName}. These must match parameter names in the request schema.
request: Optional request validation schemas containing:
params: Zod schema for path parameters
query: Zod schema for query parameters
headers: Zod schema or array of schemas for request headers
cookies: Zod schema for cookies
body: Request body definition with:
content: Media type to schema mapping
description: Body description
required: Boolean indicating if body is required
responses: Response definitions mapping status codes to response objects with content and descriptions
hide: Optional boolean to hide from documentation
middleware: Optional middleware handlers. Allows us to apply Hono middleware handlers specifically to individual routes, enabling per-route functionality like authentication, logging, caching, or custom request processing
summary: A short summary of what the operation does
description: A verbose explanation of the operation behavior
operationId: Unique string used to identify the operation
tags: A list of tags for API documentation control
security: Declaration of which security schemes can be used
RouteHandler is a generic type that infers request/response types from route configuration. RouteHandler takes four generic parameters:
R extends RouteConfig: The route configuration type
E extends Env = RouteConfigToEnv<R>: Environment type, inferred from route middleware
I extends Input = ...: Combined input types from params, query, headers, cookies, form, and JSON
P extends string = ConvertPathType<R['path']>: Path type converted from OpenAPI to Hono syntax
How Type Inference Works:
const config = createRoute({
method: 'post',
path: '/todos',
request: {
body: {
content: {
'application/json': {
schema: z.object({
title: z.string(),
}),
},
},
},
},
responses: {
201: {
content: {
'application/json': {
schema: z.object({
id: z.string(),
title: z.string(),
}),
},
},
description: 'Created',
},
},
});
// Extract config type
type Config = typeof config;
// Handler infers types from Config
const handler: RouteHandler<Config> = async (c) => {
// c.req.valid('json') returns { title: string }
const { title } = c.req.valid('json');
// Return type must match response schema
return c.json(
{
id: '123',
title: title,
},
201
);
};
The Magic: c.req.valid()
The valid() method is the key to type-safe validation:
c.req.valid('json') // Returns validated body (from request.body.schema)
c.req.valid('query') // Returns validated query params (from request.query)
c.req.valid('param') // Returns validated path params (from request.params)
c.req.valid('header') // Returns validated headers (from request.header)
What valid() Does:
Validates the incoming data using the Zod schema
Transforms the data (e.g., z.coerce.number() converts strings)
Returns typed data (TypeScript knows the exact type)
Calls defaultHook if validation fails (never reaches your handler)
Type Safety Example:
const config = createRoute({
request: {
body: {
content: {
'application/json': {
schema: z.object({
title: z.string(),
priority: z.number(),
}),
},
},
},
},
// ...
});
type Config = typeof config;
const handler: RouteHandler<Config> = async (c) => {
const data = c.req.valid('json');
// ✅ TypeScript knows these properties exist
console.log(data.title); // string
console.log(data.priority); // number
// ❌ TypeScript error: Property doesn't exist
console.log(data.description);
};
Hooks intercept validation failures before they reach your handler. The Hook type takes four generic parameters:
T: The validated data type
E extends Env: Environment type
P extends string: Path type
R: Return type
And two parameters:
result: An object containing:
target: The validation target (keyof ValidationTargets)
Either success data ({ success: true, data: T }) or error information ({ success: false, error: ZodError })
c: The Hono context object (same as in handlers)Example Hook Implementation:
import type { Hook } from '@hono/zod-openapi';
import { BAD_REQUEST } from './http-status-codes.js';
export const defaultHook: Hook<any, any, any, any> = (result, c) => {
// Only runs when validation fails
if (!result.success) {
return c.json(
{
success: false,
error: {
issues: result.error.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
},
},
BAD_REQUEST
);
}
// If validation succeeds, return nothing (continue to handler)
};
How Hooks Are Applied:
// Option 1: Per-route hook
new OpenAPIHono().openapi(config, handler, hook);
// Option 2: Global default hook
new OpenAPIHono({ defaultHook }).openapi(config, handler);
When the Hook Runs:
1. Request arrives
2. @hono/zod-openapi validates request against schema
3. ❌ Validation fails
→ Hook is called
→ Returns error response
→ Handler never executes
4. ✅ Validation succeeds
→ Hook is not called
→ Handler executes with validated data
@hono/zod-openapi extends Zod with .openapi() method for OpenAPI-specific metadata.
import { z } from '@hono/zod-openapi';
const schema = z.string().openapi({
example: 'example value',
description: 'Field description',
// ... other OpenAPI properties
});
Why Import from @hono/zod-openapi?
// ❌ Wrong - missing .openapi() extension
import { z } from 'zod';
// ✅ Correct - includes .openapi() extension
import { z } from '@hono/zod-openapi';
The @hono/zod-openapi package re-exports Zod with extensions. Always import from this package when defining schemas for OpenAPI routes. The .openapi() method added to Zod schemas accepts different parameter forms depending on how you want to enhance the schema for OpenAPI documentation.
Metadata Object Form: Takes an object with OpenAPI-specific properties:
example: Example value for the schema
description: Schema description
deprecated: Boolean to mark as deprecated
readOnly: Boolean for read-only properties
writeOnly: Boolean for write-only properties
param: Parameter metadata for path/query parameters
name: Parameter name
in: Parameter location ('path', 'query', 'header', 'cookie')
required
Schema Registration Form: Takes a string to register the schema as a referenced component in the OpenAPI document.
// Without name - inlined in OpenAPI spec
const todoSchema = z.object({
title: z.string(),
});
// With name - creates reusable component
const todoSchema = z.object({
title: z.string(),
}).openapi('Todo');
Generated OpenAPI (without name):
{
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": { "type": "string" }
}
}
}
}
}
}
Generated OpenAPI (with name):
{
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Todo"
}
}
}
},
"components": {
"schemas": {
"Todo": {
"type": "object",
"properties": {
"title": { "type": "string" }
}
}
}
}
}
Benefits of Named Schemas:
Smaller OpenAPI spec (no duplication)
Better generated client SDKs
Easier to reference in documentation
Now that we understand the @hono/zod-openapi fundamentals, let's build a complete TODO API. The project initial setup was explaned in the Hono: Setting up the development environment article.
Install dependencies:
npm install @hono/zod-openapi uuid @scalar/hono-api-reference http-problem-details
Dependency Breakdown:
@hono/zod-openapi: The star of the show - provides OpenAPI integration
@scalar/hono-api-reference: Interactive API documentation UI
http-problem-details: RFC 7807 error response formatting
uuid: For generating UUIDv7 identifiers
// src/features/todos/todo.ts
import { z } from '@hono/zod-openapi';
export const todoSchema = z
.object({
todoId: z.uuidv7().openapi({
example: '019af0ad-4ac8-7052-a609-24a539d353cd',
}),
title: z.string().min(1).openapi({
example: 'Buy groceries',
}),
completed: z.boolean().default(false).openapi({
example: false,
}),
})
.openapi('Todo');
export type Todo = z.infer<typeof todoSchema>;
export const todos: Todo[] = [];
export const tags = ['Tasks'];
Schema Composition:
todoSchema serves as the base. We'll derive other schemas from it:
// Create schema - omit server-managed fields
const createSchema = todoSchema.omit({
todoId: true,
completed: true
});
// Update schema - all fields optional except ID
const updateSchema = todoSchema.partial().omit({
todoId: true
});
// Query schema - for filtering
const querySchema = todoSchema.pick({
completed: true
});
// src/schemas/problemDocument.ts
import { z } from '@hono/zod-openapi';
const errorSchema = z.object({
path: z.string().openapi({
example: 'title',
description: 'The path to the field that failed validation',
}),
message: z.string().openapi({
example: 'Too small: expected string to have >=1 characters',
description: 'The validation error message',
}),
code: z.string().openapi({
example: 'too_small',
description: 'The validation error code',
}),
});
export const problemDocumentSchema = z
.object({
type: z.string().optional().openapi({
example: '/problems/resource-not-found',
description: 'A URI reference that identifies the problem type',
}),
title: z.string().openapi({
example: 'Resource not found',
description: 'A short, human-readable summary of the problem type',
}),
status: z.number().openapi({
example: 404,
description: 'The HTTP status code',
}),
detail: z.string().optional().openapi({
example: 'The requested todo was not found',
description: 'A human-readable explanation specific to this occurrence',
}),
instance: z.string().optional().openapi({
example: '/todos/019af0ad-4ac8-7052-a609-24a539d353cd',
description: 'A URI reference that identifies the specific occurrence',
}),
errors: z.array(errorSchema).optional().openapi({
description: 'Array of validation errors',
}),
})
.openapi('ProblemDocument');
RFC 7807 Problem Details:
This standardized error format provides:
Machine-readable error types
Human-readable messages
Traceable request instances
Extensible with custom fields
// src/hooks.ts
import type { Hook } from '@hono/zod-openapi';
import { BAD_REQUEST } from '@/http-status-codes.js';
import { ProblemDocument } from 'http-problem-details';
export const defaultHook: Hook<any, any, any, any> = (result, c) => {
if (!result.success) {
return c.json(
new ProblemDocument(
{
type: '/problems/validation-error',
title: 'Validation Error',
status: BAD_REQUEST,
detail: 'The request contains invalid data',
instance: c.req.path,
},
{
errors: result.error.issues.map(err => ({
path: err.path.join('.'),
message: err.message,
code: err.code,
})),
}
),
BAD_REQUEST
);
}
};
Hook Execution Flow:
Client Request
↓
@hono/zod-openapi validates against schema
↓
❌ Validation Fails
↓
defaultHook is called with ZodError
↓
Hook transforms error to Problem Document
↓
Returns 400 response
↓
Handler NEVER executes
Zod Error Structure:
result.error.issues = [
{
code: 'too_small',
minimum: 1,
type: 'string',
inclusive: true,
exact: false,
message: 'Too small: expected string to have >=1 characters',
path: ['title'],
}
]
// src/features/todos/addTodo.ts
import { createRoute, OpenAPIHono, type RouteHandler } from '@hono/zod-openapi';
import { v7 as uuidv7 } from 'uuid';
import { todoSchema, todos, tags } from './todo.js';
import { CREATED, BAD_REQUEST } from '@/http-status-codes.js';
import { defaultHook } from '@/hooks.js';
import { problemDocumentSchema } from '@/schemas/problemDocument.js';
// Derive create schema from base schema
const addTodoSchema = todoSchema
.omit({ todoId: true, completed: true })
.openapi('CreateTodo');
// Define route configuration
const config = createRoute({
method: 'post',
path: '/todos',
tags: tags,
request: {
body: {
content: {
'application/json': {
schema: addTodoSchema,
},
},
description: 'Todo to create',
required: true,
},
},
responses: {
[CREATED]: {
content: {
'application/json': {
schema: todoSchema,
},
},
description: 'Create a new todo',
},
[BAD_REQUEST]: {
content: {
'application/json': {
schema: problemDocumentSchema,
},
},
description: 'Invalid request data',
},
},
});
// Extract route type
type Config = typeof config;
// Type-safe handler
const handler: RouteHandler<Config> = async c => {
// c.req.valid('json') returns { title: string }
const { title } = c.req.valid('json');
const todo = {
todoId: uuidv7(),
title,
completed: false,
};
todos.push(todo);
return c.json(todo, CREATED);
};
// Export as OpenAPIHono instance
export const addRoute = new OpenAPIHono({
strict: false,
defaultHook,
}).openapi(config, handler);
Breaking Down the Implementation:
Schema Derivation:
const addTodoSchema = todoSchema.omit({
todoId: true, // Server generates
completed: true // Server sets default
});
Clients provide only title. Server manages todoId and completed.
Route Configuration:
const config = createRoute({
method: 'post',
path: '/todos',
// ...
});
This generates the OpenAPI specification for POST /todos.
Type Extraction:
type Config = typeof config;
Captures the full type of the configuration, used by RouteHandler.
Type-Safe Handler:
const handler: RouteHandler<Config> = async c => {
const { title } = c.req.valid('json');
// TypeScript knows: { title: string }
};
RouteHandler<Config> infers parameter types from config.
Validation Flow:
Request: POST /todos
Body: { "title": "" }
↓
@hono/zod-openapi validates against addTodoSchema
↓
z.string().min(1) fails (empty string)
↓
defaultHook called
↓
Returns: 400 Bad Request with Problem Document
Route Export:
export const addRoute = new OpenAPIHono({
strict: false,
defaultHook,
}).openapi(config, handler);
Creates a standalone Hono instance for this route, enabling modular composition.
// src/schemas/pagination.ts
import { z } from '@hono/zod-openapi';
const DEFAULT_PAGE_NUMBER = 1;
const DEFAULT_PAGE_SIZE = 10;
const MAX_PAGE_SIZE = 100;
export const paginationParametersSchema = z.object({
pageNumber: z.coerce.number().min(1).optional().default(DEFAULT_PAGE_NUMBER),
pageSize: z.coerce
.number()
.min(1)
.max(MAX_PAGE_SIZE)
.optional()
.default(DEFAULT_PAGE_SIZE),
});
export const createPageSchema = <T>(itemSchema: z.ZodSchema<T>) =>
z.object({
items: z.array(itemSchema),
pageNumber: z.number().min(1),
pageSize: z.number().min(1).max(MAX_PAGE_SIZE),
totalPages: z.number().min(0),
totalCount: z.number().min(0),
});
Generic Schema Factory:
createPageSchema<T>(itemSchema: z.ZodSchema<T>)
Creates a paginated response schema for any item type:
createPageSchema(todoSchema) → Page<Todo>
createPageSchema(userSchema) → Page<User>
Query Parameter Coercion:
pageNumber: z.coerce.number()
Query parameters arrive as strings:
GET /todos?pageNumber=2&pageSize=20
↓
{ pageNumber: "2", pageSize: "20" } // Strings
↓
z.coerce.number() converts
↓
{ pageNumber: 2, pageSize: 20 } // Numbers
// src/features/todos/listTodos.ts
import { createRoute, OpenAPIHono, type RouteHandler } from '@hono/zod-openapi';
import { todoSchema, todos, tags } from './todo.js';
import {
paginationParametersSchema,
createPageSchema,
} from '@/schemas/pagination.js';
import { OK } from '@/http-status-codes.js';
import { defaultHook } from '@/hooks.js';
const config = createRoute({
path: '/todos',
method: 'get',
tags: tags,
request: {
query: paginationParametersSchema,
},
responses: {
[OK]: {
content: {
'application/json': {
schema: createPageSchema(todoSchema),
},
},
description: 'List all todos',
},
},
});
type Config = typeof config;
const handler: RouteHandler<Config> = async c => {
// c.req.valid('query') returns { pageNumber: number, pageSize: number }
const { pageNumber, pageSize } = c.req.valid('query');
const startIndex = (pageNumber - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedTodos = todos.slice(startIndex, endIndex);
return c.json(
{
items: paginatedTodos,
pageNumber,
pageSize,
totalPages: Math.ceil(todos.length / pageSize),
totalCount: todos.length,
},
OK
);
};
export const listRoute = new OpenAPIHono({
strict: false,
defaultHook,
}).openapi(config, handler);
Query Parameter Validation:
request: {
query: paginationParametersSchema,
}
This validates query parameters:
pageNumber: Must be a number ≥ 1 (defaults to 1)
pageSize: Must be between 1 and 100 (defaults to 10)
Type-Safe Query Access:
const { pageNumber, pageSize } = c.req.valid('query');
// TypeScript knows both are numbers, not string | undefined
// src/features/todos/findTodo.ts
import {
createRoute,
OpenAPIHono,
z,
type RouteHandler,
} from '@hono/zod-openapi';
import { todoSchema, todos, tags } from './todo.js';
import { ProblemDocument } from 'http-problem-details';
import { problemDocumentSchema } from '@/schemas/problemDocument.js';
import { OK, NOT_FOUND } from '@/http-status-codes.js';
import { defaultHook } from '@/hooks.js';
const config = createRoute({
path: '/todos/{todoId}',
method: 'get',
tags: tags,
request: {
params: z.object({
todoId: z.uuidv7().openapi({
param: {
name: 'todoId',
in: 'path',
required: true,
},
example: '019af0ad-4ac8-7052-a609-24a539d353cd',
}),
}),
},
responses: {
[OK]: {
content: {
'application/json': {
schema: todoSchema,
},
},
description: 'Get a todo by ID',
},
[NOT_FOUND]: {
content: {
'application/json': {
schema: problemDocumentSchema,
},
},
description: 'Todo not found',
},
},
});
type Config = typeof config;
const handler: RouteHandler<Config> = async c => {
// c.req.valid('param') returns { todoId: string }
const { todoId } = c.req.valid('param');
const todo = todos.find(t => t.todoId === todoId);
if (!todo) {
return c.json(
new ProblemDocument({
type: '/problems/resource-not-found',
title: 'Resource not found',
status: NOT_FOUND,
detail: `Todo with id ${todoId} not found`,
instance: c.req.path,
}),
NOT_FOUND
);
}
return c.json(todo, OK);
};
export const findRoute = new OpenAPIHono({
strict: false,
defaultHook,
}).openapi(config, handler);
Path Parameter Configuration:
params: z.object({
todoId: z.uuidv7().openapi({
param: {
name: 'todoId', // Must match {todoId} in path
in: 'path', // Parameter location
required: true, // Path params always required
},
}),
})
Critical: Name Must Match Path Placeholder:
path: '/todos/{todoId}',
params: z.object({
todoId: z.uuidv7(), // ✅ Matches {todoId}
}),
path: '/todos/{id}',
params: z.object({
todoId: z.uuidv7(), // ❌ Doesn't match {id}
}),
Validation Flow:
Request: GET /todos/invalid-uuid
↓
@hono/zod-openapi validates 'invalid-uuid'
↓
z.uuidv7() fails
↓
defaultHook called
↓
Returns: 400 Bad Request
Request: GET /todos/019af0ad-4ac8-7052-a609-24a539d353cd
↓
Validation succeeds
↓
Handler executes
↓
Todo not in array
↓
Returns: 404 Not Found with Problem Document
// src/features/todos/checkTodo.ts
import { createRoute, OpenAPIHono, type RouteHandler } from '@hono/zod-openapi';
import { todoSchema, todos, tags } from './todo.js';
import { z } from '@hono/zod-openapi';
import { ProblemDocument } from 'http-problem-details';
import { problemDocumentSchema } from '@/schemas/problemDocument.js';
import { OK, NOT_FOUND } from '@/http-status-codes.js';
import { defaultHook } from '@/hooks.js';
const config = createRoute({
path: '/todos/{todoId}/check',
method: 'put',
tags: tags,
request: {
params: z.object({
todoId: z.uuidv7().openapi({
param: {
name: 'todoId',
in: 'path',
required: true,
},
example: '019af0ad-4ac8-7052-a609-24a539d353cd',
}),
}),
},
responses: {
[OK]: {
content: {
'application/json': {
schema: todoSchema,
},
},
description: 'Check a todo as completed',
},
[NOT_FOUND]: {
content: {
'application/json': {
schema: problemDocumentSchema,
},
},
description: 'Todo not found',
},
},
});
type Config = typeof config;
const handler: RouteHandler<Config> = async c => {
const { todoId } = c.req.valid('param');
const todo = todos.find(t => t.todoId === todoId);
if (!todo) {
return c.json(
new ProblemDocument({
type: '/problems/resource-not-found',
title: 'Resource not found',
status: NOT_FOUND,
detail: `Todo with id ${todoId} not found`,
instance: c.req.path,
}),
NOT_FOUND
);
}
todo.completed = true;
return c.json(todo, OK);
};
export const checkRoute = new OpenAPIHono({
strict: false,
defaultHook,
}).openapi(config, handler);
Action Endpoint Pattern:
path: '/todos/{todoId}/check',
method: 'put',
This is an "action" endpoint:
No request body needed
Performs a specific action (checking todo)
Idempotent (calling multiple times same effect)
// src/index.ts
import { serve } from '@hono/node-server';
import { ENV } from '@/env.js';
import { OpenAPIHono } from '@hono/zod-openapi';
import { addRoute } from '@/features/todos/addTodo.js';
import { Scalar } from '@scalar/hono-api-reference';
import { listRoute } from './features/todos/listTodos.js';
import { findRoute } from './features/todos/findTodo.js';
import { checkRoute } from './features/todos/checkTodo.js';
const app = new OpenAPIHono()
.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'Todo API',
},
})
.get(
'/reference',
Scalar({
url: '/doc',
theme: 'kepler',
layout: 'classic',
darkMode: true,
})
)
.route('/', addRoute)
.route('/', listRoute)
.route('/', findRoute)
.route('/', checkRoute);
serve(
{
fetch: app.fetch,
port: ENV.PORT,
},
info => {
console.log(
`Server(${ENV.NODE_ENV}) is running on http://localhost:${info.port}`
);
}
);
The .doc() Method:
.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'Todo API',
},
})
This registers GET /doc endpoint that returns the OpenAPI JSON specification. The spec is automatically generated from all registered routes.
What Gets Generated:
{
"openapi": "3.0.0",
"info": {
"version": "1.0.0",
"title": "Todo API"
},
"paths": {
"/todos": {
"get": { /* listRoute config */ },
"post": { /* addRoute config */ }
},
"/todos/{todoId}": {
"get": { /* findRoute config */ }
},
"/todos/{todoId}/check": {
"put": { /* checkRoute config */ }
}
},
"components": {
"schemas": {
"Todo": { /* todoSchema */ },
"CreateTodo": { /* addTodoSchema */ },
"ProblemDocument": { /* problemDocumentSchema */ }
}
}
}
Scalar Documentation UI:
.get(
'/reference',
Scalar({
url: '/doc', // Points to OpenAPI spec endpoint
theme: 'kepler', // Visual theme
layout: 'classic', // Layout style
darkMode: true, // Enable dark mode
})
)
Access at http://localhost:3000/reference for interactive API documentation.
Mounting Routes:
.route('/', addRoute)
.route('/', listRoute)
.route('/', findRoute)
.route('/', checkRoute)
Each route is a complete OpenAPIHono instance. .route() mounts them on the main app at the specified base path.
Why This Pattern?
Modularity: Each route is self-contained
Testing: Test routes independently
Organization: Related code stays together
Reusability: Routes can be mounted on different apps
OpenAPIHono is fully compatible with Hono middleware:
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { bearerAuth } from 'hono/bearer-auth';
const app = new OpenAPIHono()
.use('*', logger())
.use('/api/*', cors())
.use('/api/admin/*', bearerAuth({ token: 'secret' }));
Documenting Authentication:
.doc('/doc', {
openapi: '3.0.0',
info: { /* ... */ },
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
security: [{ bearerAuth: [] }],
})
Override the default hook for specific routes:
const customHook: Hook<any, any, any, any> = (result, c) => {
if (!result.success) {
// Custom error handling for this specific route
return c.json(
{
error: 'Custom error format',
details: result.error.issues,
},
400
);
}
};
export const specialRoute = new OpenAPIHono()
.openapi(config, handler, customHook); // Route-specific hook
Create helpers for common responses:
const createSuccessResponse = <T extends z.ZodSchema>(schema: T) => ({
[OK]: {
content: {
'application/json': {
schema,
},
},
description: 'Success',
},
});
const createErrorResponses = () => ({
[BAD_REQUEST]: {
content: {
'application/json': {
schema: problemDocumentSchema,
},
},
description: 'Validation error',
},
[NOT_FOUND]: {
content: {
'application/json': {
schema: problemDocumentSchema,
},
},
description: 'Resource not found',
},
});
// Usage
const config = createRoute({
method: 'get',
path: '/todos',
responses: {
...createSuccessResponse(createPageSchema(todoSchema)),
...createErrorResponses(),
},
});
npm run dev
Access:
Documentation: http://localhost:3000/reference
OpenAPI Spec: http://localhost:3000/doc

The @hono/zod-openapi library eliminates the traditional pain points of API development by deriving types, validation, and documentation from a single schema definition. This approach scales from simple APIs to complex enterprise systems while maintaining type safety and developer experience. You can find all the code here. Thanks, and happy coding