Hono実践ガイド - 軽量・高速なエッジ対応Webフレームワーク

中級 | 35分 で読める | 2025.12.02

公式ドキュメント

今回やること

この記事では、Honoで小さなAPIを作り、ルーティング、バリデーション、テスト、エッジ環境での実行イメージを確認します。

Honoは軽量ですが、入力検証、認証、エラー処理を省略すると本番APIとしては危険です。

Honoとは

Honoは、日本発の軽量・高速なWebフレームワークです。Cloudflare Workers、Deno、Bun、Node.js、AWS Lambdaなど、あらゆるJavaScriptランタイムで動作します。

実践メモ: Honoはエッジランタイムに最適化されており、Cloudflare WorkersやDeno Deployで特に高いパフォーマンスを発揮します。

flowchart TB
 subgraph Core["Hono Core (~12KB)"]
 Router["Router<br/>(RegExpRouter, TrieRouter)"]
 MW["Middleware Chain"]
 Ctx["Context<br/>(c.req, c.res)"]
 end

 Core --> CF["Cloudflare Workers"]
 Core --> Deno["Deno Deploy"]
 Core --> Bun["Bun"]

 CF --> Node["Node.js"]
 Deno --> Vercel["Vercel"]
 Bun --> Lambda["AWS Lambda"]

特徴: Web標準API準拠、TypeScript First、超軽量

セットアップ

# 新規プロジェクト作成
npm create hono@latest my-app

# 既存プロジェクトへの追加
npm install hono

# アダプター(必要に応じて)
npm install @hono/node-server

基本的なアプリケーション

// src/index.ts
import { Hono } from 'hono';

const app = new Hono();

app.get('/', (c) => {
 return c.text('Hello Hono!');
});

app.get('/json', (c) => {
 return c.json({ message: 'Hello JSON!' });
});

export default app;

ランタイム別の起動方法

// Cloudflare Workers
export default app;

// Node.js
import { serve } from '@hono/node-server';
serve(app);

// Bun
export default { port: 3000, fetch: app.fetch };

// Deno
Deno.serve(app.fetch);

ルーティング

ポイント: HonoのルーターはRegExpRouter(高速)とTrieRouter(パターンマッチ豊富)を自動選択します。パフォーマンスはExpressの数倍です。

import { Hono } from 'hono';

const app = new Hono();

// 基本的なルート
app.get('/users', (c) => c.json({ users: [] }));
app.post('/users', (c) => c.json({ created: true }));
app.put('/users/:id', (c) => c.json({ updated: true }));
app.delete('/users/:id', (c) => c.json({ deleted: true }));

// パスパラメータ
app.get('/users/:id', (c) => {
 const id = c.req.param('id');
 return c.json({ id });
});

// 複数パラメータ
app.get('/posts/:postId/comments/:commentId', (c) => {
 const { postId, commentId } = c.req.param();
 return c.json({ postId, commentId });
});

// ワイルドカード
app.get('/files/*', (c) => {
 const path = c.req.param('*');
 return c.text(`File path: ${path}`);
});

// 正規表現パターン
app.get('/user/:id{[0-9]+}', (c) => {
 const id = c.req.param('id');
 return c.json({ id: parseInt(id) });
});

// オプショナルパラメータ
app.get('/posts/:id?', (c) => {
 const id = c.req.param('id');
 if (id) {
 return c.json({ post: { id } });
 }
 return c.json({ posts: [] });
});

ルートグループ

import { Hono } from 'hono';

const app = new Hono();

// APIグループ
const api = new Hono();

api.get('/users', (c) => c.json({ users: [] }));
api.get('/users/:id', (c) => c.json({ user: {} }));
api.post('/users', (c) => c.json({ created: true }));

// プレフィックス付きでマウント
app.route('/api', api);

// ネストしたルート
const v1 = new Hono();
const v2 = new Hono();

v1.get('/users', (c) => c.json({ version: 1 }));
v2.get('/users', (c) => c.json({ version: 2 }));

app.route('/api/v1', v1);
app.route('/api/v2', v2);

ミドルウェア

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 { timing } from 'hono/timing';
import { prettyJSON } from 'hono/pretty-json';

const app = new Hono();

// 組み込みミドルウェア
app.use('*', logger());
app.use('*', cors());
app.use('*', compress());
app.use('*', etag());
app.use('*', secureHeaders());
app.use('*', timing());
app.use('*', prettyJSON());

// カスタムミドルウェア
app.use('*', async (c, next) => {
 const start = Date.now();
 await next();
 const duration = Date.now() - start;
 c.res.headers.set('X-Response-Time', `${duration}ms`);
});

// 特定パスにのみ適用
app.use('/api/*', async (c, next) => {
 console.log('API request:', c.req.url);
 await next();
});

認証ミドルウェア

import { Hono } from 'hono';
import { jwt } from 'hono/jwt';
import { bearerAuth } from 'hono/bearer-auth';
import { basicAuth } from 'hono/basic-auth';

const app = new Hono();

// JWT認証
app.use('/api/*', jwt({
 secret: process.env.JWT_SECRET!,
}));

