Next.js Route Handlers + HonoでバックエンドAPIを構築 & OpenAPIドキュメントを生成する

はじめに
最近7年もののエアコンの買い替えを検討しているkohachanです。
今回はNext.jsのRoute HandlersとHonoを組み合わせて、Honoにルーティングを持たせる形でバックエンドAPIを構築する方法をご紹介したいと思います。
さらに、OpenAPIのドキュメントもライブラリを使うことで簡単に生成できるため、その方法まで実践していきます。
Route Handlersとは?
公式ドキュメントは以下になるので、詳細はこちらが参考になります。
https://nextjs.org/docs/app/building-your-application/routing/route-handlers
Next.jsのApp Routerでは、Route HandlersというAPIを公開するためのリクエストハンドラー機能があります。
app
ディレクトリ配下にroute.ts (or route.js)
というファイル名でリクエストハンドラーを含むファイルを配置することでAPIが実装できます。
APIのパスはapp
配下のディレクトリ構造に合わせて、外部に公開されます。
例えば、app/api/hello/route.ts
に以下のような実装をした場合、
アプリのapi/hello
にGETリクエストを送信すると、{"message": "hello"}
というJSONが帰ってきます。
export async function GET() {
const res = { message: "Hello" }
return Response.json({ data })
}
Honoとは?
Honoは軽量かつ高速なWebフレームワークで、どのJavaScriptランタイムでも動作することができます。
また、TypeScriptもサポートしているので適切に型定義もされるようになっています。
ハンズオン
では実際に、Next.jsのRoute Handlersに対して、Honoでルーティングを実装してAPIを構築するまでを試してみたいと思います。
また、OpenAPIドキュメントの生成を行う実装も行っていきます。
※Next.jsの環境は構築済みのものとして割愛。
Route HandlersでHonoを利用する設定
まず、app/api/[...route]
のディレクトリを作成します。
そして、app/api/[...route]/route.ts
を作成し、以下のように実装をします。
app/api/[...route]/route.ts
import { Hono } from "hono";
import { handle } from "hono/vercel";
import todoLists from "./todos";
const app = new Hono().basePath("/api");
app.route("/todos", todoLists);
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
APIのスキーマをschema/todos.ts
にて定義しておきます。
schema/todos.ts
import { z } from "zod";
export const TodoSchema = z.object({
id: z.number(),
title: z.string(),
dueDate: z.string(),
isCompleted: z.boolean(),
order: z.number(),
});
export const TodosResponseSchema = z.object({
todos: z.array(TodoSchema),
});
次に、app/api/[...route]/todos.ts
に以下のコードを実装します。
app/api/[...route]/todos.ts
import { Hono } from "hono";
import { TodosResponseSchema } from "@/schema/todos";
import { z } from "zod";
type ApiGetOutputType = z.infer<typeof TodosResponseSchema>;
const todos = [
{
id: 1,
title: "買い物リストを作成する",
dueDate: new Date("2024-03-25").toISOString().split("T")[0],
isCompleted: false,
order: 1,
},
{
id: 2,
title: "プロジェクトの進捗報告書を書く",
dueDate: new Date("2024-03-26").toISOString().split("T")[0],
isCompleted: true,
order: 2,
},
{
id: 3,
title: "コードレビューの実施",
dueDate: new Date("2024-03-27").toISOString().split("T")[0],
isCompleted: false,
order: 3,
},
];
const app = new Hono().get("/", async (c) => {
return c.json<ApiGetOutputType>({
todos,
});
});
export default app;
以下、簡単な解説です。
まず、app/api/[...route]
のディレクトリを作ることで、app/api/
配下へのリクエストを全て上記のroute.tsの処理で受けれるようにします。
route.ts内では、const app = new Hono().basePath("/api")
とすることでAPIのベースパスを/api
と定義しています。
さらにapp.route()
でパスに対応するHonoのインスタンスを紐付けています。
そしてexport const GET = handle(app);
のように、それぞれのHTTPメソッド名の定数でハンドラーをエクスポートすることで、対応するHTTPメソッドのリクエストを受けれるようになります。
app/api/[...route]/todos.ts
側では、HTTPメソッドに対応した関数をHonoインスタンスに繋げて実装することができます。
こちらでもroute.ts側でパスと紐づけるのに参照させるため、Honoインスタンスをexport default app
でエクスポートしておきます。
上記の実装については、Honoの以下ドキュメントを参考にしています。
https://hono.dev/docs/guides/best-practices#building-a-larger-application
クライアント側のデータフェッチ実装
以上の設定でAPIの実装は完了していますが、実際にクライアント側からデータを取得できるかを確認するために、簡単なフェッチ処理を実装してみます。
app/todos/page.tsx
に以下のようなコードを記述します。
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
import { TodoSchema } from "@/schema/todos";
type Todo = z.infer<typeof TodoSchema>;
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
fetch("/api/todos")
.then((res) => res.json())
.then((data) => setTodos(data.todos))
.catch((error) => console.error("データ取得エラー:", error));
}, []);
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-4">Todoリスト</h1>
{todos.length === 0 ? (
<p>現在登録されているTodoはありません。</p>
) : (
<ul className="space-y-2">
{todos.map((todo) => (
<li key={todo.id} className="border rounded p-3 bg-white shadow-sm">
<div className="font-semibold text-gray-800">{todo.title}</div>
<div className="text-sm text-gray-600">
期限: {new Date(todo.dueDate).toLocaleDateString()} / 完了:
{todo.isCompleted ? "済" : "未"}
</div>
</li>
))}
</ul>
)}
</div>
);
}
動作の確認
ローカル開発サーバーを起動して、http://localhost:3000/todos にアクセスします。
正常にAPIとの通信ができていれば、Todoのリストが表示されます。

