フロントエンドをReact(Next.js)にするとしてバックエンドをどうするか。選択肢の一つとして「Hono」はいかがでしょうか。今回は、ここ数年で人気急上昇のHonoというフレームワークを試してみました。また、JavaScriptのランタイムにはBunを使い、とにかく速くてモダンそうな技術を体験してみます。
簡単にそれぞれの特徴をまとめました。
🔥 Honoとは:
- 軽量で超高速なWebフレームワーク
- マルチランタイム対応(Cloudflare Workers、Deno、Bun、Node.jsなどどこでも動く)
- 作者が日本人(yusukebeさん)👏
🥟 Bunとは:
- Node.jsのようなJavaScriptのランタイムのこと
- 爆速ということで有名
- マスコットが可愛い(餃子かと思ったけど、肉まん(pork bun)だった...)
🔼 Next.jsとは:
- Reactベースのフルスタックフレームワーク
- Reactベースのフレームワークの中でシェア1位
- CSR・SSR・SSG・ISRなど複数のレンダリングを柔軟に使い分け可能
前提条件
Hono,Bun,Next.jsを触るのが初めてでも全く問題ありません!
私自身いずれも今回初めて使ってみましたが、情報も多いし公式ドキュメントも充実してるので色々と勉強になりました。
今回はECサイトを想定した簡単なデモアプリを作りながら、各技術を体験したいと思います。
今回やること:
- 環境構築
- Part1: GET編
- HonoでGET APIを実装
- Next.js Server Componentsで一覧表示
- Part2: POST編
- HonoでPOST APIを実装
- Next.js Server Actionsで登録フォーム
- Part3: HonoRPC編
- Part1・2のコードをRPC+Zod Validatorで書き換え
(認証機能は今回作りません)
環境構築
Bunインストール
まずはBunをインストール
# Linux & macOS
curl -fsSL https://bun.sh/install | bash
プロジェクト作成
ディレクトリ構成:
demo-app/
├── apps/
│ ├── frontend/ # Next.js
│ └── backend/ # Hono
├── .gitignore
├── package.json
├── ...
はじめに、プロジェクト用ディレクトリを作成し、.gitignoreを用意してGitを初期化しておきましょう。
mkdir demo-app && cd demo-app
echo "node_modules" > .gitignore
git init
後でHonoとNext.jsをインストールするappsディレクトリも作っておきます。
mkdir apps && cd apps
Workspaces設定
HonoとNext.jsのインストール前に、BunのWorkspacesを設定します。
これにより依存関係が一元管理され、共通パッケージはルートのnode_modulesに集約されて重複を削減できます。
インストール後は、各パッケージのnode_modulesはルートのnode_modulesを参照する構成になり、シンボリックリンクで解決されます。
ルートにpackage.jsonを作成。
{
"name": "demo-app",
"private": true,
"workspaces": ["apps/*"]
}
参考: https://bun.sh/guides/install/workspaces
Honoインストール
Honoをインストール
bun create hono@latest backend --template bun --install --pm bun
-
--template bun: ランタイムテンプレートとして Bun を選択 -
--install: プロジェクト生成後に依存パッケージを自動インストール -
--pm bun: パッケージマネージャーとして Bun を使用(npm/yarn/pnpmではなく)
Next.jsインストール
Next.jsをインストールします。
# Run in apps/
bun create next-app@latest frontend
各設定は次のようにしました。
✔ Would you like to use the recommended Next.js defaults? › No, customize
✔ Would you like to use TypeScript? › Yes
✔ Which linter would you like to use? › ESLint
✔ Would you like to use React Compiler? › No
✔ Would you like to use Tailwind CSS? › Yes
✔ Would you like your code inside a `src/` directory? › Yes
✔ Would you like to use App Router? › Yes
✔ Would you like to customize the import alias? › No
✔ Would you like to include AGENTS.md? › Yes
ちなみに、HonoもNext.jsもnpmでのインストールも試してみましたが、bunが圧倒的に速かったです!!
デモ実装(Part1: GET編)
🔥 Honoの実装
まずはHonoでのAPI開発を体験するために、以下2つの簡単なAPIを作成してみます。
- 商品一覧取得:
GET /api/products - 商品詳細取得:
GET /api/products/{id}
apps/backend/src/index.tsを以下のように書き換えます。
import { Hono } from 'hono'
type Product = {
id: number
name: string
price: number
stock: number
}
const products: Product[] = [
{ id: 1, name: 'Tシャツ', price: 2980, stock: 100 },
{ id: 2, name: 'デニムパンツ', price: 7980, stock: 50 },
{ id: 3, name: 'スニーカー', price: 5480, stock: 30 },
]
const app = new Hono()
app.get('/api/products', (c) => {
return c.json(products)
})
app.get('/api/products/:id', (c) => {
const id = Number(c.req.param('id'))
const product = products.find((p) => p.id === id)
if (!product) return c.json({ message: 'Not found' }, 404)
return c.json(product)
})
export default {
port: 3001, // Next.jsのデフォルトポート3000と被るため変更
fetch: app.fetch,
}
開発サーバーを起動します。
cd apps/backend
bun dev # → http://localhost:3001 で起動
ブラウザやcurlで動作確認してみましょう。
curl http://localhost:3001/api/products # 商品一覧
curl http://localhost:3001/api/products/1 # 商品詳細
curl http://localhost:3001/api/products/999 # → 404
🔼 Next.jsの実装
次に、フロントエンドから先ほどのAPIを呼び出して商品一覧を表示してみましょう。
apps/frontend/app/page.tsxを以下のように書き換えます。
(見た目はメインではないのでTailwindCSSで適当に装飾)
type Product = {
id: number
name: string
price: number
stock: number
}
export default async function Page() {
const res = await fetch('http://localhost:3001/api/products')
const products: Product[] = await res.json()
return (
<main className="max-w-2xl p-8">
<h1 className="text-xl font-bold mb-4">商品一覧</h1>
<ul className="divide-y">
{products.map((product) => (
<li key={product.id} className="flex gap-4 py-2">
<span>{product.name}</span>
<span>¥{product.price.toLocaleString()}</span>
<span>在庫: {product.stock}</span>
</li>
))}
</ul>
</main>
)
}
Next.jsの開発サーバーを起動します。
cd apps/frontend
bun dev # → http://localhost:3000 で起動
http://localhost:3000 を開くと、Honoから取得した商品一覧が表示されます。
Tips: 便利なBunコマンド
今回のような2つのパッケージがあるモノレポ構成であれば、--filterオプションを使って両方のdevスクリプトを一発で実行することもできます。
bun --filter '*' dev
それぞれ実行したい場合でも、プロジェクトルートから以下のように実行できます。
bun run --filter frontend dev
bun run --filter backend dev
デモ実装(Part2: POST編)
🔥 Honoの実装
apps/backend/src/index.ts にPOSTエンドポイントを追加します
// ...
app.post('/api/products', async (c) => {
const body = await c.req.json<Omit<Product, 'id'>>()
const newProduct = { id: products.length + 1, ...body }
products.push(newProduct)
return c.json(newProduct, 201)
})
curlで動作確認しておきます。
curl -X POST http://localhost:3001/api/products \
-H "Content-Type: application/json" \
-d '{"name":"キャップ","price":3980,"stock":20}'
curl http://localhost:3001/api/products
🔼 Next.jsの実装
次に商品登録フォームを作成します。apps/frontend/app/new/page.tsx を新規作成します。
import { redirect } from 'next/navigation'
async function createProduct(formData: FormData) {
'use server'
await fetch('http://localhost:3001/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
price: Number(formData.get('price')),
stock: Number(formData.get('stock')),
}),
})
redirect('/')
}
export default function NewPage() {
return (
<main className="p-8">
<h1 className="text-xl font-bold mb-4">商品登録</h1>
<form action={createProduct} className="space-y-4">
<div>
<label>商品名</label>
<input name="name" type="text" className="border ml-2 px-2 py-1" />
</div>
<div>
<label>価格</label>
<input name="price" type="number" className="border ml-2 px-2 py-1" />
</div>
<div>
<label>在庫</label>
<input name="stock" type="number" className="border ml-2 px-2 py-1" />
</div>
<button type="submit" className="px-4 py-2 bg-black text-white">
登録
</button>
</form>
</main>
)
}
page.tsxがあるディレクトリがそのままURLになります。
app/
├── page.tsx → /
└── new/
└── page.tsx → /new
http://localhost:3000/new を開いてフォームを送信で登録できます。登録後に一覧ページへリダイレクトされるので一覧に追加されているか確認ぢます。
ポイント: Server Actions
'use server'をつけた関数はサーバー上で実行されます。フォームのactionに直接関数を渡せます。
デモ実装(Part3: HonoRPC編)
Part1・2では Product 型をフロントとバックエンドで別々に定義していました。
Part3ではHonoのRPCとZodバリデーターを組み合わせて、バックエンドの型をフロントエンドで自動的に利用できるようにします。
🔥 Honoの実装
まず必要なパッケージを追加します。
cd apps/backend
bun add zod @hono/zod-validator
apps/backend/src/index.tsを次のように修正します。
-
zValidatorを使ってリクエストボディをバリデーション - ルートを
.get().get().post()とメソッドチェーンでつなげてroutesに代入 -
typeof routesをAppTypeとしてエクスポート
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const schema = z.object({
name: z.string(),
price: z.number(),
stock: z.number(),
})
type Product = z.infer<typeof schema> & { id: number }
const products: Product[] = // ...略
const app = new Hono()
const routes = app
.get('/api/products', (c) => {
return c.json(products)
})
.get('/api/products/:id', (c) => {
const id = Number(c.req.param('id'))
const product = products.find((p) => p.id === id)
if (!product) return c.json({ message: 'Not found' }, 404)
return c.json(product)
})
.post('/api/products', zValidator('json', schema), async (c) => {
const body = c.req.valid('json')
const newProduct = { id: products.length + 1, ...body }
products.push(newProduct)
return c.json(newProduct, 201)
})
export type AppType = typeof routes
export default {
port: 3001,
fetch: app.fetch,
}
c.req.json() の代わりに c.req.valid('json') を使うことで、Zodのスキーマで検証済みの型安全なボディを取得できます。また、schema の型情報がRPCクライアントにそのまま伝わるため、フロントエンド側でも存在しないフィールドを渡すとVSCodeが警告で教えてくれます。
🔼 Next.jsの実装
フロントエンドでは hono/client の hc を使ってAPIクライアントを作成します。
AppType をインポートすることで、レスポンスの型が自動で効きます。
まずapps/frontendにhonoを依存関係に追加します。
cd apps/frontend
bun add hono
商品一覧 apps/frontend/src/app/page.tsx
import { hc } from 'hono/client'
import type { AppType } from '../../../backend/src'
const client = hc<AppType>('http://localhost:3001')
export default async function Page() {
const res = await client.api.products.$get()
const products = await res.json()
return (
<main className="max-w-2xl p-8">
<h1 className="text-xl font-bold mb-4">商品一覧</h1>
<ul className="divide-y">
{products.map((product) => (
<li key={product.id} className="flex gap-4 py-2">
<span>{product.name}</span>
<span>¥{product.price.toLocaleString()}</span>
<span>在庫: {product.stock}</span>
</li>
))}
</ul>
</main>
)
}
fetch('http://localhost:3001/api/products') と書いていた箇所が client.api.products.$get() になりました。URLの文字列がなくなり、エンドポイントの変更がコンパイルエラーとして検出できます。
商品登録 apps/frontend/src/app/new/page.tsx
import { hc } from 'hono/client'
import { redirect } from 'next/navigation'
import type { AppType } from '../../../../backend/src'
async function createProduct(formData: FormData) {
'use server'
const client = hc<AppType>('http://localhost:3001')
await client.api.products.$post({
json: {
name: formData.get('name') as string,
price: Number(formData.get('price')),
stock: Number(formData.get('stock')),
},
})
redirect('/')
}
export default function NewPage() {
// ...略
}
fetch に渡していたJSONボディが $post({ json: { ... } }) になりました。schema の型がRPCを通じてフロントに伝わるため、存在しないフィールドを渡すとコンパイルエラーとして検出できます。
まとめ
本記事では、Hono + Bun + Next.jsのモノレポ構成で簡単なECサイトのデモアプリを作成しました。
今回はインメモリのダミーデータを使いましたが、PrismaなどのORMを導入してDBと繋いだり、認証機能を追加することで本格的なアプリへ拡張していくことができそうです。
今後もう少し深掘りしていこうと思います。
参考
Hono
Honoインストール方法
https://hono.dev/docs/getting-started/basic
開発者ご本人によるRPCの紹介記事(Zenn)
https://zenn.dev/yusukebe/articles/a00721f8b3b92e
Zod、Zod Validator Middlewareについて
https://hono.dev/docs/guides/validation#with-zod
Bun
Bunインストール方法
https://bun.com/docs/installation
Bun Workspacesについて
https://bun.com/docs/pm/workspaces
bunスクリプトの--filterについて
https://bun.com/docs/pm/filter#running-scripts-with-filter
Next.js
Next.jsインストール方法
https://nextjs.org/docs/app/getting-started/installation
use serverについて
https://nextjs.org/docs/app/api-reference/directives/use-server#using-use-server-inline
Server Functionsについて
https://react.dev/reference/rsc/server-functions