app.get('/api/me', (c) => {
 const payload = c.get('jwtPayload');
 return c.json({ user: payload });
});

// Bearer Token認証
app.use('/admin/*', bearerAuth({
 token: process.env.ADMIN_TOKEN!,
}));

// Basic認証
app.use('/dashboard/*', basicAuth({
 username: 'admin',
 password: process.env.DASHBOARD_PASSWORD!,
}));

// カスタム認証
const authMiddleware = async (c: Context, next: Next) => {
 const token = c.req.header('Authorization')?.replace('Bearer ', '');

 if (!token) {
 return c.json({ error: 'Unauthorized' }, 401);
 }

 try {
 const user = await verifyToken(token);
 c.set('user', user);
 await next();
 } catch {
 return c.json({ error: 'Invalid token' }, 401);
 }
};

app.use('/api/*', authMiddleware);

注意: ミドルウェアの実行順序は登録順です。認証ミドルウェアはCORSの後、ルートハンドラーの前に配置しましょう。

バリデーション

Zodとの統合

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

const app = new Hono();

// リクエストボディのバリデーション
const createUserSchema = z.object({
 email: z.string().email(),
 name: z.string().min(2).max(100),
 age: z.number().int().positive().optional(),
});

app.post('/users', zValidator('json', createUserSchema), async (c) => {
 const data = c.req.valid('json');
 // data は型安全: { email: string, name: string, age?: number }
 return c.json({ user: data });
});

// クエリパラメータのバリデーション
const listUsersSchema = z.object({
 limit: z.coerce.number().int().min(1).max(100).default(10),
 offset: z.coerce.number().int().min(0).default(0),
 sort: z.enum(['name', 'created_at']).optional(),
});

app.get('/users', zValidator('query', listUsersSchema), async (c) => {
 const { limit, offset, sort } = c.req.valid('query');
 return c.json({ limit, offset, sort });
});

// パスパラメータのバリデーション
const userIdSchema = z.object({
 id: z.string().uuid(),
});

app.get('/users/:id', zValidator('param', userIdSchema), async (c) => {
 const { id } = c.req.valid('param');
 return c.json({ id });
});

// ヘッダーのバリデーション
const headerSchema = z.object({
 'x-api-key': z.string().min(32),
});

app.use('/api/*', zValidator('header', headerSchema));

カスタムバリデーションエラー

import { zValidator } from '@hono/zod-validator';

app.post(
 '/users',
 zValidator('json', createUserSchema, (result, c) => {
 if (!result.success) {
 return c.json(
 {
 error: {
 code: 'VALIDATION_ERROR',
 message: 'Invalid request body',
 details: result.error.issues.map((issue) => ({
 field: issue.path.join('.'),
 message: issue.message,
 })),
 },
 },
 400
 );
 }
 }),
 async (c) => {
 const data = c.req.valid('json');
 return c.json({ user: data });
 }
);

Hono RPC

型安全なクライアント-サーバー通信を実現します。

// server.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

const app = new Hono()
 .get('/users', async (c) => {
 const users = await db.users.findMany();
 return c.json({ users });
 })
 .get('/users/:id', async (c) => {
 const id = c.req.param('id');
 const user = await db.users.findById(id);
 return c.json({ user });
 })
 .post(
 '/users',
 zValidator('json', z.object({
 email: z.string().email(),
 name: z.string(),
 })),
 async (c) => {
 const data = c.req.valid('json');
 const user = await db.users.create(data);
 return c.json({ user }, 201);
 }
 );

export type AppType = typeof app;
export default app;
// client.ts
import { hc } from 'hono/client';
import type { AppType } from './server';

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

// 型安全なAPI呼び出し
async function main() {
 // GET /users
 const res1 = await client.users.$get();
 const data1 = await res1.json(); // 型推論される

 // GET /users/:id
 const res2 = await client.users[':id'].$get({
 param: { id: '123' },
 });
 const data2 = await res2.json();

 // POST /users
 const res3 = await client.users.$post({
 json: {
 email: 'user@example.com',
 name: 'John Doe',
 },
 });
 const data3 = await res3.json();
}

ファイルアップロード

import { Hono } from 'hono';

const app = new Hono();

app.post('/upload', async (c) => {
 const body = await c.req.parseBody();
 const file = body['file'];

 if (file instanceof File) {
 const buffer = await file.arrayBuffer();
 const filename = file.name;
 const contentType = file.type;
 const size = file.size;

 // S3やR2にアップロード
 await uploadToStorage(buffer, filename);

 return c.json({
 filename,
 contentType,
 size,
 url: `https://cdn.example.com/${filename}`,
 });
 }

 return c.json({ error: 'No file uploaded' }, 400);
});

// 複数ファイルアップロード
app.post('/upload-multiple', async (c) => {
 const body = await c.req.parseBody({ all: true });
 const files = body['files'];

 if (Array.isArray(files)) {
 const results = await Promise.all(
 files.map(async (file) => {
 if (file instanceof File) {
 const buffer = await file.arrayBuffer();
 await uploadToStorage(buffer, file.name);
 return { filename: file.name, size: file.size };
 }
 return null;
 })
 );

 return c.json({ uploaded: results.filter(Boolean) });
 }

 return c.json({ error: 'No files uploaded' }, 400);
});

