はじめに
同窓会やクラス会で「最近どうしてる?」を事前に集められるWebアプリを作っています。
まずはMVPとして、URLを共有すると参加者が近況を投稿できて、みんなの投稿を一覧で見られるところまでを作りました。
管理画面や画像アップロードなど、あとで欲しくなりそうな機能はいったん後回しにして、Phase1では「投稿できる」「一覧で見られる」に絞っています。
この記事では、React RouterでURL共有前提の画面を作り、Hono APIで投稿フォームの保存・取得を実装した流れをまとめます。
作ったもの
今回作ったのは、同窓会やクラス会の参加者にURLを共有して、近況を投稿してもらうための小さなWebアプリです。
グループごとにURLを持っていて、参加者はそのURLから近況を投稿でき、投稿された内容は一覧画面で確認できます。
主な画面は次の3つです。
- グループホーム
- 近況投稿フォーム
- 近況一覧
参加者にはグループホームのURLを共有します。そこから投稿フォームに進んで近況を書き、投稿後はみんなの近況一覧を見ることができます。
このPhase1では、ログインや管理画面はまだ作っていません。まずは「URLを送るだけで近況を集められる」状態を目指しました。
技術構成
今回使った主な技術は次のとおりです。
- React + Vite:画面を作る
- React Router:URLごとに画面を切り替える
- Hono:APIを作る
- Cloudflare Workers:APIを動かす
- Cloudflare D1:データを保存する
- Drizzle ORM:TypeScriptからDBを扱う
- Zod:入力値をチェックする
- react-hook-form:フォームを扱う
フロントエンドは React + Vite で作りました。React Router を使って、グループホーム、投稿フォーム、近況一覧の3つの画面をURLで切り替えています。
バックエンドは Hono でAPIを作り、Cloudflare Workers 上で動かしています。DBには Cloudflare D1 を使い、Drizzle ORM でテーブル定義やクエリを書いています。
フォームまわりは react-hook-form と Zod を使いました。Zodで投稿内容のスキーマを定義しておくと、フォーム側でもAPI側でも同じルールで入力値をチェックできるので便利でした。
ルーティング設計
今回は、グループごとにURLを持たせるために slug をURLに含める設計にしました。
フロントエンド側では React Router を使って、次のような画面ルートを用意しています。
/g/:slug
/g/:slug/new
/g/:slug/classmates
:slug はグループを識別する文字列です。たとえば oita-2016 というグループなら、次のようなURLになります。
/g/oita-2016
/g/oita-2016/new
/g/oita-2016/classmates
API側も同じように slug を受け取り、その slug に対応するグループを取得します。
GET /api/groups/:slug
GET /api/groups/:slug/classmates
POST /api/groups/:slug/classmates
投稿一覧を取得するときも、投稿を作成するときも、まず groupsテーブルから slug に一致するグループを探します。
その後、取得した group.id を使って classmates テーブルの投稿を読み書きします。
流れとしては次のようになります。
/g/oita-2016 にアクセス
↓
React Router が slug = oita-2016 を取り出す
↓
GET /api/groups/oita-2016 を呼ぶ
↓
API側で groups.slug = oita-2016 のグループを取得
↓
group.id を使って近況データを取得・投稿する
この形にしておくと、URLを共有するだけで特定のグループに紐づいた投稿フォームを開けます。
また、グループが増えてもURLの形は変わらず、slug だけでどのグループかを判定できます。
データ設計
Phase1では、グループ情報と投稿内容を保存できればよいので、テーブルは最小限にしました。
用意したテーブルは次の2つです。
groupsclassmates
groups は、近況を集める単位を表すテーブルです。
同窓会やクラス会ごとに1つのグループを作る想定です。
主なカラムは次のとおりです。
groups
- id
- name
- slug
- description
- created_at
- updated_at
slug はURLでグループを識別するために使います。
たとえば oita-2016 という slug なら、/g/oita-2016のようなURLになります。
classmates は、参加者が投稿した近況を保存するテーブルです。
classmates
- id
- group_id
- name
- nickname
- current_location
- job
- comment
- sns_url
- visibility
- status
- created_at
- updated_at
Phase1で必要なのは、名前、現在地、仕事・活動、近況コメントなどの投稿内容です。
ただし、あとから管理機能や公開範囲を追加しやすいように、status と visibilityも最初から持たせています。
status は投稿の状態を表します。
published
hidden
deleted
Phase1では基本的に published の投稿だけを扱います。
将来的に管理画面を作ったときに、不適切な投稿を hidden にしたり、削除扱いとしてdeleted にしたりする想定です。
visibility は公開範囲を表します。
public
organizer_only
private
Phase1では一覧に出す投稿を public に絞っています。
今後、幹事だけが見られる投稿や、本人だけが管理できる投稿のような機能を追加する余地を残しています。
最初から作り込みすぎるとMVPが重くなるので、Phase1では使う機能を絞りつつ、あとで必要になりそうな状態管理だけはDBに入れておく形にしました。
投稿フォームの実装
投稿フォームには react-hook-form と Zod を使いました。
react-hook-form はフォームの状態管理を担当し、Zod は入力値の検証ルールを担当します。
今回は投稿内容のスキーマを共通ファイルに定義して、フロントエンドとAPI側の両方で同じルールを使えるようにしました。
フォームで入力する項目は次のようなものです。
- 名前
- 当時の呼び名
- 今住んでいる地域
- 仕事・活動
- 近況コメント
- SNS URL
- 公開範囲
名前、現在地、仕事・活動、近況コメントは必須にしています。
SNS URLは任意ですが、入力された場合はURLとして正しい形式かをチェックしています。
たとえば、Zod側では次のようにURLバリデーションを入れています。
snsUrl: z
.string()
.trim()
.max(200, 'SNS URLは200文字以内で入力してください')
.optional()
.default('')
.refine((value) => value === '' || isValidUrl(value), {
message: 'SNS URLの形式を確認してください',
})
value === '' を許可しているので、未入力の場合はエラーにせず、入力されたときだけURL形式をチェックします。
投稿が成功したら、近況一覧画面に遷移します。
入力して終わりではなく、そのまま投稿結果を見られるようにすることで、参加者が「ちゃんと投稿できた」と分かりやすくしています。
また、投稿フォームでは下書き保存も入れています。
途中で画面を離れても入力内容が消えにくいように、ブラウザ側に一時保存する形にしました。スマホで入力することも想定しているので、長めの近況コメントを書いている途中で消えてしまうストレスを減らすためです。
Hono APIの実装
Phase1で用意したAPIは次の3つです。
GET /api/groups/:slug
GET /api/groups/:slug/classmates
POST /api/groups/:slug/classmates
GET /api/groups/:slug は、URLの slug に対応するグループ情報を取得するAPIです。
存在しない slug の場合は404を返します。
GET /api/groups/:slug/classmates は、そのグループに紐づく近況一覧を取得するAPIです。
まず groups テーブルから slug に一致するグループを取得し、その group.id を使って classmates テーブルを検索します。
POST /api/groups/:slug/classmates は、近況を投稿するAPIです。
こちらもまず slug からグループを取得し、存在するグループに対して投稿を保存します。
入力値の検証には、フロントエンドと同じ Zod スキーマを使っています。
const input = classmateInputSchema.parse(await c.req.json())
API側でも検証することで、フロントエンドを通さないリクエストが来た場合でも、不正なデータがDBに入らないようにしています。
エラーレスポンスも最低限整えています。
Zodのバリデーションエラーが発生した場合は、400 と一緒に入力内容を確認してもらうメッセージを返します。
if (error instanceof ZodError) {
return c.json(
{
message: '入力内容を確認してください',
issues: error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
},
400,
)
}
これにより、フォーム側でもAPI側でも同じルールで入力値を扱えるようにしています。
公開一覧の条件
近況一覧に表示する投稿は、すべての投稿ではなく、公開対象の投稿だけに絞っています。
条件は次の2つです。
status = published
visibility = public
status は投稿の状態を表します。
Phase1では投稿時に published として保存していますが、将来的には管理画面から hidden や deleted に変更できるようにする想定です。
visibility は公開範囲を表します。
Phase1では public の投稿だけを一覧に出しています。
API側では、一覧取得時に次のような条件で絞り込んでいます。
.where(
and(
eq(classmates.groupId, group.id),
eq(classmates.status, 'published'),
eq(classmates.visibility, 'public'),
),
)
今のPhase1では管理画面や限定公開はまだありません。
ただ、最初から status と visibility を持たせておくことで、あとから投稿の非表示、削除、幹事だけに見せる投稿などを追加しやすくしています。
MVPでは機能を作り込みすぎないようにしつつ、あとから拡張しやすい最低限の状態だけDBに持たせるようにしました。
作っていて悩んだところ
作っていて一番悩んだのは、Phase1にどこまで機能を入れるかです。
最初から認証を入れるか、グループ作成機能まで作るか、画像アップロードも入れるかなど、やりたいことはいくつかありました。
ただ、それらを全部入れようとすると、最初のMVPとしては少し重くなります。
今回まず確認したかったのは、「URLを共有して、参加者に近況を書いてもらう」という体験が成り立つかどうかです。
そのため、Phase1ではログインや管理画面はいったん入れず、固定のグループURLから投稿できる形にしました。
グループ作成も後回しにしています。
本来はユーザーが自分でグループを作れるほうが自然ですが、最初の検証では1つのグループが動けば十分だと考えました。
画像アップロードも同じ理由で後回しにしました。
あると便利ですが、保存先、容量制限、アップロードUI、表示方法など考えることが増えます。まずはテキストの近況だけで成立するかを見ることにしました。
MVPでは「便利そうな機能」を足すよりも、「これがないと体験が成立しない機能」だけを残すことが大事だと感じました。
今後やること
今後追加したい主な機能は次のとおりです。
- 管理画面
- 投稿の非表示・削除
- グループ作成
- 合言葉
- 画像アップロード
Phase1では、URLを共有して近況を投稿・閲覧するところまでを作りました。
今後は、実際に運用するために必要な機能を順番に追加していく予定です。
まずは管理画面を作り、投稿の非表示や削除ができるようにします。
URLを知っていれば投稿できる形なので、不適切な投稿に対応できる仕組みも必要になりそうです。
その後、グループ作成機能を追加して、ユーザーが自分で近況募集ページを作れるようにします。
また、グループごとの合言葉や、画像アップロードも追加候補です。
合言葉があるとURLを知っている人だけでなく、さらに簡単な参加制限ができます。画像アップロードは、当時の写真や最近の写真を共有できるようにするために入れたい機能です。
まとめ
今回は、React + Hono APIでURL共有型の投稿フォームを作りました。
URL設計を先に決めておくと、画面ルートとAPIルートの対応が分かりやすくなり、実装も進めやすかったです。
特に slug をURLに含めることで、どのグループの投稿なのかをシンプルに扱えました。
また、MVPとしては「投稿できる」「一覧で見られる」だけでも十分に検証できると感じました。
最初から認証、管理画面、画像アップロードまで作り込むのではなく、まずは一番小さい体験を動かすことを優先してよかったです。
今後は、管理機能やグループ作成機能を追加して、実際に使いやすい形に近づけていきます。