Route HandlersをZod OpenAPI Honoで置き換える
はじめに
この記事では、Next.js の Route Handlers の代わりに Zod OpenAPI Hono を使って API ルートを置き換える方法について紹介します。Hono は軽量かつ柔軟で、Zodとの相性も良く、型安全な API 設計と仕様書自動生成が非常にスムーズに行えます。
特に今回試したのは以下のようなポイントです:
- @hono/zod-openapi を使って OpenAPI 仕様書を自動生成する
- RouteHandlers ではなく Hono を API のエントリポイントとして使う
Next.js の API 開発をより型安全かつ拡張しやすいものにしたい方に、少しでも参考になれば幸いです。
サンプルプロジェクト作成(スキップ可能)
chatGPTにRouteHandlersを使用したサンプルアプリを作成してもらい、それに対しHonoで置き換え+仕様書自動作成を行います。
今回作成したデモアプリ
どちらもシンプルなJSONを返すエンドポイントです。
GET api/fugaはクエリパラメータを取得してそれに応じたmessageをJSONで返します。
POST api/hoge/[id]はパスパラメータを取得してそれに応じたmessageをJSONで返します。
ディレクトリ構成:
app/
├── api/
│ ├── fuga/
│ │ └── route.ts // GET /api/fuga?name=...
│ └── hoge/
│ └── [id]/
│ └── route.ts // POST /api/hoge/[id]
├── layout.tsx
└── page.tsx
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const { searchParams } = new URL(req.url);
const name = searchParams.get("name") ?? "anonymous";
return NextResponse.json({
message: `Hello, ${name}! Received via query parameter.`,
});
}
import { NextRequest, NextResponse } from "next/server";
export async function GET(
_req: NextRequest,
context: { params: { id: string } }
) {
const { id } = context.params;
return NextResponse.json({ message: `Received ID: ${id}` });
}
RouteHandlersをHonoで置き換えていく
上記のデモアプリと同じ振る舞い行うエンドポイントを/api配下に実装していきます。
以下、参考にしたサイトです。
公式ドキュメント
どうやって置き換えるか
RouteHandlersのOptional Catch-all Segments を使用します。
/api/[[..route]]などのフォルダを作成することで、/api/[[..route]]/route.ts内で/api配下への全てのリクエストをキャッチすることができます。
今回は以下のようなディレクトリ構成で置き換えます。
app/
└── api/
└── [[..route]]/ // Catch-all APIルート(Honoエントリポイント)
├── model/
│ ├── fuga.ts // fuga に関する Zod スキーマ定義
│ └── hoge.ts // hoge に関する Zod スキーマ定義
├── fugaApi.ts // fuga API のルート定義(createRoute)
├── hogeApi.ts // hoge API のルート定義(createRoute)
└── route.ts // Honoルーターの作成と統合、およびOpenAPI/Swagger UIのエンドポイント定義
Honoの導入
npm i hono zod @hono/zod-openapi @hono/swagger-ui
スキーマ定義
First, define your schemas with Zod. The z object should be imported from @hono/zod-openapi:
まず、zod-openapiを使用して、各エンドポイントでバリデーションを行うためのスキーマを定義します。
model/hoge.tsとmodel/fuga.tsを作成
import { z } from "@hono/zod-openapi";
export const PostHogeResponseSchema = z.object({
message: z.string(),
});
export const PostHogeParamSchema = z
.object({
id: z.string().openapi({
param: {
name: "id",
in: "path",
},
example: "random_uuid",
}),
})
.openapi("PostHogeParamSchema");
import { z } from "@hono/zod-openapi";
export const GetFugaResponseSchema = z.object({
message: z.string(),
});
export const GetFugaParamSchema = z
.object({
name: z.string().openapi({
param: {
name: "name",
in: "query",
},
example: "miyamotto",
}),
})
.openapi("GetFugaParamSchema");
エンドポイント実装
各エンドポイントの実装をしていきます。
fugaエンドポイント
Next, create a route:
次に、OpenAPIのルート定義を行います。ここでは、@hono/zod-openapi パッケージの createRoute メソッドを使用します。
先ほど定義したスキーマを用いてルート定義を行います。requestやresponseには、bodyやheaders, 500エラーや400エラーのスキーマを定義することもできます。
const getFugaRoute = createRoute({
path: "/",
method: "get",
description: "fugaに対するGETリクエスト",
request: {
query: GetFugaParamSchema,
},
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: GetFugaResponseSchema,
},
},
},
// 500:{ ・・・ },
// 400:{ ・・・ }
},
});
続いてハンドラーです。パラメータを取得してJSONを返すだけです。
@hono/zod-openapiが提供するRouteHandler<R>型を利用することで、c.req.valid()で取得したパラメータにも型推論が効くようになります。
const postHogeHandler: RouteHandler<typeof postHogeRoute> = async (c) => {
const { id } = c.req.valid("param");
return c.json({ message: `Received ID: ${id}` }, 200);
};
hogeエンドポイント
続いてhogeエンドポイントです。fugaと同じように実装してきます。
const postHogeRoute = createRoute({
path: "/{id}",
method: "post",
description: "hogeに対するPOSTリクエスト",
request: {
params: PostHogeParamSchema,
},
responses: {
200: {
description: "OK",
content: {
"application/json": { schema: PostHogeResponseSchema },
},
},
},
});
const postHogeHandler: RouteHandler<typeof postHogeRoute> = async (c) => {
const { id } = c.req.valid("param");
return c.json({ message: `Received ID: ${id}` }, 200);
};
セットアップ
先ほど定義したルータとハンドラーを各エンドポイントでまとめます。
export const fugaApi = new OpenAPIHono().openapi(getFugaRoute, getFugaHandler);
// .openapi(postFugaRoute, postFugaHandler).openapi(putFugaRoute, putFugaHandler).・・・
// のようにChained routeでエンドポイントごとに一度まとめておくと、後の/api/route.tsが見やすい
export const hogeApi = new OpenAPIHono().openapi(
postHogeRoute,
postHogeHandler
);
api/route.tsでChained routeにより各エンドポイントをまとめます。
import { OpenAPIHono } from "@hono/zod-openapi";
import { handle } from "hono/vercel";
import { fugaApi } from "./fugaApi";
import { hogeApi } from "./hogeApi";
import { swaggerUI } from "@hono/swagger-ui";
const app = new OpenAPIHono()
.basePath("/api")
.route("/fuga", fugaApi)
.route("/hoge", hogeApi)
.doc("/specification", {
openapi: "3.0.0",
info: {
title: "API",
version: "1.0.0",
},
})
.get(
"/doc",
swaggerUI({
url: "/api/specification",
})
);
export const GET = handle(app);
export const POST = handle(app);
.doc()によって使用を/api/specificationに記述して、それを元にしたswaggerUIを/doc配下に生成しています。この場合、/api/docで仕様書を見ることができます。
エンドポイントの実装全体
import { createRoute, OpenAPIHono, RouteHandler } from "@hono/zod-openapi";
import { GetFugaParamSchema, GetFugaResponseSchema } from "./model/fuga";
const getFugaRoute = createRoute({
path: "/",
method: "get",
description: "fugaに対するGETリクエスト",
request: {
query: GetFugaParamSchema,
},
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: GetFugaResponseSchema,
},
},
},
},
});
const getFugaHandler: RouteHandler<typeof getFugaRoute> = async (c) => {
const { name } = c.req.valid("query");
return c.json(
{ message: `Hello, ${name}! Received via query parameter.` },
200
);
};
export const fugaApi = new OpenAPIHono().openapi(getFugaRoute, getFugaHandler);
import { createRoute, OpenAPIHono, RouteHandler } from "@hono/zod-openapi";
import { PostHogeParamSchema, PostHogeResponseSchema } from "./model/hoge";
const postHogeRoute = createRoute({
path: "/{id}",
method: "post",
description: "hogeに対するPOSTリクエスト",
request: {
params: PostHogeParamSchema,
},
responses: {
200: {
description: "OK",
content: {
"application/json": { schema: PostHogeResponseSchema },
},
},
},
});
const postHogeHandler: RouteHandler<typeof postHogeRoute> = async (c) => {
const { id } = c.req.valid("param");
return c.json({ message: `Received ID: ${id}` }, 200);
};
export const hogeApi = new OpenAPIHono().openapi(
postHogeRoute,
postHogeHandler
);
実際に仕様書を見てみる
http://localhost:3000/api/docから実際に自動生成された仕様書を見ることができます。

