Hono(ほの)は、Web 標準の Request/Response の上に作られた、小さな Web フレームワークです。 同じコードが Cloudflare Workers・Deno・Bun・Node.js などで動き、Node.js では @hono/node-server というアダプターを通して動かします。

npm i hono @hono/node-server
// src/index.ts
import { Hono } from 'hono'
import { serve } from '@hono/node-server'

const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Listening on http://localhost:${info.port}`)
})
$ npx tsx src/index.ts
Listening on http://localhost:3000
$ curl localhost:3000/
Hello Hono!
やりたいこと Hono の書き方
ルート app.get('/users/:id', (c) => ...)
パスの値・クエリ c.req.param('id')・c.req.query('q')
JSON を返す c.json(data, 201)
入力チェック zValidator('json', schema) → c.req.valid('json')
ルートをまとめる app.route('/api/todos', todoRoutes)
CORS app.use('/api/*', cors({ origin: [...] }))
JWT 認証 app.use('/api/me', jwt({ secret, alg: 'HS256' }))
型付きクライアント hc<AppType>('http://localhost:3000')

確認環境:hono 4.13.13・@hono/node-server 2.1.4・@hono/zod-validator 0.9.1・zod 4.6.7・tsx 4.23.15・TypeScript 7.0.2・Node.js 22.22.2(Linux)。本文の応答・ヘッダー・型エラーは、この環境で curl と tsc を実行して得たものです。オプションの既定値は Hono 公式ドキュメントで確かめました(確認日 2026年10月13日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Hono入門|Node.jsでルーティング・zod検証・JWT・RPC」

プロジェクトを作る

公式の雛形は npm create hono@latest my-app で作れます(テンプレートは nodejs を選ぶ)。この記事では、中身が分かるように手で作りました。

mkdir hono-api && cd hono-api
npm init -y && npm pkg set type=module
npm i hono @hono/node-server @hono/zod-validator zod
npm i -D tsx typescript @types/node
パッケージ 役割
hono 本体(ルーター・ミドルウェア・JWT・RPC クライアントを含む)
@hono/node-server Node.js の HTTP サーバーにつなぐアダプター
@hono/zod-validator zod のスキーマで入力をチェックするミドルウェア
tsx TypeScript をそのまま実行する(開発用)

@hono/node-server 2.1.4 の package.json では engines が node >=20 でした。公式ドキュメントの Node.js ページにある対応版(18.14.1 以上など)より新しい版が求められています。

TypeScript 7 では tsconfig.json に "types": ["node"] が要りました。 書かないと process が見つからず、error TS2591: Cannot find name 'process'. になりました。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  }
}

TypeScript を Node.js で動かす方法の比較は、関連記事「TypeScriptをNode.jsで実行|tsx・ts-node・型ストリップ」で扱っています。

完成コード:Todo API

ルーティング・バリデーション・CORS・JWT・エラー処理を1つにまとめたものです。

// src/app.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { jwt, sign } from 'hono/jwt'
import type { JwtVariables } from 'hono/jwt'
import { HTTPException } from 'hono/http-exception'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-me'

type Todo = { id: number; title: string; done: boolean }
const todos: Todo[] = [{ id: 1, title: '牛乳を買う', done: false }]
let nextId = 2

const createSchema = z.object({
  title: z.string().min(1).max(100),
  done: z.boolean().optional(),
})
const idParam = z.object({ id: z.coerce.number().int().positive() })

// /api/todos 以下のルート(メソッドをつなげて書くと RPC の型が付く)
const todoRoutes = new Hono()
  .get('/', zValidator('query', z.object({ done: z.enum(['true', 'false']).optional() })), (c) => {
    const { done } = c.req.valid('query')
    const list = done === undefined ? todos : todos.filter((t) => String(t.done) === done)
    return c.json(list)
  })
  .get('/:id', zValidator('param', idParam), (c) => {
    const { id } = c.req.valid('param')
    const todo = todos.find((t) => t.id === id)
    if (!todo) return c.json({ message: 'not found' }, 404)
    return c.json(todo)
  })
  .post('/', zValidator('json', createSchema), (c) => {
    const body = c.req.valid('json')
    const todo = { id: nextId++, title: body.title, done: body.done ?? false }
    todos.push(todo)
    return c.json(todo, 201)
  })
  .delete('/:id', zValidator('param', idParam), (c) => {
    const i = todos.findIndex((t) => t.id === c.req.valid('param').id)
    if (i < 0) throw new HTTPException(404, { message: 'todo not found' })
    todos.splice(i, 1)
    return c.body(null, 204)
  })

