Keeping validation and documentation in sync from a single schema with Hono + Zod OpenAPI
When building an API, you will inevitably encounter this situation: you clearly modified the code, but the old field name is still sitting in your OpenAPI document, and the frontend team is continuing development based on that outdated doc. Or the reverse — you updated the documentation first, but the actual code still follows the old approach. This phenomenon is called Schema Drift, and it's nearly unavoidable in long-running REST APIs maintained without dedicated tooling.
The @hono/zod-openapi pattern discussed in this article is an approach that reduces the structural causes of drift. Because TypeScript type inference, runtime input validation, and OpenAPI 3.x document generation all derive simultaneously from a single Zod schema, code and documentation are forced to originate from the same source. As of 2026, OpenAPI integration in the Hono ecosystem has settled into two main branches — the official Zod-only path and the community multi-validator path — so the right choice depends on your team's situation. Let's look at how to choose between them, and how to actually use each.
Why Schema Drift Happens
In the traditional approach, you typically manage three separate artifacts by hand.
Changing a field from string to number, updating the type and validation code but forgetting the OpenAPI YAML — this happens all the time. Working solo you might catch it, but as team size grows and PR velocity increases, it's easy to miss in review too.
Why Zod Solves This Problem
Zod is fundamentally a runtime validation library, but it lets you validate values with .parse() or .safeParse() while simultaneously extracting TypeScript types via z.infer<>. On top of that, @hono/zod-openapi adds an .openapi() extension method, allowing the same schema to be exported as components/schemas in an OpenAPI spec.
The result is that the point of change converges to a single Zod schema. Of course, if you import the schema file inconsistently or maintain a separately hardcoded document in parallel, the divergence can still happen — so maintaining the discipline of "everything derives from a single source" is a prerequisite for your team.
Comparing the Two Integration Paths
As of 2026, there are two main paths for adding OpenAPI to Hono.
| Item | @hono/zod-openapi |
hono-openapi |
|---|---|---|
| Maintained by | Official Hono | Community |
| Supported validation libraries | Zod only | Zod, Valibot, ArkType, TypeBox, etc. |
| Integration approach | Replace app class (OpenAPIHono) |
Add middleware |
| Migrating existing code | Requires rewriting routes | Incremental adoption possible |
| Hono RPC compatibility | Supported | Partial |
| Standard Schema compatibility | Indirect via Zod v4 | Direct per adapter |
If your team is already using Zod and starting a new project, @hono/zod-openapi is the natural choice. On the other hand, if you're gradually adopting it into a legacy Hono app or using a different validation library like Valibot, hono-openapi is the better fit. This decision framework is revisited in the "Which to Choose in Which Situation" section below.
@hono/zod-openapi Usage Flow
Installation and App Initialization
npm install hono @hono/zod-openapi zod @hono/swagger-uiIf you plan to add Swagger UI, install @hono/swagger-ui alongside. The key change is using OpenAPIHono instead of Hono as your app class.
import { OpenAPIHono } from '@hono/zod-openapi'
const app = new OpenAPIHono()Schema Definition — Validation and Documentation in One Place
import { z } from '@hono/zod-openapi'
const UserParamSchema = z.object({
id: z.string().min(1).openapi({
param: { name: 'id', in: 'path' },
example: '123',
}),
})
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
}).openapi('User')The .openapi() call is where OpenAPI metadata is attached to the Zod schema. You declare the example, description, param location, and more here.
Route Definition
import { createRoute } from '@hono/zod-openapi'
const getUserRoute = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: UserParamSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Returns user information',
},
404: {
content: {
'application/json': {
schema: z.object({ message: z.string() }).openapi('ErrorResponse'),
},
},
description: 'User not found',
},
},
})Handler Registration — Types Are Already Attached
app.openapi(getUserRoute, async (c) => {
const { id } = c.req.valid('param')
// id is confirmed as a string type that has passed Zod validation
// Pseudocode below (e.g., Prisma, Drizzle, etc.)
const user = await findUserById(id)
if (!user) {
return c.json({ message: 'User not found' }, 404)
}
return c.json(user, 200)
})The value retrieved via c.req.valid('param') is a type-safe object that has already passed Zod validation. There's no need to add separate validation code inside the handler.
OpenAPI Document Endpoint and UI
import { swaggerUI } from '@hono/swagger-ui'
app.doc('/doc', {
openapi: '3.0.0',
info: {
title: 'User API',
version: '1.0.0',
},
})
app.get('/ui', swaggerUI({ url: '/doc' }))Accessing /doc returns an OpenAPI JSON that automatically includes all routes defined above. Simply running the server keeps the documentation always up to date.
Switching to Scalar UI
The Syntax.fm podcast has covered the Hono + Zod + OpenAPI + Scalar combination, and more and more people are using Scalar instead of the traditional Swagger UI. The setup is straightforward.
npm install @scalar/hono-api-referenceimport { apiReference } from '@scalar/hono-api-reference'
app.get(
'/reference',
apiReference({
spec: { url: '/doc' },
})
)It's ultimately a matter of UI preference, so it's hard to say definitively which is better, but Scalar offers a more modern design.
hono-openapi for Incremental Adoption in Existing Apps
The barrier to entry with @hono/zod-openapi is that you have to replace an existing Hono app with OpenAPIHono. It doesn't seem like a big deal at first, but in a real project where routers are spread across many files, it becomes quite a tedious migration.
hono-openapi works around this problem with a middleware-based approach. When using the Zod adapter, there's no need to install a separate @hono/zod-openapi; it relies on the zod-openapi family of adapters instead.
npm install hono-openapi zod zod-openapiimport { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { resolver, validator } from 'hono-openapi/zod'
import { z } from 'zod'
const app = new Hono()
const UserSchema = z.object({
id: z.string(),
name: z.string(),
})
app.get(
'/users/:id',
describeRoute({
description: 'Get user',
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: resolver(UserSchema),
},
},
},
},
}),
validator('param', z.object({ id: z.string() })),
(c) => {
const { id } = c.req.valid('param')
return c.json({ id, name: 'Alice' })
}
)Since you keep the existing Hono instance as-is and attach the describeRoute middleware per route, teams can adopt it incrementally.
Visualizing the Full Request Flow
Using the GET /users/{id} example from earlier, here is how the request handling path and the document generation path share the same schema.
The key is that the request path and the document generation path share the same Zod schema. When the schema changes, both paths change together.
Which to Choose in Which Situation
It's hard to say one is unconditionally better; the right choice depends on your team's circumstances.
| Situation | Recommendation |
|---|---|
| New project, already using Zod | @hono/zod-openapi |
| Incremental adoption into an existing Hono app | hono-openapi |
| Prefer Valibot / ArkType | hono-openapi |
| Need to use Hono RPC alongside | @hono/zod-openapi |
| Edge deployment (Cloudflare Workers, etc.) | Either works; Hono has first-class support |
| Heavy use of complex OpenAPI composition | Some manual work required with either |
The decision flow can be summarized as follows.
Trade-offs You'll Encounter in Practice
Once you've chosen a path, it's good to know the friction points you'll hit in practice.
The Problem of OpenAPI Metadata Polluting Your Schema
Adding .openapi({ example: '...' }) directly to a Zod schema means a previously pure validation schema now carries an OpenAPI dependency. Adding this metadata to shared common schemas used in many places can make dependency relationships complex.
One practical approach is to separate schemas into two layers.
// Pure validation (shareable)
const BaseUserSchema = z.object({
id: z.string().min(1),
email: z.string().email(),
})
// With OpenAPI metadata (API layer only)
const UserApiSchema = BaseUserSchema.openapi('User', {
description: 'User information',
})Scope of Response Validation
Request validation is a security baseline, but validating response bodies with Zod adds overhead to response time depending on schema size and request volume. How much overhead you'll see varies greatly by schema complexity, array sizes, and runtime environment (Node.js / Bun / edge), so benchmarking against your own traffic profile is the most reliable approach.
A common strategy is to enable response validation in development/staging to catch discrepancies between the schema and actual responses, then selectively retain it in production after reviewing the performance profile.
Limits of Complex OpenAPI Composition
@hono/zod-openapi converts z.discriminatedUnion() into OpenAPI's oneOf + discriminator. Simple discriminated unions are represented cleanly. However, manual work may still be required for cases like:
- Expressing a plain
z.union()(without a discriminant key) with a specificdiscriminatormapping - Assembling
allOf-based inheritance relationships or directly referencing external spec$refs - Needing conditional schemas (e.g.,
if/then/else)
In these edge cases, passing OpenAPI objects directly instead of Zod schemas is cleaner.
responses: {
200: {
content: {
'application/json': {
schema: {
oneOf: [
{ $ref: '#/components/schemas/AdminUser' },
{ $ref: '#/components/schemas/RegularUser' },
],
},
},
},
description: 'Response based on user type',
},
},Detecting Spec Drift in CI/CD
A common approach is to snapshot the OpenAPI spec as a file at build time and require explicit reviewer approval when a diff appears in a PR. For example, call app.getOpenAPIDocument() in scripts/generate-openapi.ts, write the JSON to a file, track that file as a committed artifact, and fail the CI pipeline if re-generation produces a diff.
The passing condition here is "does the committed snapshot in the repository match the re-generated result?" If you intentionally changed a schema, you're forced to commit the updated snapshot in the same PR — which means reviewers can clearly see the actual API change in the diff.
Strategy for Using Hono RPC and OpenAPI Together
@hono/zod-openapi can be used alongside Hono's RPC mode (the hc function). The benefit becomes clear when your audience splits into two groups.
- Internal service-to-service calls: For services calling each other within the same repository or monorepo, importing the server's types directly into the client eliminates a code generation step and lets you see type signatures in your IDE immediately.
- External partners / frontend teams: For parties who can't share server types directly, or who use a different language, the OpenAPI spec serves as the contract.
Since both outputs derive from the same Zod schema, situations where only one side reflects a change — leaving them out of sync — are structurally prevented.
// Extract server type
type AppType = typeof app
// Use RPC client in an internal service (no code generation needed)
import { hc } from 'hono/client'
const client = hc<AppType>('http://localhost:3000')
const res = await client.users[':id'].$get({ param: { id: '123' } })
// Types are inferred automaticallyYou expose the OpenAPI JSON at /doc to external consumers, and use the RPC client internally. This also leads naturally into workflows where tools like Speakeasy or Fern generate language-specific SDKs from the OpenAPI spec.
Closing Thoughts
Whichever path you choose, the practical long-term advantages of managing schemas from a single location are clear. When documentation and code are maintained separately, changing a single field scatters work across multiple files, and anything a reviewer misses in the diff comes back as onboarding friction for the frontend team or re-verification requests in the API console after deployment. Conversely, when schemas converge to a single source, the number of files to review shrinks, and debugging time spent on "the docs and the actual response don't match" issues after deployment decreases.
Whether you choose @hono/zod-openapi or hono-openapi depends on your team's current codebase and choice of validation library — there is no single right answer. But whichever you pick, the real value of this approach lies in maintaining the discipline of the "schema → types / validation / documentation" derivation relationship.
References
- Zod OpenAPI - Hono Official Docs
- Hono OpenAPI - Hono Official Docs
- hono/zod-openapi GitHub
- How To Generate an OpenAPI Document With Hono - Speakeasy
- Build Self-Documenting APIs with Hono, Zod, and OpenAPI - Victor Li
- Request Validation at the Edge - DEV Community
- Build a documented/type-safe API with Hono, Drizzle, Zod, OpenAPI and Scalar - Syntax.fm
- OpenAPI | HONC Docs
- Introducing Hono OpenAPI - DEV Community
- TypeScript API Contracts That Don't Drift - Medium