Skip to Content
OpenAPI HubFrameworksHono

How to generate an OpenAPI document with Hono

This guide demonstrates generating an OpenAPI document for a Hono  API and using Speakeasy to create an SDK based on the generated document.

This guide covers:

  1. Adding Hono OpenAPI and Scalar UI to a Node.js Hono project.
  2. Generating an OpenAPI document using the Hono OpenAPI middleware with Zod validation.
  3. Improving the generated OpenAPI document to prepare it for code generation.
  4. Using the Speakeasy CLI to generate an SDK based on the OpenAPI document.
  5. Adding Speakeasy OpenAPI extensions to improve the generated SDK.

The generated SDK usage will also be demonstrated.

While the example application is simplified, the steps outlined should translate well to any Hono project.

The OpenAPI creation pipeline

Hono OpenAPI  is middleware that enables automatic OpenAPI documentation generation for Hono APIs by integrating with validation libraries like Zod, Valibot, ArkType, and TypeBox. This guide uses Zod  to define data schemas and validate values, then sets up the Hono app to generate an OpenAPI document.

The quality of the OpenAPI document determines the quality of generated SDKs and documentation, so techniques for improving the generated document will be explored based on the Speakeasy OpenAPI best practices.

The improved OpenAPI document will then be used to generate an SDK using Speakeasy.

Instructions for adding SDK creation to a CI/CD pipeline will be provided so that Speakeasy automatically generates fresh SDKs whenever the Hono API changes in the future.

Finally, a simplified example will demonstrate how to use the generated SDK.

Requirements

Ensure the following are installed:

This guide starts from scratch with a new Hono project, but anyone adding OpenAPI documentation to an existing Hono project can jump straight to step 3.

Step 1: Scaffold a Hono project

Those starting from scratch, run the Hono project scaffold command to get a basic setup.

Terminal
npm create hono@latest users-api

When prompted, select nodejs as the template. Then install dependencies:

Terminal
cd users-api && npm install

Step 2: Create a basic Web API

The scaffold generates a single src/index.ts. Split this into two files:

  • src/index.ts — starts the HTTP server
  • src/app.ts — defines the Hono app and routes

Replace src/index.ts with:

src/index.ts
import { serve } from '@hono/node-server' import app from './app' const port = 3000 console.log(`Server running on http://localhost:${port}`) serve({ fetch: app.fetch, port })

Create src/app.ts:

src/app.ts
import { Hono } from 'hono'; const app = new Hono(); app.get('/users', (c) => { return c.json([ { id: '1', name: 'John Doe', age: 42 }, { id: '2', name: 'Sarah Jones', age: 32 }, ]) }); app.post('/users', async (c) => { const body = await c.req.json() return c.json({ id: '3', ...body }, 200) }); app.get('/users/:id', (c) => { const id = c.req.param('id') return c.json({ id, name: 'John Doe', age: 42 }) }); export default app;

Run npm run dev and open http://localhost:3000/users to confirm the API is working.

Step 3: Add Hono OpenAPI middleware

The Hono OpenAPI  middleware will be used to generate an OpenAPI document. This middleware works with validation libraries through Standard Schema , including Zod, Valibot, ArkType, and TypeBox.

This guide will use Zod (v4) schemas to validate values and types and to generate part of the OpenAPI document, but the same principles apply to the other supported validation libraries.

First, install the middleware and Zod:

Terminal
npm i hono-openapi @hono/standard-validator zod@v4

Next, create a schemas.ts file in the src folder, with Zod schemas for the data:

schema.ts
import { z } from "zod"; export const UserSelectSchema = z .object({ id: z.string(), name: z.string(), age: z.number(), }) .describe("User object") .meta({ ref: "UserSelect" }); export const UserInsertSchema = z .object({ name: z.string(), age: z.number(), }) .describe("User creation data") .meta({ ref: "UserInsert" }); export const patchUserSchema = UserInsertSchema.partial() .describe("User update data") .meta({ ref: "patchUser" });

The z object is imported directly from zod unlike other Hono/Zod integrations that modify it and extent it a bit. This is just a plain Zod instance, so check out the Zod documentation for more guidance.

