今回やること
Honoは、Web標準のRequestとResponseを中心にAPIを作れる軽量なWebフレームワークです。
この記事ではNode.js上で、メモリにTodoを保存する最小APIを作ります。
GET /todosでTodo一覧を返すPOST /todosでTodoを1件追加する- ZodでPOSTのJSONを検証する
app.request()でGET、正常POST、不正POSTをテストする
データベース、認証、ファイルアップロード、Cloudflare Workersなどは扱いません。

使用環境
2026年7月25日時点で確認したnpmの安定版を使います。
| パッケージ | バージョン |
|---|---|
hono | 4.12.32 |
@hono/node-server | 2.0.11 |
@hono/zod-validator | 0.9.0 |
zod | 4.4.3 |
Node.jsは現行LTSの24を前提にします。Node.js 26はCurrentであり、まだLTSではありません。
node --version
npm --version
1. プロジェクトを作る
mkdir hono-todo-practice
cd hono-todo-practice
npm init -y
npm pkg set type=module
npm pkg set "scripts.dev=tsx watch src/server.ts"
npm pkg set "scripts.check=tsc"
npm pkg set "scripts.test=vitest run"
npm install hono@4.12.32 @hono/node-server@2.0.11 @hono/zod-validator@0.9.0 zod@4.4.3
npm install --save-dev typescript tsx vitest
mkdir src
Windows PowerShellでsrcを作る場合は、最後の行の代わりに次を実行します。
New-Item -ItemType Directory -Force src
tsconfig.jsonを作ります。
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
2. Todo APIを作る
src/app.tsを作ります。
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
type Todo = {
id: number;
title: string;
done: boolean;
};
const newTodoSchema = z.object({
title: z.string().trim().min(1).max(100),
});
export function createApp() {
const app = new Hono();
const todos: Todo[] = [];
let nextId = 1;
app.get("/todos", (c) => {
return c.json({ todos });
});
app.post(
"/todos",
zValidator("json", newTodoSchema, (result, c) => {
if (!result.success) {
return c.json(
{ error: "titleは1〜100文字で入力してください" },
400,
);
}
}),
(c) => {
const input = c.req.valid("json");
const todo: Todo = {
id: nextId,
title: input.title,
done: false,
};
nextId += 1;
todos.push(todo);
return c.json(todo, 201);
},
);
return app;
}
createApp()を呼ぶたびに、空の配列とnextIdが新しく作られます。これにより、テスト同士でTodoが混ざりません。
zValidatorは、Content-Type: application/jsonのリクエスト本文をZodで検証します。検証に失敗した場合はルート処理へ進まず、HTTP 400を返します。
3. Node.jsサーバーを起動できるようにする
src/server.tsを作ります。
import { serve } from "@hono/node-server";
import { createApp } from "./app";
const app = createApp();
serve(
{
fetch: app.fetch,
port: 3000,
},
(info) => {
console.log(`Listening on http://localhost:${info.port}`);
},
);
Honoのアプリを、@hono/node-serverがNode.jsのHTTPサーバーへ接続します。
4. app.requestでテストする
src/app.test.tsを作ります。
import { describe, expect, test } from "vitest";
import { createApp } from "./app";
describe("Todo API", () => {
test("GET /todos returns an empty list", async () => {
const app = createApp();
const response = await app.request("/todos");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ todos: [] });
});
test("POST /todos creates a todo", async () => {
const app = createApp();
const response = await app.request("/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Honoを試す" }),
});
expect(response.status).toBe(201);
expect(await response.json()).toEqual({
id: 1,
title: "Honoを試す",
done: false,
});
});
test("POST /todos rejects a blank title", async () => {
const app = createApp();
const response = await app.request("/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: " " }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: "titleは1〜100文字で入力してください",
});
});
});
各テストでcreateApp()を呼ぶため、どのテストもTodoが0件の状態から始まります。
JSONを検証する時はContent-Type: application/jsonが必要です。付け忘れるとHonoがJSON本文として読み取れません。
5. 型とテストを確認する
npm run check
npm test
次のように3件すべてが成功すれば、サーバーを起動しなくてもルートと検証を確認できています。
Test Files 1 passed
Tests 3 passed
6. APIを実際に呼ぶ
開発サーバーを起動します。
npm run dev
別のターミナルで一覧を取得します。
curl http://localhost:3000/todos
最初は空です。
{"todos":[]}
Todoを追加します。
curl \
--request POST \
--header "Content-Type: application/json" \
--data '{"title":"Honoを学ぶ"}' \
http://localhost:3000/todos
{"id":1,"title":"Honoを学ぶ","done":false}
もう一度GETすると、追加したTodoが一覧に含まれます。
curl http://localhost:3000/todos
確認後は、サーバーを起動したターミナルでCtrl+Cを押して停止します。
成功確認
次の4点を確認できれば完了です。
npm run checkが成功する- GET、正常POST、不正POSTの3テストが成功する
- curlのPOSTがHTTP 201相当のTodoを返す
- その後のGETに追加したTodoが含まれる
Todoはメモリ内の配列だけに保存しています。サーバーを再起動すると消えるため、本番用の永続化ではありません。
よくあるつまずき
Cannot find packageと表示される
記事のプロジェクトフォルダーで依存関係を入れたか確認します。
npm install
npm list hono @hono/node-server @hono/zod-validator zod
POSTが400になる
Content-Type: application/jsonを付けたか- JSONのプロパティ名が
titleか titleが空白だけ、または100文字超になっていないか
EADDRINUSEと表示される
ポート3000を別のプロセスが使っています。以前起動した開発サーバーをCtrl+Cで停止してください。
テスト結果が順番によって変わる
共有されたアプリを全テストで使い回すと、メモリ内Todoが残ります。各テスト内でcreateApp()を呼び、状態を分離してください。
curlの文字列をPowerShellで実行できない
PowerShellではクォートとcurlの扱いが異なります。まずnpm testでAPIを確認するか、Invoke-RestMethodを使ってください。
練習
doneもPOSTで受け取れるようにするのではなく、まずGETのテストを1つ追加してください。
- fresh appへ正常なPOSTを1回送る
- 同じappへGETを送る
- GETの配列に1件あることを検証する
同じシナリオ内では同じapp、別のテストではfresh appを使うのがポイントです。
次のステップ
次は、TodoをIDで取得するGET /todos/:idと404のテストを追加すると、パスパラメータとエラー応答を学べます。
データベース永続化、認証、アップロード、RPC、Cloudflare Workersへのデプロイは、入力検証とテストを保ったまま別の記事で1つずつ追加してください。
参考リソース
- Hono公式: Node.js
- Hono公式: Validation
- Hono公式: Testing
- npm: hono
- npm: @hono/node-server
- npm: @hono/zod-validator