いい感じですね。
この画面から試し打ちもできます。


Honoで置き換えることによる利点
今回、RouteHandlersをHonoで置き換えることで、以下のような利点があると感じました。
✅ OpenAPIドキュメントの自動生成
-
@hono/zod-openapiを使えば、Zodスキーマから OpenAPI 仕様書を自動で生成可能です。 - 仕様書の手書きが不要になり、常に最新の状態を保つことができます。
- チーム開発でも API 仕様の共有が圧倒的に楽になります。
✅ RPCのような型安全な通信ができる
-
hcを利用すれば、Honoで定義したAPIをクライアント側からRPC的に呼び出しできます。 -
fetchのラッパーとして型補完が効き、リクエスト・レスポンス両方の型が安全に保たれます。 - バックエンドとフロントエンドのAPI仕様の乖離を防止できます。
こちら非常に強力だと感じました。
✅ エラーハンドリングの共通化
-
app.onError()やapp.notFound()を使えば、全エンドポイントに共通のエラーハンドリング処理を定義できます。 - 例外時のログ出力、カスタムエラーレスポンス、通知処理なども一箇所に集約可能です。
app.onError((err, c) => { console.error(err); return c.json({ error: 'Internal Server Error' }, 500); });
Honoは、型安全・仕様書・エラー処理・開発体験をすべて一段階引き上げてくれる選択肢だと感じました。
今後も、他のプロジェクトでも積極的に使っていきたいと思える手応えがありました。
今回作成したコードはこちら
Discussion