はじめに
Next.js で個人開発をしていると、ルーティングもAPI Routesもビルドも全部フレームワークがやってくれる。便利だけど、技術面接で「なぜその構成にしたのか?」と聞かれた時に、自分の言葉で答えられない場面があった。
フレームワークが設計判断を肩代わりしてくれていた分、自分では何も設計していなかったことに気づいた。
そこで、会計システムを題材に「フレームワークの魔法を外して全部自分で設計する」をテーマに開発を始めた。この記事では、その最初のステップであるモノレポ構成とバックエンドの構築について書く。
この記事で扱うこと
- pnpm workspaces によるモノレポの設計
- フロントエンドとバックエンドの型共有
- Hono によるバックエンドの最小構成
技術スタック
| レイヤー | 技術 |
|---|---|
| フロントエンド | React + Vite (SPA) |
| バックエンド | Hono (TypeScript) |
| パッケージ管理 | pnpm workspaces |
| データベース | Supabase (PostgreSQL) |
1. モノレポの設計 — なぜ pnpm workspaces を使うのか
解決したい問題
フロントエンドとバックエンドの間で型を共有したい。
例えば API レスポンスの型を考える。フロントが User 型を期待しているのに、バックエンドが UserResponse という別の型を返していたら実行時にバグになる。この手のズレは、型定義が2箇所にコピペされている時に起きやすい。
workspaces なしだとどうなるか
folio-v3/
├── frontend/ ← ここに User 型がある
├── backend/ ← ここにも User 型がある(コピペ)
- 相対パスで
import "../backend/types"→ パッケージの境界を無視していて壊れやすい - 型をコピペ → 片方を変えたらもう片方も手動で直す。直し忘れ = バグ
workspaces ありの構成
folio-v3/
├── pnpm-workspace.yaml ← 「この中のフォルダは全部仲間」と宣言
├── packages/
│ ├── frontend/ ← React + Vite
│ ├── backend/ ← Hono
│ └── shared/ ← 型定義を置く場所
pnpm-workspace.yaml はこれだけ:
packages:
- "packages/*"
これで pnpm が packages/ 配下のフォルダをローカルパッケージとして認識する。
shared パッケージの package.json で名前を付けておけば:
{
"name": "@folio/shared",
"main": "src/index.ts"
}
フロントエンドやバックエンドから普通の npm パッケージと同じ感覚で使える:
import { User } from "@folio/shared";
npm に公開しなくても、ローカルで依存関係が繋がる。型がズレたらビルドエラーで検知できるので、実行時ではなくビルド時にバグを潰せる。
Swagger(OpenAPI)との比較
API の型共有には Swagger で仕様書を書いて型を自動生成するアプローチもある。
| Swagger (OpenAPI) | workspaces の型共有 | |
|---|---|---|
| 何が正(source of truth) | JSON/YAML の仕様書 | TypeScript の型定義そのもの |
| 同期の仕組み | 仕様書からコードを生成 | コードを直接共有 |
| 向いている規模 | 大規模・チーム間連携 | 小〜中規模・同一チーム |
今回は小規模な個人開発なので、仕様書を書いてから型を生成するオーバーヘッドは不要。workspaces で直接共有するシンプルな方法を採用した。
ただし、チームが大きくなりフロント/バックの開発者が別れる場合は、Swagger のようにスキーマを先に定義する「スキーマファースト」の方が適している。
--filter で特定パッケージにだけ操作する
モノレポだと pnpm add hono で全パッケージに入ってしまいそうだが、--filter で対象を絞れる:
pnpm add hono --filter @folio/backend # backend にだけ hono を追加
pnpm add react --filter @folio/frontend # frontend にだけ react を追加
フロントに Hono は要らないし、バックに React は要らない。パッケージごとに依存を制御できるのがモノレポの利点。
2. Hono バックエンドの最小構成
なぜ Hono なのか
バックエンドフレームワークの選択肢はいくつかある:
| フレームワーク | 特徴 |
|---|---|
| Express | 歴史が長い、情報が多い。コールバックベース |
| Fastify | 高速。Express の後継的な立ち位置 |
| Hono | 軽量。Web Standards(Request/Response API)準拠 |
Hono を選んだ理由は2つ:
-
Web Standards 準拠 — Hono は
Request/Responseという Web 標準の API を使っている。Node.js 固有のreq, resではないので、Cloudflare Workers や Deno にもそのまま動く。プラットフォーム非依存の知識が身につく - 明示的なミドルウェア — 何が起きているか自分で把握できる。「魔法を外す」というテーマに合っている
3つのパーツ
Hono のバックエンドは最小でこの3つだけ:
import { serve } from "@hono/node-server";
import { Hono } from "hono";
// 1. アプリケーションインスタンスの作成
// 全てのルートやミドルウェアがぶら下がる「親」
const app = new Hono();
// 2. ルートの定義
// 「このURLにこのメソッドでアクセスしたら、こう返す」
app.get("/", (c) => c.json({ message: "Hello!" }));
// 3. サーバーの起動
// これがないとサーバーが立たない
serve({ fetch: app.fetch, port: 3000 });
Vite がテンプレートで全部用意してくれるのに対し、Hono は自分で構成を作る。でも必要なものは同じパターン:
| 必要なもの | 役割 |
|---|---|
package.json |
依存関係の管理 |
tsconfig.json |
TypeScript の設定 |
src/index.ts |
エントリーポイント |
どのプロジェクトでもこの3つが基本構成。 テンプレートはこれを隠してくれているだけ。
tsx watch で開発する
TypeScript のファイルを Node.js で動かすには、通常は tsc でコンパイルしてから node で実行する。tsx はこの2ステップを1つにまとめてくれるツール:
| コマンド | やること |
|---|---|
tsc |
TypeScript → JavaScript にコンパイルするだけ |
tsx |
コンパイル + 実行を1ステップで |
tsx watch |
さらにファイル変更を監視して自動で再実行 |
フロントの vite(HMR で自動リロード)のバックエンド版が tsx watch。
package.json の scripts に登録しておく:
{
"scripts": {
"dev": "tsx watch src/index.ts"
}
}
3. 型共有の実践 — @folio/shared の中身
実際にどんな型を共有しているか。会計システムのデータベーステーブルに対応する型を定義している:
// packages/shared/src/index.ts
// DBの ENUM に対応する値と型を1つの定義から作る
export const ACCOUNT_TYPES = [
"asset", // 資産
"liability", // 負債
"equity", // 純資産
"revenue", // 収益
"expense", // 費用
] as const;
export type AccountType = (typeof ACCOUNT_TYPES)[number];
as const パターン
as const を付けることで、配列の値がリテラル型になる:
// as const なし → string[] (何でも入る)
const types = ["asset", "liability"];
// as const あり → readonly ["asset", "liability"] (この2つだけ)
const types = ["asset", "liability"] as const;
これにより:
-
ACCOUNT_TYPES(配列)→ ランタイムでバリデーションに使える(ACCOUNT_TYPES.includes(value)など) -
AccountType(型)→ コンパイル時の型チェックに使える
1つの定義から値と型の両方を得られるので、ズレが起きない。
テーブルに対応する型の例
export type Organization = {
id: string;
name: string;
owner_id: string;
created_at: string; // JSON には Date 型がないので string
};
export type JournalEntry = {
id: string;
organization_id: string;
date: string;
description: string;
created_by: string;
created_at: string;
};
export type JournalEntryLine = {
id: string;
journal_entry_id: string;
account_id: string;
debit_amount: number; // 金額は number
credit_amount: number;
};
created_at が Date ではなく string なのは、JSON のシリアライズ/デシリアライズを考慮しているため。API レスポンスでは日付は "2026-02-06T12:00:00Z" のような ISO 文字列になるので、共有型もそれに合わせる。
各レイヤーで同じ値を守る
同じ制約を複数レイヤーで担保している:
| レイヤー | 制限の仕組み |
|---|---|
| DB | PostgreSQL ENUM 型 |
| TypeScript |
as const + ユニオン型 |
| API | Zod バリデーション(後続の記事で扱う) |
1箇所でも漏れると不正な値が通ってしまうので、DBと型とバリデーションの3層で守る設計にしている。
まとめ
| やったこと | 技術的判断 |
|---|---|
| モノレポ構成 | pnpm workspaces でフロント/バック/共有型を1リポジトリに |
| 型共有 |
@folio/shared で TypeScript の型を直接共有(小規模なので Swagger は不要) |
| バックエンド | Hono を採用(Web Standards 準拠、明示的なミドルウェア) |
| 開発環境 |
tsx watch でファイル変更を監視して自動リロード |
| 型定義 |
as const パターンで値と型を1つの定義から作る |
Next.js の API Routes を使えば packages/shared も pnpm-workspace.yaml もいらない。1つのプロジェクトで全部完結する。
でも、フロントとバックが分離した構成で型をどう共有するかを考えること自体が、小さなチーム開発に必要な設計力に繋がる。便利さを手放した分、仕組みの理解は深くなった。
次の記事では、この構成の上に Supabase Auth + Hono ミドルウェアで JWT 認証フローを組む話を書く。