Bun + Hono で始める個人開発の最小構成

Cloudflare Workers 以外の場面で個人開発 API を最小構成で組むなら Bun + Hono。TypeScript 設定もテストランナーも追加ライブラリも無しに、bun init から bun test まで通せる構成を、Fly.io / Railway / Cloudflare Workers の 3 方向にそのまま出せる形で解説します。

Cloudflare Workers 前提に従わない場面で、個人開発 API をどこまで薄く組めるか。Node + Express は今どき重すぎ、tsx + Hono は中途半端。この間を埋めるのが Bun + Hono です。本記事では、TS 設定もテストランナーも追加ライブラリも入れずに bun run だけで動く最小 API を組み、Fly.io / Railway / Cloudflare Workers の 3 方向に出せる状態までをまとめます。

なぜ Bun + Hono なのか

  • TypeScript がネイティブに動くtsctsx も要らない
  • テストランナー同梱bun testdescribe / expect を Jest 互換 API で提供
  • HTTP サーバ同梱Bun.serve({ fetch }) だけでサーバが立つ
  • Hono は app.fetch を返すランタイム中立な設計 — 同じコードが Bun / Workers / Deno / Node で動く
  • bun build --compile単一バイナリ化 も可能

依存は実質 hono + zod + @hono/zod-validator の 3 つ。package.json が 20 行を超えない構成になります。

到達点と前提

作るもの: 最小の投稿 API (POST /posts / GET /posts/:id)。永続化は本記事の範囲外(メモリ内 Map に保持)で、本題は「薄いランタイム構成」です。

動作確認バージョン (2026-04 時点最新):

  • Bun v1.3 / Hono v4.12 / Zod v4.3 / @hono/zod-validator v0.7

セットアップ

コマンド 2 発で終わります。

bun init -y                    # package.json / tsconfig.json / .gitignore を生成
bun add hono @hono/zod-validator zod

bun init はインタラクティブ版もありますが、既存プロジェクトに最小限の土台だけ欲しければ -y で十分です。

src/app.ts — ランタイム中立な Hono アプリ

// src/app.ts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

type Post = { id: string; title: string; body: string; createdAt: number };
const store = new Map<string, Post>();

const postInput = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(50_000),
});

const app = new Hono();

app.onError((err, c) => {
  if (err instanceof HTTPException) return err.getResponse();
  console.error(err);
  return c.json({ code: "internal", message: "unexpected error" }, 500);
});

app.post("/posts", zValidator("json", postInput), (c) => {
  const input = c.req.valid("json");
  const id = crypto.randomUUID();
  const post: Post = { id, ...input, createdAt: Date.now() };
  store.set(id, post);
  return c.json(post, 201);
});

app.get("/posts/:id", (c) => {
  const post = store.get(c.req.param("id"));
  if (!post) throw new HTTPException(404, { message: "post not found" });
  return c.json(post);
});

export default app; // ← Bun / Workers / Deno すべてこの 1 行で対応

export default appBun でも Workers でも同じエントリになるのが Hono の強みです。Bun は default exportfetch メソッドがあれば、それを Bun.serve に自動適用して起動します。

起動とテスト

開発は bun --watch src/app.ts、テストは bun test。Hono には app.request() という疑似 HTTP テスト用メソッド があり、supertest を足さずにそのままテストが書けます。

// src/app.test.ts
import { describe, test, expect } from "bun:test";
import app from "./app";

describe("posts api", () => {
  test("POST /posts creates a post", async () => {
    const res = await app.request("/posts", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ title: "hi", body: "hello world" }),
    });
    expect(res.status).toBe(201);
    const post = await res.json();
    expect(post.title).toBe("hi");
  });

  test("GET /posts/:id returns 404 when missing", async () => {
    const res = await app.request("/posts/not-exist");
    expect(res.status).toBe(404);
  });
});

実際の HTTP サーバを立てずに fetch ハンドラを直接呼ぶので、テスト実行が速く CI コストも下がります。

デプロイ選択肢 — 同じコードで 3 方向

出力先必要な設定ファイル手順
Fly.io / Railway / 任意 VPSDockerfile (Bun ベース)bun install --productionbun src/app.ts を起動コマンドに
Cloudflare Workerswrangler.jsoncmain: "src/app.ts" を指定して wrangler deploy。Bun 特有 API を使っていなければそのまま動く
単一バイナリ不要bun build --compile --outfile server src/app.ts

本記事のコード(メモリ Map 保存)は 3 つとも動きます。永続化を D1 / Postgres / R2 に差し替える段になっても、Hono アプリ本体は変更不要なのがこの構成の芯です。

落とし穴

  • Node 互換は完全ではないfs.promises の一部、一部 crypto API、OS 依存コマンドが未実装のケースがあります。本番投入前に bun run で通し実行する
  • bun --watch が TS の変更を全部見ているとは限らないtsconfig.jsoninclude を適切にしないと、サブディレクトリが watch 外になる
  • Bun.serve({ fetch }) 直接利用 vs export default app — どちらも動きますが、ランタイム可搬性を重視するなら後者を基本にする
  • 本番 Docker で bun install のみだと devDeps も入るbun install --production --frozen-lockfile を明示する

まとめ

Bun + Hono は、TS 設定・テストランナー・追加ライブラリなしで bun init から bun test までを 1 行で通せる、個人開発の 下限構成 です。同じコードを Cloudflare Workers にも VPS にも単一バイナリにも出せる柔軟性が、このスタックの本当の価値です。

次回は Better Auth + D1 による認証実装と CVE-2025-61928 対策 を書く予定です。

参考

記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。

$cat./src/app.tsts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

type Post = { id: string; title: string; body: string; createdAt: number };

// デモ用のメモリストア。
// 本番で永続化するなら D1 / Postgres / R2 などに差し替える。
const store = new Map<string, Post>();

const postInput = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(50_000),
});

const app = new Hono();

app.get("/", (c) => c.text("bun-hono-minimal-starter ok\n"));

app.onError((err, c) => {
  if (err instanceof HTTPException) return err.getResponse();
  console.error(err);
  return c.json({ code: "internal", message: "unexpected error" }, 500);
});

app.post("/posts", zValidator("json", postInput), (c) => {
  const input = c.req.valid("json");
  const id = crypto.randomUUID();
  const post: Post = { id, ...input, createdAt: Date.now() };
  store.set(id, post);
  return c.json(post, 201);
});

app.get("/posts/:id", (c) => {
  const post = store.get(c.req.param("id"));
  if (!post) throw new HTTPException(404, { message: "post not found" });
  return c.json(post);
});

// `export default app` が Bun / Workers / Deno 共通のエントリ。
// Bun はここから `fetch` を拾って Bun.serve を自動で起動する。
export default app;
slugbun-hono-minimal-starterfiles8click a file in the tree to switch