API ドキュメントは「書いた瞬間に古くなる」のが宿命です。OpenAPI YAML を手で書くと実装と二重管理になり、レスポンスのフィールド名が一個ズレた瞬間にクライアントが壊れる。@hono/zod-openapi は Zod スキーマを真実の源にして、OpenAPI 仕様 / Swagger UI / ハンドラの入出力型を一気に派生させてくれるので、この二重管理問題が消えます。
本記事では Cloudflare Workers 上で動かす前提で、最小の動くコードと本番投入時の実用パターンをまとめます。
到達点と前提
作るもの: GET /tasks/:id / POST /tasks の最小 API。OpenAPI 仕様と Swagger UI が同じ Worker から配信されます。
検証バージョン (2026-04 時点):
- Hono v4.12 /
@hono/zod-openapiv0.21 @hono/swagger-uiv0.5 / Zod v4.3- Wrangler v4.84 / Cloudflare Workers
リクエストの流れ:
client ─▶ Worker
├─ /tasks/:id ── Zod 検証 ──▶ ハンドラ
├─ /doc ── OpenAPI JSON
└─ /ui ── Swagger UI (CDN 配信)
1. スキーマを 1 ファイルに集約する
@hono/zod-openapi は Zod スキーマに .openapi() を生やすことで、OpenAPI のメタデータを後付けできます。型と仕様を分離しないのが最大の利点です。
// src/schema.ts
import { z } from "@hono/zod-openapi";
export const TaskSchema = z
.object({
id: z.string().uuid().openapi({ example: "0f5a..." }),
title: z.string().min(1).max(200).openapi({ example: "ブログ書く" }),
done: z.boolean().openapi({ example: false }),
createdAt: z.string().datetime(),
})
.openapi("Task");
export const CreateTaskInput = z
.object({
title: z.string().min(1).max(200),
})
.openapi("CreateTaskInput");
export const ErrorSchema = z
.object({
code: z.string().openapi({ example: "validation_error" }),
message: z.string(),
})
.openapi("Error");
.openapi("Task") のように名前を付けたスキーマは、OpenAPI 出力で components/schemas に登録され、$ref で参照されます。同じスキーマがレスポンス body として複数のエンドポイントから参照されるとき、巨大な inline 定義の重複が消えます。
2. ルート定義を createRoute で書く
ハンドラと OpenAPI 仕様を別々に書かず、createRoute 1 つにまとめます。
// src/routes/tasks.ts
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { z } from "@hono/zod-openapi";
import { CreateTaskInput, TaskSchema, ErrorSchema } from "../schema";
const getTask = createRoute({
method: "get",
path: "/tasks/{id}",
request: {
params: z.object({
id: z.string().uuid().openapi({ param: { name: "id", in: "path" } }),
}),
},
responses: {
200: {
content: { "application/json": { schema: TaskSchema } },
description: "task found",
},
404: {
content: { "application/json": { schema: ErrorSchema } },
description: "task not found",
},
},
tags: ["tasks"],
});
const createTask = createRoute({
method: "post",
path: "/tasks",
request: {
body: {
content: { "application/json": { schema: CreateTaskInput } },
required: true,
},
},
responses: {
201: {
content: { "application/json": { schema: TaskSchema } },
description: "task created",
},
400: {
content: { "application/json": { schema: ErrorSchema } },
description: "invalid input",
},
},
tags: ["tasks"],
});
export const tasks = new OpenAPIHono()
.openapi(getTask, async (c) => {
const { id } = c.req.valid("param"); // Zod で検証済 / 型推論済
const row = await c.env.DB.prepare("SELECT * FROM tasks WHERE id = ?")
.bind(id)
.first();
if (!row) return c.json({ code: "not_found", message: "task not found" }, 404);
return c.json(row as any, 200); // 実運用では Drizzle で型を通す
})
.openapi(createTask, async (c) => {
const input = c.req.valid("json"); // CreateTaskInput が推論される
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
await c.env.DB.prepare(
"INSERT INTO tasks (id, title, done, created_at) VALUES (?, ?, 0, ?)"
)
.bind(id, input.title, createdAt)
.run();
return c.json(
{ id, title: input.title, done: false, createdAt },
201
);
});
c.req.valid("param" | "query" | "json") は createRoute で宣言したスキーマから直接型推論されるので、ハンドラ内で as キャストが消えます。ここが他の OpenAPI ライブラリと比べて飛び抜けて気持ち良いポイントです。
3. OpenAPI JSON と Swagger UI を生やす
ルート登録が終わったら、.doc() と swaggerUI() を 1 行ずつ足すだけで仕様と UI が出ます。
// src/index.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
import { tasks } from "./routes/tasks";
type Bindings = { DB: D1Database };
const app = new OpenAPIHono<{ Bindings: Bindings }>();
app.route("/", tasks);
app.doc("/doc", {
openapi: "3.1.0",
info: { title: "r43lab tasks", version: "1.0.0" },
});
app.get("/ui", swaggerUI({ url: "/doc" }));
export default app;
/doc で OpenAPI 3.1 JSON、/ui で Swagger UI が見られます。Cloudflare Workers の場合は wrangler dev で起動してブラウザを開けばすぐ確認できます。
4. クライアント SDK を 1 コマンドで生成する
OpenAPI が生えてしまえば、クライアント側の型付き fetch ラッパーは生成器に任せられます。openapi-typescript で型だけ取り出し、openapi-fetch で叩く構成が、依存もスリムでおすすめです。
npx openapi-typescript http://localhost:8787/doc \
-o packages/client/src/types.ts
// packages/client/src/index.ts
import createClient from "openapi-fetch";
import type { paths } from "./types";
export const api = createClient<paths>({ baseUrl: "https://api.r43lab.com" });
const { data, error } = await api.POST("/tasks", {
body: { title: "ブログ書く" }, // ← 型補完が効く
});
サーバ側の Zod スキーマを 1 行変えると、ビルド時にクライアントの型まで赤くなるので、API の breaking change に気付かないまま release タグを打つ事故がほぼ消えます。
5. バリデーションエラーの整形を中央集約
デフォルトの defaultHook は何もしないので、Zod のエラーが 400 で返るときの形を揃えておきます。
// src/index.ts (抜粋)
const app = new OpenAPIHono<{ Bindings: Bindings }>({
defaultHook: (result, c) => {
if (!result.success) {
return c.json(
{
code: "validation_error",
message: "invalid input",
issues: result.error.issues,
},
400
);
}
},
});
これで全ルートの 400 が同じ JSON 形になり、クライアント側のエラーハンドラが 1 つで済みます。
落とし穴 / ハマりポイント
.openapi(name)を付け忘れると inline で展開される。生成 SDK がやたら冗長になり diff が荒れます。「再利用される型」には必ず名前を付けるz.coerceを使うと OpenAPI 仕様のtypeが崩れる。クエリの number 化などはz.coerce.number()ではなくz.string().regex(/^\d+$/).transform(Number)のように、入力型を明示してから transform するほうが OpenAPI 出力が安定する- Workers の
Response.json()と Hono のc.json()は別物。c.json(x, 200)のステータスコードは createRoute のresponsesキーと一致していないとビルドが通らない (これは型エラーで気付ける、地味に有難い) - Swagger UI は CDN 経由で JS を引く。CSP を厳しくしている場合は
script-srcにcdn.jsdelivr.netを許可するか、Scalar などローカルバンドル版に切り替える
ざっくり比較: 他の選択肢
OpenAPI を Hono に乗せる方法は複数あります。2026-04 時点の選択肢を一行で:
@hono/zod-openapi— 本記事の構成。Zod スキーマと createRoute を直結。型推論が一番きれい- TS ファースト +
tsoa系 — クラスデコレータベース。Workers と相性が悪く、エッジに持っていく場合は外れる - 手書き YAML +
@hono/swagger-ui— 仕様の自由度は最大だが二重管理が発生
「エッジで動く」「型推論が要る」の両方を満たすなら、現状 @hono/zod-openapi 一択と言って良いです。
まとめ
Zod スキーマ 1 本から OpenAPI / 実装の入力検証 / ハンドラの型 / クライアント SDK まで派生する、というのは口で言うほど自明ではありません。@hono/zod-openapi はその 4 点を自然に揃えてくれるので、API 周りの「実装と仕様がズレる」事故が構造的に消えます。Cloudflare Workers の薄い API なら、初期の 30 分でフル装備を組めます。