const app = new Hono<{ Variables: JwtVariables }>()

app.use(logger())
app.use('/api/*', cors({
  origin: ['http://localhost:5173'],
  allowHeaders: ['Content-Type', 'Authorization'],
  maxAge: 600,
}))

app.get('/', (c) => c.text('Hello Hono!'))

app.post('/login', async (c) => {
  const { user } = await c.req.json<{ user: string }>()
  // 例のため誰でもトークンを出す。本番はパスワード等を確かめてから
  const token = await sign({ sub: user, exp: Math.floor(Date.now() / 1000) + 60 * 60 }, SECRET, 'HS256')
  return c.json({ token })
})

app.use('/api/me', jwt({ secret: SECRET, alg: 'HS256' }))
app.get('/api/me', (c) => c.json({ payload: c.get('jwtPayload') }))

const routes = app.route('/api/todos', todoRoutes)

app.notFound((c) => c.json({ message: `${c.req.path} は見つかりません` }, 404))
app.onError((err, c) => {
  if (err instanceof HTTPException) return err.getResponse()
  console.error(err)
  return c.json({ message: 'internal error' }, 500)
})

export type AppType = typeof routes
export default app
// src/index.ts
import { serve } from '@hono/node-server'
import app from './app.js'

const server = serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }, (info) => {
  console.log(`Listening on http://localhost:${info.port}`)
})
// 止めるときに接続を閉じる(公式ドキュメントの graceful shutdown)
process.on('SIGINT', () => { server.close(); process.exit(0) })
process.on('SIGTERM', () => { server.close((err) => process.exit(err ? 1 : 0)) })
npx tsx src/index.ts          # 開発中は npx tsx watch src/index.ts

ルーティングを curl で確かめる

$ curl -i localhost:3000/api/todos
HTTP/1.1 200 OK
content-type: application/json
[{"id":1,"title":"牛乳を買う","done":false}]

$ curl -i -X POST localhost:3000/api/todos -H 'Content-Type: application/json' -d '{"title":"本を返す"}'
HTTP/1.1 201 Created
{"id":2,"title":"本を返す","done":false}

$ curl -i -X DELETE localhost:3000/api/todos/2
HTTP/1.1 204 No Content
リクエスト ステータス 応答
GET /api/todos?done=true 200 []
GET /api/todos/1 200 {"id":1,"title":"牛乳を買う","done":false}
GET /api/todos/99 404 {"message":"not found"}
DELETE /api/todos/2(2回目) 404 todo not found(HTTPException の message が本文)
GET /nothing 404 {"message":"/nothing は見つかりません"}(notFound)
ハンドラで throw new Error('boom') 500 {"message":"internal error"}(onError)

logger() を入れると、サーバー側に次のように出ます。

<-- POST /login
--> POST /login 200 11ms
<-- GET /api/me
--> GET /api/me 401 1ms

パスの値は、ルートの書き方で絞り込めます。 /:id{[0-9]+} のように正規表現を書くと、数字以外は 404 になりました。ただし、後で出てくる RPC クライアントでは、キーが ':id{[0-9]+}' になって client.api.todos[':id'] が型エラーになりました。完成コードでは、/:id にして zod で数値に変換しています。