OpenAPIドキュメントの生成
Honoでは、hono-openapi
というライブラリを利用することで、簡単にOpenAPIドキュメントを生成・表示することができます。
まずは必要なライブラリをインストールします。
npm install hono-openapi @hono/zod-validator zod zod-openapi
app/api/[...route]/todos.ts
を以下のように変更します。
import { Hono } from "hono";
import { describeRoute, openAPISpecs } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { TodosResponseSchema } from "@/schema/todos";
import { z } from "zod";
type ApiGetOutputType = z.infer<typeof TodosResponseSchema>;
const todos = [
{
id: 1,
title: "買い物リストを作成する",
dueDate: new Date("2024-03-25").toISOString().split("T")[0],
isCompleted: false,
order: 1,
},
{
id: 2,
title: "プロジェクトの進捗報告書を書く",
dueDate: new Date("2024-03-26").toISOString().split("T")[0],
isCompleted: true,
order: 2,
},
{
id: 3,
title: "コードレビューの実施",
dueDate: new Date("2024-03-27").toISOString().split("T")[0],
isCompleted: false,
order: 3,
},
];
const app = new Hono();
app.get(
"/",
describeRoute({
description: "Todoリストを取得する",
responses: {
200: {
description: "成功",
content: {
"application/json": { schema: resolver(TodosResponseSchema) },
},
},
},
}),
async (c) => {
return c.json<ApiGetOutputType>({
todos,
});
}
);
app.get(
"/openapi",
openAPISpecs(app, {
documentation: {
info: {
title: "Todo API",
version: "1.0.0",
description: "Todo API",
},
servers: [{ url: "http://localhost:3000", description: "Local Server" }],
},
})
);
export default app;
ドキュメントはhttp://localhost:3000/api/todos/openapi
で確認することができます。
実際にブラウザで確認してみると、以下のようなドキュメントを閲覧できるようになっていました!

ディレクトリ内にOpenAPIドキュメントのファイルを生成したい場合
上述の方法では、あくまでAPI経由でドキュメントを提供する形となります。
ファイルとして .yaml や .json 形式で生成したい場合は、zod-to-openapi
を使うのがおすすめです。
まずは必要なライブラリをインストールします。
npm install -D @asteasolutions/zod-to-openapi yaml tsx
スキーマは以下のように変更しておきます。
公式ドキュメントはこちらを参照してください。
schema/todos.ts
import { z } from "zod";
import { RouteConfig } from "@asteasolutions/zod-to-openapi";
export const TodoSchema = z.object({
id: z.number(),
title: z.string(),
dueDate: z.string(),
isCompleted: z.boolean(),
order: z.number(),
});
export const TodosResponseSchema = z.object({
todos: z.array(TodoSchema),
});
// 以下定義を追加
export const TodosApiSchema: RouteConfig = {
method: "get",
path: "/todos",
summary: "Todoリストを取得する",
tags: ["todos"],
request: {},
responses: {
200: {
description: "成功",
content: {
"application/json": { schema: TodosResponseSchema },
},
},
},
};
そして、以下のようなスクリプトを用意しておきます。
scripts/gen-openapi.ts
import fs from "fs";
import path from "path";
import * as yaml from "yaml";
import {
extendZodWithOpenApi,
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import { TodosApiSchema } from "@/schema/todos"; // スキーマ定義ファイルのパス
import { z } from "zod";
try {
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
registry.registerPath(TodosApiSchema);
const generator = new OpenApiGeneratorV3(registry.definitions);
const docs = generator.generateDocument({
openapi: "3.1.0",
info: {
title: "Todo API",
version: "1.0.0",
description: "Todo API",
},
});
const yamlDocs = yaml.stringify(docs);
const openApiFilePath = path.join(process.cwd(), "docs", "openapi.yml");
const docsDir = path.dirname(openApiFilePath);
// docsディレクトリが存在しない場合は作成
if (!fs.existsSync(docsDir)) {
fs.mkdirSync(docsDir, { recursive: true });
}
// ファイルの書き込み
fs.writeFileSync(openApiFilePath, yamlDocs, { encoding: "utf-8" });
console.log("OpenAPIドキュメントが正常に生成されました:", openApiFilePath);
} catch (error) {
console.error("OpenAPIドキュメントの生成中にエラーが発生しました:");
if (error instanceof Error) {
console.error(error.message);
} else {
console.error("不明なエラーが発生しました");
}
process.exit(1);
}
さらにpackage.jsonに以下のコマンドを追加しておきます。
{
"scripts": {
"gen-openapi": "tsx scripts/gen-openapi.ts"
}
}
これで以下のコマンドを実行すれば、docs/openapi.yaml
にOpenAPIのドキュメントファイルが生成されます。
成功すると、以下のようにopenapi.yamlが生成されていることが確認できます🙌

終わりに
Next.jsのRoute HandlersとHonoを組み合わせることで、柔軟かつ拡張性のあるAPIを簡潔に構築できることがわかりました。
また、OpenAPIとの連携によって、ドキュメントの自動生成や外部連携のための仕様提示が容易になります。
この記事が、Next.js + Hono によるAPI設計の参考になれば幸いです。