取り組みの背景:Hono.js が個人開発のバックエンド選択を変える理由
Rork でモバイルアプリを開発すると、必然的にバックエンド API が必要になる場面が出てきます。ユーザー認証、データの永続化、外部サービスとの連携 — これらをすべてクライアント側で行うのは難しく、セキュリティ上のリスクもあります。
多くの個人開発者がこの壁にぶつかったとき、Express.js や NestJS を選びます。しかし 2026 年現在、より優れた選択肢が存在します。それが Hono.js × Cloudflare Workers の組み合わせです。
Hono.js は「炎」を意味する日本語が名前の由来で、エッジ環境に最適化された超軽量 Web フレームワークです。コアサイズはわずか 12KB 未満。Cloudflare Workers のゼロコールドスタート環境と組み合わせることで、レイテンシー 1ms 以下の API を世界中のエッジから提供できます。
なぜ tRPC ではなく Hono.js なのか
当サイトではすでに tRPC × Cloudflare Workers による型安全バックエンド実装ガイド を公開しています。では今回なぜ Hono.js を取り上げるのでしょうか?
tRPC は TypeScript のみ使用するフルスタック構成で真価を発揮しますが、外部クライアント(iOS ネイティブアプリ、サードパーティとの連携など)からアクセスしたい場合は REST が適しています。Hono.js は REST・GraphQL・RPC いずれにも対応でき、柔軟性が高い点が魅力です。
前提知識・環境準備
必要なもの
Node.js 20 以上
Cloudflare アカウント(無料プランで可)
Wrangler CLI(Cloudflare 公式 CLI)
Rork または Rork Max アカウント
Wrangler のインストールと認証
# Wrangler CLI をグローバルインストール
npm install -g wrangler
# Cloudflare アカウントにログイン
wrangler login
# バージョン確認
wrangler --version
# Expected output: wrangler 3.x.x
プロジェクトの初期化
# Hono.js テンプレートで新規プロジェクトを作成
npm create hono@latest rork-api-backend
# テンプレートの選択: cloudflare-workers を選ぶ
cd rork-api-backend
# 追加パッケージをインストール
npm install hono
npm install --save-dev @cloudflare/workers-types wrangler
wrangler.toml の設定
# wrangler.toml
name = "rork-api-backend"
main = "src/index.ts"
compatibility_date = "2026-01-01"
# D1 データベースのバインディング
[[ d1_databases ]]
binding = "DB"
database_name = "rork_app_db"
database_id = "YOUR_D1_DATABASE_ID" # wrangler d1 create で取得
# KV ストアのバインディング(セッション管理)
[[ kv_namespaces ]]
binding = "SESSION_KV"
id = "YOUR_KV_NAMESPACE_ID" # wrangler kv:namespace create で取得
# R2 バケットのバインディング(ファイルストレージ)
[[ r2_buckets ]]
binding = "STORAGE"
bucket_name = "rork-app-storage"
# 環境変数(シークレットは wrangler secret put で設定)
[ vars ]
ENVIRONMENT = "production"
D1 データベースのスキーマ設計
まずデータベース構造を定義します。今回はユーザー管理 API を例にします。
-- migrations/001_initial.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT ( lower (hex(randomblob( 16 )))),
email TEXT UNIQUE NOT NULL ,
password_hash TEXT NOT NULL ,
display_name TEXT ,
avatar_url TEXT ,
created_at TEXT DEFAULT ( datetime ( 'now' )),
updated_at TEXT DEFAULT ( datetime ( 'now' ))
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY DEFAULT ( lower (hex(randomblob( 16 )))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE ,
token_hash TEXT UNIQUE NOT NULL ,
expires_at TEXT NOT NULL ,
created_at TEXT DEFAULT ( datetime ( 'now' ))
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
マイグレーションの適用:
# ローカル環境でマイグレーション実行
wrangler d1 execute rork_app_db --local --file=migrations/001_initial.sql
# 本番環境に適用
wrangler d1 execute rork_app_db --remote --file=migrations/001_initial.sql
メインエントリポイントの実装
Hono.js アプリケーションのコアファイルを作成します。
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { authRouter } from './routes/auth'
import { usersRouter } from './routes/users'
import { filesRouter } from './routes/files'
import { rateLimitMiddleware } from './middleware/rateLimit'
import { errorHandler } from './middleware/errorHandler'
// Cloudflare Workers の Bindings 型定義
type Bindings = {
DB : D1Database
SESSION_KV : KVNamespace
STORAGE : R2Bucket
JWT_SECRET : string
ENVIRONMENT : string
}
// Hono アプリケーションを初期化
const app = new Hono <{ Bindings : Bindings }>()
// グローバルミドルウェアを登録
app. use ( '*' , logger ())
app. use ( '*' , secureHeaders ())
app. use ( '*' , cors ({
origin: [ 'https://yourrorkapp.com' , 'exp://localhost' ],
allowMethods: [ 'GET' , 'POST' , 'PUT' , 'DELETE' , 'OPTIONS' ],
allowHeaders: [ 'Content-Type' , 'Authorization' ],
maxAge: 86400 ,
}))
// レート制限(全エンドポイントに適用)
app. use ( '*' , rateLimitMiddleware)
// グローバルエラーハンドラー
app. onError (errorHandler)
// ヘルスチェックエンドポイント
app. get ( '/health' , ( c ) => {
return c. json ({
status: 'ok' ,
environment: c.env. ENVIRONMENT ,
timestamp: new Date (). toISOString (),
})
})
// ルーターを登録
app. route ( '/api/v1/auth' , authRouter)
app. route ( '/api/v1/users' , usersRouter)
app. route ( '/api/v1/files' , filesRouter)
// 404 ハンドラー
app. notFound (( c ) => {
return c. json ({ error: 'Not Found' , path: c.req.path }, 404 )
})
export default app
JWT 認証ミドルウェアの実装
認証システムはどのアプリにも欠かせません。Hono.js の JWT ヘルパーを使って実装します。
// src/middleware/auth.ts
import { Context, Next } from 'hono'
import { verify } from 'hono/jwt'
export type JWTPayload = {
sub : string // ユーザー ID
email : string
iat : number // 発行時刻
exp : number // 有効期限
}
// JWT 認証ミドルウェア
export const authMiddleware = async ( c : Context , next : Next ) => {
const authHeader = c.req. header ( 'Authorization' )
if ( ! authHeader || ! authHeader. startsWith ( 'Bearer ' )) {
return c. json ({ error: 'Unauthorized: No token provided' }, 401 )
}
const token = authHeader. slice ( 7 )
try {
const payload = await verify (token, c.env. JWT_SECRET ) as JWTPayload
// トークンの有効期限チェック
if (payload.exp < Math. floor (Date. now () / 1000 )) {
return c. json ({ error: 'Unauthorized: Token expired' }, 401 )
}
// ペイロードをコンテキストに保存
c. set ( 'jwtPayload' , payload)
c. set ( 'userId' , payload.sub)
await next ()
} catch (error) {
return c. json ({ error: 'Unauthorized: Invalid token' }, 401 )
}
}
認証ルートの実装
// src/routes/auth.ts
import { Hono } from 'hono'
import { sign } from 'hono/jwt'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { hashPassword, verifyPassword } from '../utils/crypto'
type Bindings = {
DB : D1Database
SESSION_KV : KVNamespace
JWT_SECRET : string
}
export const authRouter = new Hono <{ Bindings : Bindings }>()
// 登録リクエストのバリデーションスキーマ
const registerSchema = z. object ({
email: z. string (). email ( '有効なメールアドレスを入力してください' ),
password: z. string (). min ( 8 , 'パスワードは8文字以上必要です' ),
displayName: z. string (). min ( 1 ). max ( 50 ). optional (),
})
// ユーザー登録
authRouter. post ( '/register' , zValidator ( 'json' , registerSchema), async ( c ) => {
const { email , password , displayName } = c.req. valid ( 'json' )
// メールアドレスの重複チェック
const existing = await c.env. DB . prepare (
'SELECT id FROM users WHERE email = ?'
). bind (email). first ()
if (existing) {
return c. json ({ error: 'このメールアドレスは既に登録されています' }, 409 )
}
// パスワードのハッシュ化
const passwordHash = await hashPassword (password)
// ユーザーを作成
const userId = crypto. randomUUID ()
await c.env. DB . prepare (
'INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)'
). bind (userId, email, passwordHash, displayName ?? null ). run ()
// JWT トークンを発行(有効期限: 30日)
const token = await sign (
{
sub: userId,
email,
iat: Math. floor (Date. now () / 1000 ),
exp: Math. floor (Date. now () / 1000 ) + 60 * 60 * 24 * 30 ,
},
c.env. JWT_SECRET
)
return c. json ({
message: '登録が完了しました' ,
token,
user: { id: userId, email, displayName },
}, 201 )
})
// ログイン
const loginSchema = z. object ({
email: z. string (). email (),
password: z. string (). min ( 1 ),
})
authRouter. post ( '/login' , zValidator ( 'json' , loginSchema), async ( c ) => {
const { email , password } = c.req. valid ( 'json' )
const user = await c.env. DB . prepare (
'SELECT id, email, password_hash, display_name FROM users WHERE email = ?'
). bind (email). first <{
id : string
email : string
password_hash : string
display_name : string | null
}>()
if ( ! user) {
// タイミング攻撃対策: ユーザーが存在しない場合も同じ処理時間を確保
await hashPassword ( 'dummy_for_timing_attack_prevention' )
return c. json ({ error: 'メールアドレスまたはパスワードが正しくありません' }, 401 )
}
const isValidPassword = await verifyPassword (password, user.password_hash)
if ( ! isValidPassword) {
return c. json ({ error: 'メールアドレスまたはパスワードが正しくありません' }, 401 )
}
const token = await sign (
{
sub: user.id,
email: user.email,
iat: Math. floor (Date. now () / 1000 ),
exp: Math. floor (Date. now () / 1000 ) + 60 * 60 * 24 * 30 ,
},
c.env. JWT_SECRET
)
return c. json ({
token,
user: { id: user.id, email: user.email, displayName: user.display_name },
})
})
R2 によるファイルアップロードの実装
アバター画像やアプリで使うメディアファイルの管理に R2 を使います。
// src/routes/files.ts
import { Hono } from 'hono'
import { authMiddleware } from '../middleware/auth'
type Bindings = {
STORAGE : R2Bucket
}
type Variables = {
userId : string
}
export const filesRouter = new Hono <{ Bindings : Bindings ; Variables : Variables }>()
// 認証必須
filesRouter. use ( '*' , authMiddleware)
// ファイルアップロード(最大 10MB)
filesRouter. post ( '/upload' , async ( c ) => {
const userId = c. get ( 'userId' )
const formData = await c.req. formData ()
const file = formData. get ( 'file' ) as File | null
if ( ! file) {
return c. json ({ error: 'ファイルが指定されていません' }, 400 )
}
// ファイルサイズ制限(10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024
if (file.size > MAX_FILE_SIZE ) {
return c. json ({ error: 'ファイルサイズは10MB以下にしてください' }, 413 )
}
// 許可する MIME タイプ
const ALLOWED_TYPES = [ 'image/jpeg' , 'image/png' , 'image/webp' , 'image/gif' ]
if ( ! ALLOWED_TYPES . includes (file.type)) {
return c. json ({ error: '対応フォーマット: JPEG, PNG, WebP, GIF' }, 415 )
}
// ファイル名をサニタイズして R2 のパスを生成
const ext = file.name. split ( '.' ). pop ()?. toLowerCase () ?? 'bin'
const objectKey = `uploads/${ userId }/${ crypto . randomUUID () }.${ ext }`
// R2 に保存
await c.env. STORAGE . put (objectKey, await file. arrayBuffer (), {
httpMetadata: {
contentType: file.type,
cacheControl: 'public, max-age=31536000' ,
},
customMetadata: {
uploadedBy: userId,
originalName: file.name,
},
})
// 公開 URL を返す(Cloudflare R2 カスタムドメインが必要)
const publicUrl = `https://assets.yourapp.com/${ objectKey }`
return c. json ({ url: publicUrl, key: objectKey }, 201 )
})
// ファイル削除
filesRouter. delete ( '/:key' , async ( c ) => {
const userId = c. get ( 'userId' )
const key = c.req. param ( 'key' )
// アップロードしたユーザーのみ削除可能
const object = await c.env. STORAGE . head ( `uploads/${ userId }/${ key }` )
if ( ! object) {
return c. json ({ error: 'ファイルが見つかりません' }, 404 )
}
await c.env. STORAGE . delete ( `uploads/${ userId }/${ key }` )
return c. json ({ message: 'ファイルを削除しました' })
})
レート制限ミドルウェアの実装
API の乱用を防ぐため、KV を使ったレート制限を実装します。
// src/middleware/rateLimit.ts
import { Context, Next } from 'hono'
type Bindings = {
SESSION_KV : KVNamespace
}
// レート制限の設定
const RATE_LIMIT_CONFIG = {
windowMs: 60 * 1000 , // 1分間のウィンドウ
maxRequests: 60 , // 最大60リクエスト/分
authMaxRequests: 5 , // 認証エンドポイントはより厳格に
}
export const rateLimitMiddleware = async (
c : Context <{ Bindings : Bindings }>,
next : Next
) => {
const clientIp = c.req. header ( 'CF-Connecting-IP' ) ?? '0.0.0.0'
const path = c.req.path
const now = Math. floor (Date. now () / 1000 )
const windowStart = now - (now % 60 ) // 1分ウィンドウ
// 認証エンドポイントは制限を厳しく
const isAuthEndpoint = path. includes ( '/auth/' )
const maxRequests = isAuthEndpoint
? RATE_LIMIT_CONFIG .authMaxRequests
: RATE_LIMIT_CONFIG .maxRequests
const kvKey = `rate_limit:${ clientIp }:${ path . split ( '/' )[ 3 ] }:${ windowStart }`
// 現在のリクエスト数を取得・インクリメント
const current = await c.env. SESSION_KV . get (kvKey)
const count = current ? parseInt (current, 10 ) + 1 : 1
if (count > maxRequests) {
return c. json (
{
error: 'Too Many Requests' ,
message: `リクエスト制限を超えました。${ isAuthEndpoint ? '1分後' : '1分後'}に再試行してください。` ,
retryAfter: windowStart + 60 - now,
},
429 ,
{
'Retry-After' : String (windowStart + 60 - now),
'X-RateLimit-Limit' : String (maxRequests),
'X-RateLimit-Remaining' : '0' ,
'X-RateLimit-Reset' : String (windowStart + 60 ),
}
)
}
// カウンターを保存(TTL: 65秒)
await c.env. SESSION_KV . put (kvKey, String (count), { expirationTtl: 65 })
// レート制限ヘッダーを付与
c. header ( 'X-RateLimit-Limit' , String (maxRequests))
c. header ( 'X-RateLimit-Remaining' , String (maxRequests - count))
c. header ( 'X-RateLimit-Reset' , String (windowStart + 60 ))
await next ()
}
Rork アプリからの API 呼び出し実装
Rork 側の実装も見ておきましょう。型安全な API クライアントを作成します。
// lib/apiClient.ts(Rork アプリ内のコード)
import AsyncStorage from '@react-native-async-storage/async-storage'
const API_BASE_URL = 'https://rork-api-backend.YOUR_SUBDOMAIN.workers.dev/api/v1'
class ApiClient {
private async getToken () : Promise < string | null > {
return AsyncStorage. getItem ( 'auth_token' )
}
private async request < T >(
method : string ,
path : string ,
body ?: unknown
) : Promise < T > {
const token = await this . getToken ()
const headers : Record < string , string > = {
'Content-Type' : 'application/json' ,
}
if (token) {
headers[ 'Authorization' ] = `Bearer ${ token }`
}
const response = await fetch ( `${ API_BASE_URL }${ path }` , {
method,
headers,
body: body ? JSON . stringify (body) : undefined ,
})
if ( ! response.ok) {
const error = await response. json () as { error : string }
throw new Error (error.error ?? `HTTP ${ response . status }` )
}
return response. json () as T
}
// 型付きのメソッド
async register ( email : string , password : string , displayName ?: string ) {
return this . request <{ token : string ; user : User }>(
'POST' , '/auth/register' , { email, password, displayName }
)
}
async login ( email : string , password : string ) {
return this . request <{ token : string ; user : User }>(
'POST' , '/auth/login' , { email, password }
)
}
async getProfile () {
return this . request < User >( 'GET' , '/users/me' )
}
async uploadAvatar ( imageUri : string ) {
const token = await this . getToken ()
const formData = new FormData ()
formData. append ( 'file' , {
uri: imageUri,
type: 'image/jpeg' ,
name: 'avatar.jpg' ,
} as unknown as Blob )
const response = await fetch ( `${ API_BASE_URL }/files/upload` , {
method: 'POST' ,
headers: { Authorization: `Bearer ${ token }` },
body: formData,
})
if ( ! response.ok) throw new Error ( 'Upload failed' )
return response. json () as Promise <{ url : string }>
}
}
export const apiClient = new ApiClient ()
export type User = {
id : string
email : string
displayName : string | null
}
CI/CD パイプラインの構築
GitHub Actions を使って自動デプロイを設定します。
# .github/workflows/deploy.yml
name : Deploy to Cloudflare Workers
on :
push :
branches : [ main ]
pull_request :
branches : [ main ]
jobs :
test :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : '20'
cache : 'npm'
- run : npm ci
- run : npm run typecheck
- run : npm test
deploy :
needs : test
runs-on : ubuntu-latest
if : github.ref == 'refs/heads/main'
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : '20'
cache : 'npm'
- run : npm ci
- name : Deploy to Cloudflare Workers
uses : cloudflare/wrangler-action@v3
with :
apiToken : ${{ secrets.CLOUDFLARE_API_TOKEN }}
command : deploy --env production
シークレットの設定:
# JWT シークレットを Cloudflare Workers に安全に登録
wrangler secret put JWT_SECRET
# プロンプトが表示されたら、ランダムな長い文字列を入力
# 開発環境でローカルに設定
echo 'JWT_SECRET="your-super-secret-key-minimum-32-chars"' >> .dev.vars
パフォーマンスチューニングと本番運用のポイント
1. D1 クエリの最適化
// 悪い例: N+1 クエリ
const users = await db. prepare ( 'SELECT * FROM users' ). all ()
for ( const user of users.results) {
const posts = await db. prepare ( 'SELECT * FROM posts WHERE user_id = ?' )
. bind (user.id). all ()
}
// 良い例: JOIN で一度に取得
const { results } = await db. prepare ( `
SELECT u.id, u.email, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id
LIMIT 50
` ). all ()
2. KV キャッシュ戦略
頻繁に読まれるデータは KV にキャッシュして D1 の読み取りコストを削減します。
// キャッシュ付きユーザー取得
async function getCachedUser ( userId : string , kv : KVNamespace , db : D1Database ) {
const cacheKey = `user:${ userId }`
// KV キャッシュを確認
const cached = await kv. get (cacheKey, 'json' )
if (cached) return cached
// D1 から取得してキャッシュに保存(TTL: 5分)
const user = await db. prepare (
'SELECT id, email, display_name, avatar_url FROM users WHERE id = ?'
). bind (userId). first ()
if (user) {
await kv. put (cacheKey, JSON . stringify (user), { expirationTtl: 300 })
}
return user
}
3. エラーハンドリングとロギング
本番環境では適切なエラー分類とログ出力が重要です。
// src/middleware/errorHandler.ts
import { Context } from 'hono'
export const errorHandler = ( err : Error , c : Context ) => {
// 本番環境では詳細なエラーを外部に露出しない
const isProd = c.env. ENVIRONMENT === 'production'
console. error ( '[Error]' , {
path: c.req.path,
method: c.req.method,
error: err.message,
stack: err.stack,
})
if (err.name === 'ZodError' ) {
return c. json ({ error: 'Validation Error' , details: isProd ? undefined : err.message }, 400 )
}
if (err.name === 'UnauthorizedError' ) {
return c. json ({ error: 'Unauthorized' }, 401 )
}
return c. json (
{
error: 'Internal Server Error' ,
message: isProd ? 'サーバーエラーが発生しました' : err.message,
},
500
)
}
よくあるエラーと対処法
CORS エラーが発生する
原因 : Cloudflare Workers の CORS 設定と Rork アプリの接続先 URL が一致していません。
対処法 : wrangler.toml の vars に正しいオリジンを設定し、cors() ミドルウェアの origin 配列に追加します。開発時は http://localhost:8081(Expo の開発サーバー)を忘れずに含める。
D1 の接続タイムアウト
原因 : D1 クエリが複雑すぎてタイムアウトしています。
対処法 : クエリにインデックスが適切に設定されているか確認します。EXPLAIN QUERY PLAN で実行計画を確認し、インデックスが使われていない場合は CREATE INDEX で追加します。
R2 へのアップロードが失敗する
原因 : wrangler.toml の R2 バインディング設定が不正、またはファイルサイズが Worker のメモリ制限を超えています。
対処法 : 大きなファイルは R2 の Presigned URL を使ってクライアントから直接アップロードする設計に変更します。Cloudflare Workers の CPU 時間制限(無料プラン: 10ms)にも注意が必要。
個人開発者の視点から(実体験メモ)
まとめ
今回は Hono.js × Cloudflare Workers を使って、Rork アプリのバックエンドをゼロから構築する方法を解説しました。
ポイントを整理すると、Hono.js の型安全ルーティングと組み込みミドルウェアを活用することで、JWT 認証・D1 データベース・R2 ファイルストレージ・KV キャッシュ・レート制限をコンパクトなコードで実現できます。Cloudflare Workers のエッジ環境と組み合わせることで、世界中のユーザーに対して低レイテンシーな API を提供できるのも大きな利点です。
次のステップとして、tRPC × Cloudflare Workers による型安全バックエンド実装ガイド も合わせてお読みいただくと、REST と RPC それぞれのアプローチの違いがよりクリアに理解できます。
Rork でモバイルアプリを作り、Hono.js でバックエンドを支える — このスタックで、個人開発でもスケールするサービスを作り上げてください。
バックエンド実装