エラーハンドリング

import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';

const app = new Hono();

// カスタムエラークラス
class NotFoundError extends HTTPException {
 constructor(resource: string) {
 super(404, { message: `${resource} not found` });
 }
}

class ValidationError extends HTTPException {
 constructor(details: unknown[]) {
 super(400, {
 message: 'Validation failed',
 cause: { details },
 });
 }
}

// エラーハンドラー
app.onError((err, c) => {
 console.error('Error:', err);

 if (err instanceof HTTPException) {
 return c.json(
 {
 error: {
 code: err.status === 404 ? 'NOT_FOUND' : 'ERROR',
 message: err.message,
 details: err.cause,
 },
 },
 err.status
 );
 }

 return c.json(
 {
 error: {
 code: 'INTERNAL_ERROR',
 message: 'An unexpected error occurred',
 },
 },
 500
 );
});

// 404ハンドラー
app.notFound((c) => {
 return c.json(
 {
 error: {
 code: 'NOT_FOUND',
 message: 'Route not found',
 },
 },
 404
 );
});

// 使用例
app.get('/users/:id', async (c) => {
 const user = await db.users.findById(c.req.param('id'));
 if (!user) {
 throw new NotFoundError('User');
 }
 return c.json({ user });
});

Cloudflare Workers連携

// src/index.ts
import { Hono } from 'hono';

type Bindings = {
 DB: D1Database;
 KV: KVNamespace;
 R2: R2Bucket;
 AI: Ai;
};

const app = new Hono<{ Bindings: Bindings }>();

// D1データベース
app.get('/users', async (c) => {
 const { results } = await c.env.DB.prepare(
 'SELECT * FROM users LIMIT 10'
 ).all();
 return c.json({ users: results });
});

// KVストレージ
app.get('/cache/:key', async (c) => {
 const key = c.req.param('key');
 const value = await c.env.KV.get(key);
 return c.json({ key, value });
});

app.put('/cache/:key', async (c) => {
 const key = c.req.param('key');
 const { value, ttl } = await c.req.json();
 await c.env.KV.put(key, value, { expirationTtl: ttl });
 return c.json({ success: true });
});

// R2オブジェクトストレージ
app.get('/files/:key', async (c) => {
 const key = c.req.param('key');
 const object = await c.env.R2.get(key);

 if (!object) {
 return c.json({ error: 'Not found' }, 404);
 }

 const headers = new Headers();
 object.writeHttpMetadata(headers);
 headers.set('etag', object.httpEtag);

 return new Response(object.body, { headers });
});

// Workers AI
app.post('/ai/summarize', async (c) => {
 const { text } = await c.req.json();

 const response = await c.env.AI.run('@cf/meta/llama-2-7b-chat-int8', {
 messages: [
 { role: 'system', content: 'Summarize the following text.' },
 { role: 'user', content: text },
 ],
 });

 return c.json({ summary: response.response });
});

export default app;

テスト

// src/index.test.ts
import { describe, it, expect } from 'vitest';
import app from './index';

describe('API Tests', () => {
 it('GET / returns Hello', async () => {
 const res = await app.request('/');
 expect(res.status).toBe(200);
 expect(await res.text()).toBe('Hello Hono!');
 });

 it('GET /users returns user list', async () => {
 const res = await app.request('/users');
 expect(res.status).toBe(200);

 const data = await res.json();
 expect(data).toHaveProperty('users');
 });

 it('POST /users creates a user', async () => {
 const res = await app.request('/users', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({
 email: 'test@example.com',
 name: 'Test User',
 }),
 });

 expect(res.status).toBe(201);
 const data = await res.json();
 expect(data.user.email).toBe('test@example.com');
 });

 it('POST /users returns 400 for invalid data', async () => {
 const res = await app.request('/users', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({
 email: 'invalid-email',
 }),
 });

 expect(res.status).toBe(400);
 });
});

動作確認

HonoのAPIを作ったら、次を確認します。

確認項目方法OKの状態
起動開発サーバーを起動エラーなくlistenする
GETcurl http://localhost:3000/期待したレスポンスが返る
POSTJSONを送信201または意図したstatusが返る
バリデーション不正JSONを送信400が返る
テストvitest runAPIテストが通る

エラー時にスタックトレースや秘密情報をレスポンスへ出していないかも確認します。

練習

次のTodo APIを作ってください。

  1. GET /todos で一覧を返す
  2. POST /todos でTodoを追加する
  3. Zodなどで入力を検証する
  4. 不正な入力では400を返す
  5. app.request() を使ってテストを書く

次のステップ

入力検証は Zodバリデーション入門 を先に確認すると理解しやすくなります。HTTPの読み方は HTTPリクエストの読み方HTTPレスポンスの読み方 が役立ちます。

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR