※本記事には広告(楽天アフィリエイト)リンクが含まれる場合があります。
「Cloudflare WorkersでExpressが動かない」「Bunでシンプルなサーバーを立てたい」「Node.js・Deno・Bunを横断できる統一フレームワークはないか」——そんな悩みを一気に解決するのが Hono.js です。
Honoは日本語で「炎」を意味する超軽量Webフレームワークで、2023年以降急速に普及し2026年現在、Edge/サーバーレス環境でのAPI開発において最有力の選択肢のひとつになっています。コアサイズわずか約14KB、ゼロ依存で、Cloudflare Workers・Bun・Deno・Node.js・AWS Lambda Edge・Vercelなど主要ランタイムすべてに対応します。
本記事では、Honoのインストールから実践的なルーティング・ミドルウェア・型安全RPC・JWT認証・テストまで、すぐに使い始められる実例を交えて徹底解説します。
Honoとは何か?なぜ今注目されているのか
HonoはYusuke Wada氏が開発した日本発のWebフレームワークで、Web標準API(Fetch API・Request/Response)をベースに設計されています。そのため、Node.js固有のAPIに依存せず、あらゆるランタイムで動作します。
Honoが解決する問題
従来のNode.js向けフレームワーク(Express・Fastify)には、Edge環境での大きな壁がありました。
- ExpressはCloudflare Workersで動かない:Node.js固有APIを多用するため
- ランタイムごとに書き直しが必要:Bun用・Deno用・CF Workers用でコードが分散
- 型安全性の欠如:ExpressはTypeScriptとの親和性が低い
- バンドルサイズが大きい:Edgeの制限(1MB未満)に収まりにくい
Honoはこれらをすべてクリアし、「1つのコードで複数のランタイムにデプロイ」を実現します。
主要フレームワーク比較
| フレームワーク | バンドルサイズ | CF Workers対応 | TypeScript親和性 | RPCサポート |
|---|---|---|---|---|
| Express | 〜200KB+ | × | △(型定義が弱い) | × |
| Fastify | 〜100KB+ | △(adapter必要) | ○ | × |
| Hono | 〜14KB | ◎(ネイティブ対応) | ◎(型推論完備) | ◎(Hono RPC) |
| Elysia(Bun専用) | 〜20KB | × | ◎ | ◎(Eden) |
インストールと最初のサーバー
ランタイム別にインストール方法が異なります。まずは最もシンプルなBun環境で試してみましょう。
Bun環境での導入
bun create hono my-app
cd my-app
bun run dev
または手動で:
mkdir my-app && cd my-app
bun init -y
bun add hono
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))
export default app
// package.json(Bun用)
{
"scripts": {
"dev": "bun --hot src/index.ts"
}
}
Node.js環境での導入
npm create hono@latest my-app
# ランタイム選択: nodejs を選ぶ
cd my-app
npm install
npm run dev
// src/index.ts(Node.js用)
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono on Node.js!'))
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Listening on http://localhost:${info.port}`)
})
Cloudflare Workers環境での導入
npm create hono@latest my-worker
# ランタイム選択: cloudflare-workers を選ぶ
cd my-worker
npm install
npm run dev # wrangler dev が起動
// src/index.ts(Cloudflare Workers用)
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello from Cloudflare Workers!'))
export default app // Workersはexport defaultするだけ
ルーティングの基本
HonoのルーティングAPIはExpressに似ていますが、完全な型推論が効きます。
HTTPメソッドとパスパラメータ
import { Hono } from 'hono'
const app = new Hono()
// 基本的なGET
app.get('/hello', (c) => c.text('Hello!'))
// パスパラメータ(型安全)
app.get('/users/:id', (c) => {
const id = c.req.param('id') // string型として推論
return c.json({ userId: id })
})
// クエリパラメータ
app.get('/search', (c) => {
const q = c.req.query('q') ?? ''
const page = Number(c.req.query('page') ?? 1)
return c.json({ query: q, page })
})
// POST + JSONボディ
app.post('/users', async (c) => {
const body = await c.req.json()
return c.json({ created: body }, 201)
})
// PUT / DELETE
app.put('/users/:id', async (c) => {
const id = c.req.param('id')
const body = await c.req.json()
return c.json({ id, ...body })
})
app.delete('/users/:id', (c) => {
return c.json({ deleted: c.req.param('id') })
})
ルートグループとネスト
import { Hono } from 'hono'
const api = new Hono().basePath('/api')
// /api/users 配下をまとめる
const users = new Hono()
users.get('/', (c) => c.json([{ id: 1, name: 'Alice' }]))
users.post('/', async (c) => c.json(await c.req.json(), 201))
users.get('/:id', (c) => c.json({ id: c.req.param('id') }))
// /api/posts 配下
const posts = new Hono()
posts.get('/', (c) => c.json([]))
api.route('/users', users)
api.route('/posts', posts)
export default api
ワイルドカードと正規表現
// ワイルドカード
app.get('/files/*', (c) => {
const path = c.req.param('*')
return c.text(`File: ${path}`)
})
// 正規表現(:param{regex})
app.get('/items/:id{[0-9]+}', (c) => {
return c.json({ id: Number(c.req.param('id')) })
})
ミドルウェア
Honoはビルトインミドルウェアを豊富に持ち、npm installなしで即座に使えます。
ビルトインミドルウェア一覧
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { compress } from 'hono/compress'
import { etag } from 'hono/etag'
import { secureHeaders } from 'hono/secure-headers'
import { prettyJSON } from 'hono/pretty-json'
import { timing } from 'hono/timing'
const app = new Hono()
// リクエストログ
app.use(logger())
// CORS(詳細設定も可)
app.use('/api/*', cors({
origin: ['https://example.com', 'http://localhost:3000'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
}))
// Gzip圧縮(Node.js / Bun環境)
app.use(compress())
// ETagキャッシュ
app.use(etag())
// セキュリティヘッダー自動付与
app.use(secureHeaders())
// JSON整形表示(?pretty=1 で有効)
app.use(prettyJSON())
// Server-Timingヘッダー
app.use(timing())
カスタムミドルウェア
// 認証チェックミドルウェア
app.use('/api/*', async (c, next) => {
const auth = c.req.header('Authorization')
if (!auth || !auth.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401)
}
// next() を呼ばないとハンドラーへ進まない
await next()
})
// レスポンスに共通ヘッダーを付与するミドルウェア
app.use('*', async (c, next) => {
await next()
c.res.headers.set('X-Powered-By', 'Hono')
})
エラーハンドリング
import { HTTPException } from 'hono/http-exception'
// カスタム例外をスロー
app.get('/protected', (c) => {
throw new HTTPException(403, { message: 'Forbidden' })
})
// グローバルエラーハンドラー
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse()
}
console.error(err)
return c.json({ error: 'Internal Server Error' }, 500)
})
// 404ハンドラー
app.notFound((c) => {
return c.json({ error: 'Not Found' }, 404)
})
バリデーション(Zod validator)
HonoはZodと組み合わせると、リクエストの型安全バリデーションが完結します。
npm install @hono/zod-validator zod
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
})
app.post(
'/users',
zValidator('json', createUserSchema),
async (c) => {
const body = c.req.valid('json')
// body は { name: string; email: string; age?: number } として型推論
return c.json({ created: body }, 201)
}
)
// クエリパラメータのバリデーション
const searchSchema = z.object({
q: z.string().min(1),
page: z.coerce.number().int().positive().default(1),
})
app.get(
'/search',
zValidator('query', searchSchema),
(c) => {
const { q, page } = c.req.valid('query')
return c.json({ results: [], query: q, page })
}
)
バリデーションエラーは自動で 400 Bad Request + エラー詳細JSONとして返されます。
Hono RPC:型安全なフルスタック開発
Honoの最大の差別化機能が Hono RPC です。バックエンドで定義したルートの型をフロントエンドに共有し、tRPCのように型安全なAPIクライアントを自動生成できます。
サーバー側:型をエクスポートする
// server/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const users = new Hono()
.get('/', (c) => {
return c.json([{ id: 1, name: 'Alice' }])
})
.post(
'/',
zValidator('json', z.object({ name: z.string(), email: z.string().email() })),
async (c) => {
const body = c.req.valid('json')
return c.json({ id: 2, ...body }, 201)
}
)
.get('/:id', (c) => {
return c.json({ id: c.req.param('id'), name: 'Alice' })
})
export default users
export type UsersRoute = typeof users
// server/index.ts
import { Hono } from 'hono'
import users from './routes/users'
const app = new Hono().route('/users', users)
export default app
export type AppType = typeof app
クライアント側:型安全なHTTPクライアント
// client/api.ts
import { hc } from 'hono/client'
import type { AppType } from '../server/index'
const client = hc<AppType>('http://localhost:3000')
// 型補完が効く!
const res = await client.users.$get()
const users = await res.json()
// users: { id: number; name: string }[]
const created = await client.users.$post({
json: { name: 'Bob', email: 'bob@example.com' }
})
const newUser = await created.json()
// newUser: { id: number; name: string; email: string }
tRPCと違い、実際のHTTPリクエストを送るため、バックエンドとフロントエンドを別々にデプロイしても動作します。
JWT認証の実装
HonoはJWT認証ミドルウェアをビルトインで持っています。
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import { sign } from 'hono/jwt'
const app = new Hono()
const JWT_SECRET = 'your-secret-key'
// ログインエンドポイント(トークン発行)
app.post('/auth/login', async (c) => {
const { username, password } = await c.req.json()
// 実際はDBで検証する
if (username !== 'admin' || password !== 'password') {
return c.json({ error: 'Invalid credentials' }, 401)
}
const payload = {
sub: 'user-id-1',
username,
role: 'admin',
exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1時間
}
const token = await sign(payload, JWT_SECRET)
return c.json({ token })
})
// 保護されたルート
app.use('/api/*', jwt({ secret: JWT_SECRET }))
app.get('/api/profile', (c) => {
const payload = c.get('jwtPayload')
return c.json({ userId: payload.sub, username: payload.username })
})
app.get('/api/admin', (c) => {
const payload = c.get('jwtPayload')
if (payload.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403)
}
return c.json({ message: 'Admin area' })
})
Cloudflare Workersへのデプロイ
HonoアプリをCloudflare Workersにデプロイするのは非常に簡単です。
セットアップ
npm create hono@latest my-worker -- --template cloudflare-workers
cd my-worker
npm install
// src/index.ts
import { Hono } from 'hono'
type Bindings = {
DB: D1Database // Cloudflare D1
KV: KVNamespace // Cloudflare KV
BUCKET: R2Bucket // Cloudflare R2
API_KEY: string // 環境変数(シークレット)
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/', async (c) => {
// D1クエリ
const result = await c.env.DB.prepare('SELECT * FROM users LIMIT 10').all()
return c.json(result.results)
})
app.get('/kv/:key', async (c) => {
const value = await c.env.KV.get(c.req.param('key'))
return c.json({ value })
})
export default app
# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
[[kv_namespaces]]
binding = "KV"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# ローカル開発
npm run dev
# デプロイ
npm run deploy
Cloudflare Pages Functions との連携
// functions/api/[[path]].ts(Pages Functions)
import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'
const app = new Hono().basePath('/api')
app.get('/hello', (c) => c.json({ message: 'Hello from Pages Functions!' }))
export const onRequest = handle(app)
テスト
Honoはapp.request()メソッドで、実際のHTTPサーバーを立てずにユニットテストが書けます。
// src/index.test.ts(Vitest + Hono)
import { describe, it, expect } from 'vitest'
import app from './index'
describe('GET /', () => {
it('returns Hello Hono!', async () => {
const res = await app.request('/')
expect(res.status).toBe(200)
expect(await res.text()).toBe('Hello Hono!')
})
})
describe('POST /users', () => {
it('creates a user', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
})
expect(res.status).toBe(201)
const body = await res.json()
expect(body).toMatchObject({ name: 'Alice', email: 'alice@example.com' })
})
it('rejects invalid input', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }), // emailなし
})
expect(res.status).toBe(400)
})
})
describe('JWT Protected', () => {
it('rejects without token', async () => {
const res = await app.request('/api/profile')
expect(res.status).toBe(401)
})
})
# テスト実行
bun test
# または
npx vitest
実践:TODOアプリAPIを作る
これまでの内容を組み合わせて、認証付きTODO管理APIを実装してみます。
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { jwt, sign } from 'hono/jwt'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { HTTPException } from 'hono/http-exception'
type Variables = {
userId: string
}
const app = new Hono<{ Variables: Variables }>()
app.use(logger())
app.use(cors())
// メモリDBの代わり
const todos = new Map<string, { id: string; title: string; done: boolean; userId: string }>()
// ログイン
app.post(
'/auth/login',
zValidator('json', z.object({ username: z.string(), password: z.string() })),
async (c) => {
const { username, password } = c.req.valid('json')
if (username !== 'demo' || password !== 'demo1234') {
throw new HTTPException(401, { message: 'Invalid credentials' })
}
const token = await sign(
{ sub: 'user-1', exp: Math.floor(Date.now() / 1000) + 3600 },
'secret'
)
return c.json({ token })
}
)
// 認証ミドルウェア
app.use('/todos/*', jwt({ secret: 'secret' }))
app.use('/todos/*', async (c, next) => {
const payload = c.get('jwtPayload')
c.set('userId', payload.sub)
await next()
})
// TODO CRUD
app.get('/todos', (c) => {
const userId = c.get('userId')
const userTodos = [...todos.values()].filter((t) => t.userId === userId)
return c.json(userTodos)
})
app.post(
'/todos',
zValidator('json', z.object({ title: z.string().min(1) })),
(c) => {
const { title } = c.req.valid('json')
const userId = c.get('userId')
const id = crypto.randomUUID()
const todo = { id, title, done: false, userId }
todos.set(id, todo)
return c.json(todo, 201)
}
)
app.patch(
'/todos/:id',
zValidator('json', z.object({ done: z.boolean() })),
(c) => {
const id = c.req.param('id')
const todo = todos.get(id)
if (!todo || todo.userId !== c.get('userId')) {
throw new HTTPException(404, { message: 'Not found' })
}
const { done } = c.req.valid('json')
const updated = { ...todo, done }
todos.set(id, updated)
return c.json(updated)
}
)
app.delete('/todos/:id', (c) => {
const id = c.req.param('id')
const todo = todos.get(id)
if (!todo || todo.userId !== c.get('userId')) {
throw new HTTPException(404, { message: 'Not found' })
}
todos.delete(id)
return c.body(null, 204)
})
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse()
return c.json({ error: 'Internal Server Error' }, 500)
})
export default app
よく使うHonoのヘルパー
HTMLレスポンスとJSX
// JSXでHTMLを返す(SSR)
// tsconfig.json: "jsx": "react-jsx", "jsxImportSource": "hono/jsx"
import { Hono } from 'hono'
import { html } from 'hono/html'
const app = new Hono()
// テンプレートリテラル
app.get('/html', (c) => {
return c.html(html`<!DOCTYPE html>
<html>
<body><h1>Hello Hono HTML!</h1></body>
</html>`)
})
// JSXコンポーネント(hono/jsx)
import { jsx } from 'hono/jsx'
const Layout = ({ children }: { children: any }) => (
<html lang="ja">
<head><meta charset="UTF-8" /></head>
<body>{children}</body>
</html>
)
app.get('/jsx', (c) => {
return c.html(
<Layout>
<h1>Hello JSX!</h1>
</Layout>
)
})
ストリーミングレスポンス
import { stream, streamText, streamSSE } from 'hono/streaming'
// テキストストリーム(AIレスポンスに便利)
app.get('/stream', (c) => {
return streamText(c, async (stream) => {
for (const word of ['Hello', ' ', 'World', '!']) {
await stream.write(word)
await stream.sleep(100)
}
})
})
// SSE(Server-Sent Events)
app.get('/sse', (c) => {
return streamSSE(c, async (stream) => {
let count = 0
while (true) {
await stream.writeSSE({ data: `Count: ${count++}`, event: 'tick' })
await stream.sleep(1000)
}
})
})
パフォーマンス:Honoは本当に速いのか
Honoは「最速フレームワーク」を標榜していますが、実測値で見てみましょう。
| フレームワーク | ランタイム | RPS(req/s)目安 | レイテンシ |
|---|---|---|---|
| Express | Node.js | 〜50,000 | 低 |
| Fastify | Node.js | 〜80,000 | 低 |
| Hono | Node.js | 〜120,000 | 非常に低 |
| Hono | Bun | 〜200,000+ | 極めて低 |
Bunランタイムとの組み合わせでは、Node.js + Expressの約4倍のスループットが出ます。ただし実際のアプリではDBクエリやI/Oがボトルネックになるため、フレームワーク自体の速度差よりも設計や実装の最適化の方が重要です。
移行ガイド:ExpressからHonoへ
Expressから移行する際の対応表です。
| Express | Hono |
|---|---|
| req.params.id | c.req.param('id') |
| req.query.q | c.req.query('q') |
| req.body | await c.req.json() |
| req.headers['x-foo'] | c.req.header('x-foo') |
| res.json({ ... }) | return c.json({ ... }) |
| res.status(201).json() | return c.json({}, 201) |
| res.redirect('/path') | return c.redirect('/path') |
| app.use(middleware) | app.use(middleware) |
| router.use('/api', router2) | app.route('/api', app2) |
主な違いは レスポンスをreturnで返す こと、コンテキストオブジェクトcでreq/resが統一されていることです。
まとめ:Honoを使うべき場面
Hono.jsが特に輝く場面をまとめます。
- Cloudflare Workers / Pages Functions:ネイティブ対応で最も相性が良い
- Bun製のAPIサーバー:最速の組み合わせ
- マルチランタイム展開:同一コードをNode.js・Bun・Denoに展開したい場合
- 型安全フルスタック:Hono RPCでバックエンド型をフロントエンドに共有したい場合
- バンドルサイズが重要な環境:14KBという軽さはEdge環境で大きな強み
逆に、すでにExpressエコシステムに依存したライブラリを大量に使っている既存プロジェクトや、Node.js専用のストリームAPIを多用するケースでは移行コストが発生することも考慮しましょう。
Hono.jsは日本発のOSSでありながらグローバルで急速に普及しており、GitHubスターは2026年現在で5万以上に達しています。Cloudflare Workersを使う開発者にとってはほぼ必須のツールとなっており、学習コストの低さ・TypeScript親和性・ゼロ依存という特性から、新規プロジェクトでの採用を強くおすすめします。