One important thing here is .meta({ ref: "..." }), which registers schemas as reusable components exactly based on OpenAPI’s component structure and the JSON Schema $ref keyword.

Schemas can be created for anything that would benefit from a data schema, validation properties, and human-readable descriptions. This includes, but is not limited to, the JSON request body, query parameters, headers, and response data, and error messages.

schema.ts
export const idParamsSchema = z.object({ id: z.string().min(3), }); export const ErrorSchema = z .object({ message: z.string(), }) .describe("Error response") .meta({ ref: "Error" });

Step 4: Describe what endpoints should be doing with describeRoutes

With hono-openapi, the OpenAPI metadata and schemas are added through the describeRoute middleware.

For better code organization, and to avoid overloading src/app.ts with OpenAPI descriptions, consider splitting the route description objects from the route handlers.

Create a users.routes.ts file in the src/routes/users folder, with route description objects like this following:

users.routes.ts
import { ErrorSchema, UserSelectSchema, } from "@/schemas"; import { resolver } from "hono-openapi"; import { z } from "zod"; export const listRoute = { description: "Get all users", responses: { 200: { content: { "application/json": { schema: resolver(z.array(UserSelectSchema)), }, }, description: "The list of users", }, }, }; export const createRoute = { description: "Create a user", responses: { 200: { content: { "application/json": { schema: resolver(UserSelectSchema), }, }, description: "The created user", }, 422: { content: { "application/json": { schema: resolver(ErrorSchema), }, }, description: "The validation error(s)", }, }, }; export const getOneRoute = { description: "Get a user by ID", responses: { 200: { content: { "application/json": { schema: resolver(UserSelectSchema), }, }, description: "The requested user", }, 404: { content: { "application/json": { schema: resolver(ErrorSchema), }, }, description: "User not found", }, 422: { content: { "application/json": { schema: resolver(ErrorSchema), }, }, description: "Invalid id error", }, }, };

These route description objects define the OpenAPI specification for each route. The resolver function converts Zod schemas to OpenAPI schemas, which will be used for request validation automatically when the validator middleware is used in the route handlers.

Step 5: Implement route handlers

With the endpoints described by OpenAPI in users.routes.ts it’s time to define the route handler logic itself. This is what the endpoint will actually do. Create something. Save something. Send something.

To implement this code we need another file, so create a users.handlers.ts file in the src/routes/users folder and use the following placeholder code.

users.handlers.ts
import type { Context } from "hono"; export const list = async (c: Context) => { // TODO: db query to get all users return c.json( [ { age: 42, id: "123", name: "John Doe", }, { age: 32, id: "124", name: "Sarah Jones", }, ], 200, ); }; export const create = async (c: Context) => { const user = c.req.valid("json"); console.log({ user }); // TODO: db query to create a user return c.json( { id: "2342", age: user.age, name: user.name, }, 200, ); }; export const getOne = async (c: Context) => { const { id } = c.req.valid("param"); // TODO: db query to get a user by id const foundUser = { age: 50, id, name: "Lisa Smith", }; if (!foundUser) { return c.json( { message: "Not found", }, 404, ); } return c.json(foundUser, 200); };

With hono-openapi, handlers accept the one argument, which is Hono’s standard Context type. This gives access to the request data and other validation middleware logic, which can be accessed using c.req.valid().

The schemas are there, the app logic is written, but now it all needs to be brought together.

Step 6: Register application routes with the Hono router

For each endpoint in the API, it needs a route, a HTTP method, a path, and a handler. That’s regular Hono usage, but what’s extra for OpenAPI here is the two middlewares being added in: describeRoute and zValidator.

users.index.ts
import { Hono } from "hono"; import { describeRoute, validator as zValidator } from "hono-openapi"; import { idParamsSchema, UserInsertSchema } from "@/schemas"; import * as handlers from "./users.handlers"; import * as routes from "./users.routes"; const router = new Hono(); router.get("/users", describeRoute(routes.listRoute), handlers.list); router.post( "/users", describeRoute(routes.createRoute), zValidator("json", UserInsertSchema), handlers.create ); router.get( "/users/:id", describeRoute(routes.getOneRoute), zValidator("param", idParamsSchema), handlers.getOne ); export default router;

The important bits here are these two validators:

  • describeRoute middleware to add OpenAPI documentation to the route based on the route description objects defined in users.routes.ts.
  • validator (aliased as zValidator) middleware to validate requests and automatically add request schemas to OpenAPI.

By using the same Zod schema instance to both power the actual server-side validation logic, and generate the OpenAPI description (which is then used for documentation, mock servers, SDK generation, etc.) the implementation cannot get out of sync with the OpenAPI, something people have struggled with for years.

Step 7: Exporting OpenAPI and API reference documentation

Once all this is set up, it’s time to go about exporting the OpenAPI, and therefore creating API Reference Documentation, SDKs, or any other artifacts from this OpenAPI. One popular approach is to offer this up over HTTP so it can be used by anything with access to the API domain, therefore ensuring the correct version of the OpenAPI is being looked at.

One way to do this is with a src/lib/configureOpenAPI.ts file, that does three things:

  1. Add all the higher-level OpenAPI that’s not covered on the route-specific Zod-based stuff so far.
  2. Add a route for /openapi to export the OpenAPI itself.
  3. Add a route for /docs which uses Scalar UI to turn that OpenAPI into beautiful simple API documentation.

Option 3 is optional, and will need the following NPM package to make it work:

Terminal
npm install @scalar/hono-api-reference

The code to do all of this will look like this.

src/lib/configureOpenAPI.ts
import type { Hono } from "hono"; import { Scalar } from "@scalar/hono-api-reference"; import { openAPIRouteHandler } from "hono-openapi"; import packageJson from "../../package.json"; export const openAPIDocumentation = { info: { version: packageJson.version, title: "Users API", }, externalDocs: { description: "Find out more about Users API", url: "www.example.com", }, }; export default function configureOpenAPI(app: Hono) { // Comment these out if exposing OpenAPI/Docs over HTTP is not wanted app.get( "/openapi", openAPIRouteHandler(app, { documentation: openAPIDocumentation, }) ); app.get( "/docs", Scalar({ url: "/openapi", pageTitle: "Users Management API", }) ); }

Now, pass in the Hono app instance to the configureOpenAPI function in the src/app.ts file:

app.ts
import { Hono } from "hono"; import configureOpenAPI from "./lib/configureOpenAPI"; const app = new Hono(); configureOpenAPI(app);

To confirm this has all worked, go to http://localhost:3000/openapi to see the OpenAPI description in JSON, and navigate to http://localhost:3000/docs to see Scalar UI showing off the API documentation nicely.

Scalar UI endpoints

Step 8: Save the OpenAPI description to a file

To generate an SDK, save the OpenAPI description as a YAML file. First, install the js-yaml package:

Terminal
npm i js-yaml && npm i --save-dev @types/js-yaml

Create a script called generateOpenApiDocument.ts in the src/ folder. This script imports the whole Hono application, and the openAPIDocumentation from src/lib/configureOpenAPI.ts which brings in the generic OpenAPI not defined elsewhere in the application. Then it uses generateSpecs to extract the OpenAPI document, and writes it as YAML.

src/generateOpenApiDocument.ts
import fs from "node:fs"; import { generateSpecs } from "hono-openapi"; import yaml from "js-yaml"; import mainApp from "./app"; import { openAPIDocumentation } from "./lib/configureOpenAPI"; async function main() { const specs = await generateSpecs(mainApp, { documentation: openAPIDocumentation, }); fs.writeFileSync("openapi.yaml", yaml.dump(specs)); } main();

Add a script to package.json:

package.json
"generate:openapi": "npx tsx ./src/generateOpenApiDocument.ts"

Run the script:

Terminal
npm run generate:openapi

An openapi.yaml file will be created in the root folder.

Step 9: Lint the OpenAPI document

Even though this OpenAPI is being generated from Zod and Hono, it’s still possible to make some mistakes. To guide you through this process the Speakeasy CLI has an OpenAPI linting built in, which can check the OpenAPI document for errors and style issues.

Run the linting command:

Terminal
speakeasy lint openapi --schema ./openapi.yaml

A lint report will be displayed in the terminal, showing errors, warnings, and hints:

Speakeasy lint report

The Speakeasy Linter has a set of rules that can be configured.

Speakeasy has a VS Code extension  to help validate OpenAPI documents for the creation of production-grade SDKs.

For advanced customization, Speakeasy transformations can modify the OpenAPI document (remove unused components, filter operations), and overlays can add extensions or examples without modifying the original document.

Step 10: Improving OpenAPI quality

Now that the OpenAPI is being generated as a local file and/or a URL and linted for validity, it is time to really polish this OpenAPI to make more tools have more information and therefore be more useful.

Add operationIds to routes

In the OpenAPI document, each HTTP request has an operationId that identifies the operation. The operationId is used to generate method names and documentation in SDKs, and often used for clean URLs in documentation, so it’s good to have them.

Add an operationId to each route documentation object:

users.routes.ts
export const listRoute = { operationId: 'getUsers', description: "Get all users", responses: { // ... }, };

Add tags to routes

Tags group operations in generated SDK code and documentation, improving navigation and URL structure. Add them to all routes using the tags property:

users.routes.ts
export const listRoute = { operationId: 'getUsers', description: "Get all users", tags: ['Users'], responses: { // ... }, };

Add tag metadata by including a Tag Object  in the documentation configuration:

configureOpenAPI.ts
export const openAPIDocumentation = { info: { version: packageJson.version, title: "Users API", }, externalDocs: { description: "Find out more about Users API", url: "www.example.com", }, // !mark(1:8) tags: [{ name: 'Users', description: 'Users operations', externalDocs: { description: 'Find more info here', url: 'https://example.com', }, }], };

Add servers to the OpenAPI document

Speakeasy expects a list of servers at the root of the OpenAPI document. Add a servers property to the documentation object:

configureOpenAPI.ts
export const openAPIDocumentation = { info: { version: packageJson.version, title: "Users API", }, externalDocs: { description: "Find out more about Users API", url: "www.example.com", }, // !mark(1:6) servers: [ { url: 'http://localhost:3000/', description: 'Development server', }, ], tags: [{ name: 'Users', description: 'Users operations', externalDocs: { description: 'Find more info here', url: 'https://example.com', }, }], };

Step 11: Add retries with x-speakeasy-retries

Speakeasy extensions enable vendor-specific functionality in the OpenAPI document. Extension fields are prefixed with x-speakeasy-.

Add the x-speakeasy-retries extension to configure automatic retry behavior for SDK requests. Apply it globally in the documentation object:

configureOpenAPI.ts
export const openAPIDocumentation = { info: { version: packageJson.version, title: "Users API", }, externalDocs: { description: "Find out more about Users API", url: "www.example.com", }, servers: [ { url: 'http://localhost:3000/', description: 'Development server', }, ], // !mark(1:11) 'x-speakeasy-retries': { strategy: 'backoff', backoff: { initialInterval: 500, maxInterval: 60000, maxElapsedTime: 3600000, exponent: 1.5, }, statusCodes: ['5XX'], retryConnectionErrors: true, }, tags: [{ name: 'Users', description: 'Users operations', externalDocs: { description: 'Find more info here', url: 'https://example.com', }, }], };

Override the global retry strategy for a specific route by adding x-speakeasy-retries to that route’s documentation object:

users.routes.ts
export const listRoute = { operationId: 'getUsers', description: "Get all users", tags: ['Users'], // !mark(1:11) 'x-speakeasy-retries': { strategy: 'backoff', backoff: { initialInterval: 300, maxInterval: 40000, maxElapsedTime: 3000000, exponent: 1.2, }, statusCodes: ['5XX'], retryConnectionErrors: true, }, responses: { // ... }, };

Step 12: Create the SDK

The quickstart command will be used for a guided SDK setup.

Run the command using the Speakeasy CLI:

Terminal
speakeasy quickstart

Following the prompts, provide the OpenAPI document location, name the SDK SDK, and select TypeScript as the SDK language.

In the terminal, the steps taken by Speakeasy to create the SDK will be displayed.

│ Workflow - success │ └─Target: sdk - success │ └─Source: Users API - success │ └─Validating Document - success │ └─Diagnosing OpenAPI - success │ └─Tracking OpenAPI Changes - success │ └─Snapshotting OpenAPI Revision - success │ └─Storing OpenAPI Revision - success │ └─Validating gen.yaml - success │ └─Generating Typescript SDK - success │ └─Setup Environment - success │ └─Load and Validate Document - success │ └─Generate SDK - success │ └─Compile SDK - success │ └─Setup Environment - success │ └─Load and Validate Document - success │ └─Generate SDK - success │ └─Generating Code Samples - success │ └─Snapshotting Code Samples - success │ └─Snapshotting Code Samples - success │ └─Uploading Code Samples - success

Speakeasy validates the OpenAPI document to check that it is ready for code generation. Validation issues will be printed in the terminal. The generated SDK will be saved as a folder in the project.

If ESLint styling errors occur, running the speakeasy quickstart command from outside the project will resolve them.

Speakeasy also suggests improvements for the SDK using Speakeasy Suggest, which is an AI-powered tool in Speakeasy Studio. Suggestions can be viewed by opening the link to the Speakeasy Studio workspace in the terminal:

Speakeasy Studio showing SDK improvement suggestions

Adding SDK generation to GitHub Actions

The Speakeasy sdk-generation-action repository provides workflows for integrating the Speakeasy CLI into CI/CD pipelines to automatically regenerate SDKs when the OpenAPI document changes.

Speakeasy can be set up to automatically push a new branch to SDK repositories so that engineers can review and merge the SDK changes.

For an overview of how to set up automation for SDKs, see the Speakeasy SDK Workflow Matrix.

Using the SDK

Once an SDK has been generated, it can be published for use. For TypeScript, it can be published as an npm package.

A quick, non-production-ready way to see the SDK in action is to copy the SDK folder to a frontend TypeScript project and use it there.

For example, a Vite project that uses TypeScript can be created:

Terminal
npm create vite@latest

Copy the SDK folder from the Hono app to the src directory of the TypeScript Vite project and delete the SDK folder in the Hono project.

In the SDK README.md file, documentation about the Speakeasy SDK can be found. TypeScript SDKs generated with Speakeasy include an installable Model Context Protocol (MCP) server  where the various SDK methods are exposed as tools that AI applications can invoke. The SDK documentation includes instructions for installing the MCP server.

Note that the SDK is not ready for production use. To get it production-ready, follow the steps outlined in the Speakeasy workspace.

The SDK includes Zod as a bundled dependency, as can be seen in the sdk-typescript/package.json file.

Replace the code in the src/main.ts file with the following example code taken from the sdk-typescript/docs/sdks/users/README.md file:

main.ts
import { SDK } from "./sdk-typescript/src/"; // Adjust the path as necessary eg if your generated SDK has a different name const sdk = new SDK(); async function run() { const result = await sdk.users.getUsers(); // Handle the result console.log({ result }); } run();

Run the Vite dev server:

Terminal
npm run dev

Enable CORS in the Hono dev server by importing the built-in CORS middleware in the src/app.ts file:

app.ts
import { cors } from "hono/cors";

Add the middleware and set the origin to the Vite dev server URL:

app.ts
app.use( "/users", cors({ origin: "http://localhost:5173", }), );

Run the Hono dev server as well:

Terminal
npm run dev

The following will be logged in the browser dev tools console:

{ "result": [ { "id": "123", "name": "John Doe", "age": 42 }, { "id": "124", "name": "Sarah Jones", "age": 32 } ] }

The SDK functions are type safe and include TypeScript autocompletion for arguments and outputs.

If attempting to access a property that doesn’t exist:

main.ts
const userOne = result[0].surname;

A TypeScript error will be displayed:

Property 'surname' does not exist on type 'UserSelect'

Further reading

This guide covered the basics of generating an OpenAPI document using Hono. Here are some resources for learning more about OpenAPI, the Hono OpenAPI middleware, and Speakeasy:

Last updated on