ルートの書き方 マッチする例
/users/:id /users/1・/users/abc
/users/:id{[0-9]+} /users/1(数字だけ)
/posts/:id? /posts・/posts/1(省略できる)
/files/* /files/a/b/c(ワイルドカード)
app.on(['PUT', 'PATCH'], '/x', ...) 複数のメソッド

ミドルウェアの順番

ミドルウェアは await next() の前と後で処理を書けます。 use した順に「前」が走り、ハンドラのあと逆の順で「後」が走りました。

import { createMiddleware } from 'hono/factory'

const timing = createMiddleware(async (c, next) => {
  const start = performance.now()
  await next()
  c.header('X-Response-Time', `${(performance.now() - start).toFixed(1)}ms`)
})

const app = new Hono()
  .use(timing)
  .use(async (c, next) => { order.push('A前'); await next(); order.push('A後') })
  .use(async (c, next) => { order.push('B前'); await next(); order.push('B後') })
  .get('/', (c) => { order.push('ハンドラ'); return c.text('ok') })
200 1.3ms A前 → B前 → ハンドラ → B後 → A後

app.request('/') を使うと、サーバーを起動せずにアプリへリクエストを送れます。上の結果もこの方法で得ました。テストにもそのまま使えます。

よく使う組み込みミドルウェアです(hono/○○ から import)。

ミドルウェア import 元 用途
logger hono/logger アクセスログ
cors hono/cors CORS のヘッダー
jwt hono/jwt JWT の検証
basicAuth / bearerAuth hono/basic-auth / hono/bearer-auth 簡単な認証
secureHeaders hono/secure-headers セキュリティ系のヘッダー
bodyLimit hono/body-limit 本文のサイズ上限
timeout hono/timeout 処理時間の上限
serveStatic @hono/node-server/serve-static 静的ファイル(Node.js 用)

バリデーション(zod)

zValidator(対象, スキーマ) をハンドラの前に置くと、通った値だけが c.req.valid(対象) で型付きで取れます。 対象は json・form・query・param・header・cookie です。

既定のエラー応答

チェックに通らないと 400 で、zod のエラーがそのまま返りました。

$ curl -X POST localhost:3000/api/todos -H 'Content-Type: application/json' -d '{"title":"","done":"yes"}'
{"success":false,"error":{"name":"ZodError","message":"[\n  {\n    \"origin\": \"string\",\n    \"code\": \"too_small\", ... \"path\": [\"title\"], \"message\": \"Too small: expected string to have >=1 characters\" ...
送ったもの ステータス 中身
{"title":"","done":"yes"} 400 too_small(title)と invalid_type(done)
GET /api/todos/abc 400 Invalid input: expected number, received NaN
GET /api/todos?done=maybe 400 Invalid option: expected one of "true"|"false"
{bad(壊れた JSON) 400 Malformed JSON in request body
Content-Type 無しで -d 'title=x' 400 title が received undefined

最後の行に注意します。 公式ドキュメントにも「json や form を検証するときは、合った content-type ヘッダーが必要。無いと本文は読まれず、空のオブジェクト {} になる」とあります。curl で -H 'Content-Type: application/json' を忘れると、中身を送っていても「title が無い」というエラーになりました。

エラーの形を自分で決める(hook)

3つ目の引数に関数を渡すと、エラー時の応答を変えられます。

app.post(
  '/todos',
  zValidator('json', createSchema, (result, c) => {
    if (!result.success) {
      return c.json({
        message: '入力エラー',
        issues: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
      }, 422)
    }
  }),
  (c) => c.json(c.req.valid('json'), 201),
)
422 {"message":"入力エラー","issues":[{"path":"title","message":"title は1文字以上"},{"path":"done","message":"Invalid input: expected boolean, received string"}]}

zod 自体の書き方は、関連記事「Zodの使い方|v4対応のAPI早見表とエラー出力つき実例3つ」で扱っています。

CORS

cors() はルートより前に書きます(公式ドキュメントの注意)。origin に許可するオリジンを並べると、それ以外のオリジンには Access-Control-Allow-Origin が付きませんでした。

$ curl -i -X OPTIONS localhost:3000/api/todos -H 'Origin: http://localhost:5173' \
    -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type'
HTTP/1.1 204 No Content
access-control-allow-headers: Content-Type,Authorization
access-control-allow-methods: GET,HEAD,PUT,POST,DELETE,PATCH,QUERY
access-control-allow-origin: http://localhost:5173
access-control-max-age: 600
vary: Origin, Access-Control-Request-Headers
リクエストの Origin ステータス access-control-allow-origin
http://localhost:5173(許可) 200 http://localhost:5173
http://evil.example(許可していない) 200 付かない(ブラウザが読み取りを止める)
オプション 既定値(公式ドキュメント)
origin *
allowMethods GET, HEAD, PUT, POST, DELETE, PATCH, QUERY
allowHeaders []
exposeHeaders []
maxAge / credentials 指定なし

サーバーは許可していないオリジンにも 200 で中身を返します。 CORS はブラウザが読み取りを止める仕組みで、アクセス制御の代わりにはなりません。CORS エラーの見分け方は、関連記事「CORSエラーの原因と解決方法|エラーメッセージ別に解説」で扱っています。

JWT 認証

hono/jwt に、トークンを作る sign と、検証するミドルウェア jwt が入っています。 検証に通ると、中身(payload)が c.get('jwtPayload') で取れます。

$ TOKEN=$(curl -s -X POST localhost:3000/login -H 'Content-Type: application/json' -d '{"user":"takahira"}' | jq -r .token)
$ curl localhost:3000/api/me -H "Authorization: Bearer $TOKEN"
{"payload":{"sub":"takahira","exp":1791878043}}

失敗したときは、どれも本文は Unauthorized(401)で、理由は WWW-Authenticate ヘッダーに出ました。

送ったもの WWW-Authenticate の中身
ヘッダー無し invalid_request / no authorization included in request
Bearer を付けずにトークンだけ invalid_request / invalid credentials structure
署名を書き換えたトークン invalid_token / token verification failure
期限切れ(exp が過去) invalid_token / token verification failure

alg は必須です。 jwt({ secret }) と alg を省くと、JWT auth middleware requires options for "alg" という例外になりました。公式ドキュメントのオプション表でも alg は required です。

オプション 内容(公式ドキュメント)
secret 署名の鍵(必須)
alg 検証するアルゴリズム(必須)。HS256〜EdDSA
headerName トークンを探すヘッダー。既定は Authorization
cookie Cookie から読むときのキー名
verification iss・aud の確認、exp・nbf・iat の確認(既定で有効)

JWT の仕組みと注意点は、関連記事「JWTとは|仕組みと、安全に使うための注意点」で扱っています。

RPC クライアント(hc)

サーバーのルートの型を export type AppType で書き出すと、クライアント hc がパス・入力・戻り値の型を知った状態で呼べます。 OpenAPI のコード生成を挟まずに、型が共有されます。

// src/client.ts
import { hc } from 'hono/client'
import type { AppType } from './app.js'

const client = hc<AppType>('http://localhost:3000')

const res = await client.api.todos.$post({ json: { title: 'RPCで追加' } })
if (res.ok) {
  const todo = await res.json()   // { id: number; title: string; done: boolean }
  console.log('created:', todo.id, todo.title)
}
const list = await client.api.todos.$get({ query: { done: 'false' } })
const one = await client.api.todos[':id'].$get({ param: { id: '1' } })
console.log(client.api.todos[':id'].$url({ param: { id: '1' } }).href)
created: 2 RPCで追加
status: 200 { id: 1, title: '牛乳を買う', done: false }
url: http://localhost:3000/api/todos/1

打ち間違いは tsc で見つかりました。

await client.api.todos.$post({ json: { titel: 'typo' } })
error TS2561: Object literal may only specify known properties, but 'titel' does not exist in type '{ title: string; done?: boolean | undefined; }'. Did you mean to write 'title'?

res.ok や res.status で絞り込んでから中身を使います。 zod-validator の既定のエラー応答も型に入るため、絞り込まずに data[0] を読むと、{ id: number; ... }[] | ZodSafeParseError<...> の型になって error TS7053 になりました。

RPC でつまずいた点です。

症状 原因 直し方
client.api や .index が無いと言われる AppType がどのパスから始まるかとずれている app.route() の戻り値の型を書き出し、ベース URL と合わせる
型が付かない ルートを app.get(...) と別々の文で書いた new Hono().get(...).post(...) とつなげて書く
[':id'] が型エラー ルートを /:id{[0-9]+} と書いた /:id にして validator で変換
Type instantiation is excessively deep サーバーとクライアントの Hono の版が違う(公式ドキュメント) 同じ版にそろえる

パスやクエリの値は、数値でも文字列で渡します({ id: '1' })。公式ドキュメントにもそう書かれていて、サーバー側の validator で数値にします。

Express との対応表

やること Express 5 Hono 4
アプリを作る const app = express() const app = new Hono()
JSON を読む app.use(express.json()) → req.body await c.req.json() か c.req.valid('json')
パスの値 req.params.id c.req.param('id')
クエリ req.query.q c.req.query('q')
ヘッダー req.get('x-a') c.req.header('x-a')
JSON を返す res.status(201).json(data) return c.json(data, 201)
テキストを返す res.send('ok') return c.text('ok')
ミドルウェア (req, res, next) => { next() } async (c, next) => { await next() }
ルートをまとめる express.Router() + app.use('/api', r) new Hono() + app.route('/api', r)
404 最後に app.use((req, res) => ...) app.notFound((c) => ...)
エラー処理 引数4つの (err, req, res, next) app.onError((err, c) => ...)
起動 app.listen(3000) serve({ fetch: app.fetch, port: 3000 })
テスト supertest など await app.request('/path')

一番の違いは、ハンドラが値(Response)を return することです。 Express は res に書き込み、Hono は c.json() などが作った Response を返します。

npm i したときの大きさも比べました(同じ Node.js 22.22.2・npm 10.9.7)。

入れたもの 入ったパッケージ数 node_modules
hono + @hono/node-server 2 3.1 MB
express(5.3.0) 65 3.8 MB

Express の書き方は、関連記事「Node.js Express入門|Express5対応の最小APIをコピペで作る」で扱っています。

よくある質問

Q. c.req.json() と zValidator('json', ...) のどちらを使いますか。 A. c.req.json() は中身を確かめずに返します。型を c.req.json<{ user: string }>() と書いても、実際の値は確かめていません。外から来る値は validator を通すと、型と値がそろいます。

Q. 本番ではどう動かしますか。 A. 公式ドキュメントの Node.js ページでは、build でビルドして node dist/index.js で動かす形と、node:22-alpine を使う Dockerfile の例が紹介されています。SIGTERM で server.close() を呼ぶ graceful shutdown も同じページにあります。

Q. ポートがすでに使われていたら? A. 同じポートで2つ目を起動すると、Error: listen EADDRINUSE: address already in use 0.0.0.0:3181 で止まりました。PORT 環境変数でポートを変えられるようにしておくと楽です。

Q. Express のミドルウェアはそのまま使えますか。 A. 形(引数)が違うので、そのままは使えません。Hono の組み込みか、@hono/ で始まるサードパーティのミドルウェアを使います。

まとめ

  • Hono は Web 標準の Request/Response で書くフレームワーク。Node.js では @hono/node-server の serve() で動かす
  • 入力は zValidator → c.req.valid()。json は Content-Type が無いと {} 扱い
  • cors() はルートより前。許可していないオリジンにはヘッダーが付かないだけ
  • jwt() は alg が必須。失敗理由は WWW-Authenticate に出る
  • RPC は ルートをつなげて書き、AppType を export。res.ok で絞り込んでから使う

参考・出典

確認日はいずれも 2026年10月13日です。

  • Hono ドキュメント「Node.js」:https://hono.dev/docs/getting-started/nodejs
  • Hono ドキュメント「Routing」:https://hono.dev/docs/api/routing
  • Hono ドキュメント「Validation」:https://hono.dev/docs/guides/validation
  • Hono ドキュメント「CORS Middleware」:https://hono.dev/docs/middleware/builtin/cors
  • Hono ドキュメント「JWT Auth Middleware」:https://hono.dev/docs/middleware/builtin/jwt
  • Hono ドキュメント「RPC」:https://hono.dev/docs/guides/rpc
  • @hono/zod-validator(npm):https://www.npmjs.com/package/@hono/zod-validator
  • @hono/node-server(GitHub):https://github.com/honojs/node-server

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Hono入門|Node.jsでルーティング・zod検証・JWT・RPC」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?