この記事は Zenn で公開している記事のクロスポストです。
結論から言うと、夫婦2人だけで使う生活アプリを約1年・326コミットで作り、途中で土台をぜんぶ入れ替え、いまは毎日開いてます。 この記事は、その「設計のぜんぶ」を1本で見せる保存版です。
- 何のアプリ:レシピ・家計簿・カレンダー・やりたいこと…生活まわりを全部1個に詰めた、2人専用アプリ
- どう作ってるか:Next.js 16 / Cloudflare(Workers + D1 + R2)に全部寄せて、1人で保守できる形に
- 読むと分かること:個人開発で「2人専用」に振り切ると、認証もDBもAIもどう簡単になるか
📖 この記事のテーマ:夫婦2人だけのアプリを「Cloudflare 全部乗せ」で作った設計を、コード・図・実測つきで1本にまとめる
🔍 読むと分かる:2人専用に振り切ると、認証もDBもAIもどう簡単になるか(=土台を軽くするほど個人開発は続く)
🛠 この開発はほぼ全部 Claude Code(CLI)と一緒に進めています。要所で「実際に投げたプロンプト」をマスク済みで載せます。とはいえ正解の使い方はまだ掴めてなくて、ずっと模索中です。
まず、どんなアプリ?
ざっくり言うと、夫婦の生活まわりを"全部入り"にした、自分たち専用アプリです。
- 🍳 レシピ・献立 … 写真やURLからAIで取り込み、何人前かの換算も
- 💰 家計簿 … レシートをAIで読み取り、明細・月予算まで管理
- 📅 カレンダー・予定・記念日
- 🛒 買い物リスト・ToDo … LINEで「牛乳買っといて」でも入る
- 🗺 やりたいこと … 行った県を地図で塗る
- 🤖 AI執事 … アプリ全体を自然文で操作・相談できる相棒
要は「生活で使うやつ、ぜんぶ1個に詰めた」。以下は、その中身の設計と「なぜ」を、コード付きで開けていく話です。
途中には回り道もありました。実はこのアプリ、2回も長期放置しています。5ヶ月まるごと放置(コミット0)→ 少し進めてまた数ヶ月ストップ。ある日「これ誰のアプリ?」と我に返って、カップル向けSaaSを2人専用に振り切って1日で全部作り直し。そこから毎日触るアプリに化けました(6月だけで225コミット。多い日は1日47)。ここから先の設計判断は、だいたいこの回り道の産物です。
0. いちばん上の設計判断:「2人専用」に振り切る
Futari の設計で、いちばん上に置いた決め事はこれです。
万人向けの汎用アプリにしない。登録された2人だけが使う。
最初はカップル向けSaaS(招待コード・マルチテナント・RLS〈行レベルセキュリティ=DBの行ごとに見せる相手を制御する仕組み〉で世帯ごとにデータ分離…)として作っていました。でも、ある日ふと思ったんです。
「これ誰のアプリ?」
自分たちが毎日使いたいだけなのに、他人のための複雑さを抱えていた。そこで「2人専用」に振り切って、土台ごと作り直しました。この1個の判断が、後ろの全部(認証・DB・AI)を簡単にしていきます。
| 振り切る前(カップルSaaS) | 振り切った後(2人専用) | |
|---|---|---|
| ユーザー | 不特定多数・世帯ごと分離 | DB上に2行だけ |
| 認可 | 多段RLSで行レベル制御 | 「2人のどっちか」だけ判定 |
| 招待・オンボーディング | 必要 | 不要(全部消した) |
| 認証 | SaaS型 | Googleログイン+自前JWT(本人確認だけ外部に任せる) |
注目してほしいのは、「2人専用」と決めただけで、RLSも招待コードもオンボーディングも丸ごと消えたこと。機能を足すより、土台を軽くするほうが1人だと続きます。
💭 本人メモ(開発中の実発言)
「土台が重かったから止まってた。軽くしたら勝手に続いた。」
1. 全体アーキテクチャ:入口は1個のWorkerに寄せる
技術スタックはこうです。
- フレームワーク:Next.js 16(App Router)+ React 19 / TypeScript strict
- 基盤:OpenNext 経由で Cloudflare Workers
- DB:Cloudflare D1(SQLite)/ORMなし・生SQL
- ストレージ:Cloudflare R2(画像・PDF)
- キャッシュ:Workers KV(表示キャッシュ)
- AI:外部API(Claude / Gemini)+ Cloudflare Workers AI(binding)
Cloudflare まわりの用語をざっと
- Worker:Cloudflare上で動く"アプリ本体"。世界中のサーバーで動く小さなプログラム、くらいの理解でOK。
- D1:Cloudflareのデータベース(SQLite)。予定や家計簿などのデータを保存する場所。
- R2:Cloudflareのファイル置き場。写真やPDFを入れる倉庫。
- KV:超高速のメモ帳(キャッシュ)。よく使う値を一時的に持っておく場所。
- OpenNext:Next.js(このアプリのフレームワーク)を、Cloudflare Workerの上で動かすための"変換アダプタ"。
- cron(クロン):「毎日◯時」「5分ごと」みたいに定期実行する仕掛け。
-
binding(バインディング):Worker から D1 や R2 に繋ぐ"接続口"。コードから直接
env.DBのように呼べる。
この図は左→右で読んでください。
- ① 入口 … 2つだけ(2人のアプリ と LINE)
- ② Cloudflare … 頭脳のメインWorker も データ(D1/R2/KV)も 定期処理も、ぜんぶこの中
- ③ 外に出る … 本人確認(Google)と AI(Claude/Gemini)が中心
注目してほしいのは、入口は1個のWorkerにまとまっていて、データ(D1/R2/KV)はぜんぶ Cloudflare 側に閉じていること。外部に頼るのは、本人確認(Google)・AI(Claude/Gemini)・通知(LINE への push)・要望メモ(GitHub issue)だけです(GitHub issue は執事のツール経由なので、上の図では省略しています)。定期処理も、メインを汚さないように補助Worker2本(リマインドとバックアップ)に逃がしています。
| 補助Worker | 役割 | cron |
|---|---|---|
| reminder-worker | 予定/ToDoの指定時刻リマインドを LINE へ push | 5分毎 |
| backup-worker | D1ダンプ+R2画像をバックアップ(古い分は自動削除) | 日次 |
なぜ「全部乗せ」なのか
「2人専用」という1つの判断で、前の4サービスが後は1つのWorkerのbindingに集約される。テナント分離の作り込みも2人向けに簡素化。
最初は Vercel + Supabase(PostgreSQL + RLS + Supabase Auth + Storage)で作っていました。それを1日でぜんぶ Cloudflare に入れ替えました。理由はシンプルで、2人専用に振り切ったら、Supabaseの多機能がオーバースペックになったからです。
| 前(Vercel + Supabase) | 後(Cloudflare 全部乗せ) | |
|---|---|---|
| DB | PostgreSQL + RLS | D1(SQLite)・RLSなし |
| 認証 | Supabase Auth | Google OAuth + 自前JWT |
| ストレージ | Supabase Storage | R2 |
| ホスティング | Vercel | Workers(OpenNext) |
| 管理画面 | 複数サービス往復 | ほぼ1箇所 |
注目してほしいのは、左の4サービスが右では全部1つのWorkerにぶら下がること。多段RLSも招待コードもこの差し替えで丸ごと消えました。「2人専用」という1個の判断が、土台をここまで軽くします。
⚠️ ハマり:デプロイ確認が当てにならない
OpenNext 環境だと、デプロイ一覧コマンドが古い日付を返して当てになりませんでした(これで何度か「反映された?」を勘違いしました)。なので確認はこうしています。
# ❌ これは古い情報を返すことがある
# wrangler deployments list
# ✅ 実物を叩いて確認する
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/api/health
# もしくは deploy 出力末尾の "Current Version ID" を見る
地味だけど、ここは時間を溶かしたポイントでした。
2. 認証:RLSを捨てて「認証」と「認可」を分ける
2人専用にしたことで、認証はぐっと単純になりました。まず前提から。
以前は PostgreSQL の RLS(§0でも触れた、行ごとに「誰が見ていいか」をDBが制御する仕組み)で、世帯ごとにデータを分けていました。不特定多数が使うSaaSなら必須の機能です。でも Futari は利用者が2人だけ。世帯もテナントも1つしかない。RLSで守るべき「他人のデータ」が、そもそも存在しないんです。
| RLSあり(不特定多数向け) | RLSなし(2人専用) | |
|---|---|---|
| 守る対象 | 世帯ごとの他人データ | 存在しない(2人で全共有) |
| DBの複雑さ | ポリシーを行ごとに書く | 素の SELECT でよい |
| 認可の考え方 | 行レベルで細かく | 「2人のどっちか」だけ |
そのうえで、守りは 「認証(誰か確かめる)」と「認可(何を許すか)」をキッパリ分ける設計にしました。
- 認証 … Google ログイン(本人確認だけ外部に任せる)
- 認可 … 「そのメールアドレスが、DB上の2人のどちらかか?」を自前で判定
入口は2系統でも、通す/拒否を決めるのは「usersの許可2名のどちらか」という1つの認可。Google に任せるのは本人確認だけで、「使っていいか」は自分たちのDBが最終判断する。
cookieの無いLINEで、どう本人を通すか
ここが本題。ブラウザなら「ログイン済み」を cookie で持ち回せます。でもLINEのWebhook(LINEがこちらのサーバーに送ってくる通知)には cookie がありません。
そこで、AsyncLocalStorage(1回の処理の間だけ、こっそり値を持ち回せる Node.js の仕組み)を使って、「いまの処理は"この人"として動く」を差し込めるようにしました。それが runAsUser です。
import { AsyncLocalStorage } from 'node:async_hooks'
// 「いまのユーザー」を一時的に上書きするための箱
const userOverride = new AsyncLocalStorage<User>()
// 確定済みの本人として fn を実行する(この中の getCurrentUser は必ずこの人を返す)
export function runAsUser<T>(user: User, fn: () => Promise<T>): Promise<T> {
return userOverride.run(user, fn)
}
// 開発用の擬似ユーザー。本番(NODE_ENV==='production')では必ず null を返す(多層防御)
function devEmail(): string | null {
if (process.env.NODE_ENV === 'production') return null
return process.env.DEV_USER_EMAIL || null
}
// 現在のユーザーは、まず上書きを最優先で見る
export const getCurrentUser = cache(async (): Promise<User | null> => {
// 1. runAsUser の上書きがあれば最優先(=LINEなどcookie無し文脈)
const override = userOverride.getStore()
if (override) return override
// 2. 通常はセッションcookieを検証して email を得る(本番のみ。開発時だけ擬似ユーザーにフォールバック)
const email = (await verifySessionEmail()) ?? devEmail()
if (!email) return null
// 3. その email が users の2行に一致すれば本人(=認可)
return getUserByEmail(email)
})
LINE側では、まず「署名の検証」と「発言者IDが登録済みか」を確かめて本人を確定し、そのうえで runAsUser で包んで実行します。
// LINE webhook 側(本人を確定してから包む)
const user = speakerId ? await getUserByLineUserId(speakerId) : null
if (!user) return // 登録外は無視
await runAsUser(user, async () => {
await runConcierge(command, history, []) // この中は user として安全に動く
})
注目してほしいのは、runAsUser を 「本人確認が済んだあと」にしか呼ばないこと。ここを無検証で呼ぶと、誰でも他人になりすませてしまう。だから「署名検証 → 登録ユーザー照合 → runAsUser」の順番が絶対です。効くのは2人専用だからで、世帯ごとの行レベル制御(RLS)を捨てられたぶん、認可は「2人のどっちか」を1回特定すれば終わります。
3. データモデル:正規化+生SQL(ORMなし)
DBは Cloudflare D1(Workers上で動く SQLite)。そしてORMを入れず、生のSQLで書いています。個人開発でORM無しって不安に見えるかもですが、2人専用アプリだと、これがいちばん素直でした。
スキーマは凝ったことをせず、1テーブル1責務で素の CREATE TABLE。
-- users は「常に2行だけ」
CREATE TABLE users (
id text PRIMARY KEY,
email text UNIQUE NOT NULL,
display_name text,
avatar_url text,
created_at text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- todos(真偽値は 0/1 の integer で持つ)
CREATE TABLE todos (
id text PRIMARY KEY,
title text NOT NULL,
is_done integer NOT NULL DEFAULT 0,
sort_order integer NOT NULL DEFAULT 0,
created_by text REFERENCES users(id),
created_at text NOT NULL DEFAULT CURRENT_TIMESTAMP,
done_at text
);
CREATE INDEX idx_todos_active ON todos(is_done, sort_order, created_at);
SQLite なので真偽値は 0/1 の整数、日付は ISO8601 の文字列で持ちます。よく絞る列(日付・状態)にはインデックスを貼る。それくらい。主キーはアプリ側で crypto.randomUUID() 採番していて、挿入前にIDが確定できる(=楽観的UIとかみ合う)ようにしています。
クエリは生SQL。ただし文字列結合はしない
ORMは使いませんが、SQLに値を文字列で埋め込むことは絶対にしません。必ずプレースホルダ(?)でバインドします。ここを守れば、生SQLでもインジェクションは防げます。
// 月別の支出(JOINあり・複数バインド)
export async function listExpensesByMonth(ym: string): Promise<ExpenseWithMeta[]> {
const { start, end } = monthRange(ym)
const { results } = await getDB()
.prepare(
`SELECT e.id, e.date, e.amount, e.discount_amount, e.category_id,
ec.name AS category_name, e.memo, e.external_ref
FROM expenses e
LEFT JOIN expense_categories ec ON ec.id = e.category_id
WHERE e.date >= ?1 AND e.date < ?2
ORDER BY e.date DESC, e.created_at DESC`,
)
.bind(start, end) // ← 値は必ずバインドで渡す
.all<ExpenseWithMeta>()
return results
}
主なテーブルだけ抜粋するとこう。生活系の主要テーブル(recipes・todos・events・wishlist_items など)に created_by を持たせているのがポイントで、「どっちがやったか」は2人の生活では地味にいちばん大事な情報でした。
-
users:常に2行。
line_user_idで「発言者が許可ユーザーか」も判定 -
events / todos:
remind_atreminded_at(指定時刻リマインドと二重送信防止) - recipes / recipe_steps / meal_plans:レシピ・手順写真・献立
-
expenses / expense_items / budgets:家計簿(明細=
expense_items、全体割引=discount_amount、月予算=budgets) - wishlist_items:やりたいこと(緯度経度で日本地図に塗る)
-
user_memories:執事の長期記憶(
user_idで2人を分離)
家計簿(expenses)は、当初こそ paid_by で「どっちが払ったか」を持たせていました。でも明細化(expense_items +全体割引 discount_amount)に作り替えたとき、思い切って撤去しています。2人で全部共有する以上、支払者を1人に紐づける意味が薄かったからです。「作りながら削る」がいちばん効いた例のひとつでした。
usersをハブに created_by / user_id の2系統で1:N(家計簿は2人共有なので user 列なし)。レシピと家電は、親を消しても子・孫が取り残されない、users にも依存しない独立した2つの ON DELETE CASCADE 階層。「2人専用で分離が要らない」から、関係もこれくらい素直に張れました。
⚠️ ハマり:手動でDBをいじってドリフトした
一度、本番D1に手で SQL を流してスキーマを変えたことがあります。これが事故のもとでした。マイグレーションの管理テーブル(d1_migrations)と実際のスキーマがズレて(ドリフトして)、次のマイグレーションが当たらなくなった。以来、スキーマ変更は必ずコマンド経由に統一しています。
// package.json
"db:migrate:remote": "wrangler d1 migrations apply futari-db --remote"
手で直したくなる誘惑はあるけど、「番号で管理された変更履歴」から絶対に外れない。個人開発でも、ここは規律を効かせる価値がありました。
💭 本人メモ(実発言)
「家計簿はかなりしっかりと作りたい。アプリ内にAIも入れていき、全機能で活用してきたい。」
4. AI執事:アプリ全体を「喋って操作」する
Futari の中核がこれ。「今日の予定は?」「牛乳を買い物に追加」「明日10時に歯医者」みたいに、自然文で答える/操作する執事を、Claude の tool use(ツール呼び出し)でエージェント的に組んでいます。アプリ内チャットと LINE の両方で同じエンジンを使います。
設計のキモ①:毎回フルスナップショットを送らない
最初は「アプリの全データを毎回まるごとAIに渡す」案でした。でもこれ、トークンもお金も重い。そこで方針転換します。
💭 本人メモ(実発言)
「毎回全部送るのをやめ、ボットが必要な時だけ取りに行く/折衷(おすすめ):件数インデックス常時+詳細オンデマンド(速度キープ・節約も両立)/これにします!」
結果、こうしました。
- 件数インデックスだけ常時注入:「予定◯件・ToDo◯件…」をシステムに常に渡す → 「予定いくつ?」に読み取りゼロで即答
-
詳細はオンデマンド:中身が要るときだけ
read_*ツールでAIが自分で取りに行く
注目してほしいのは、「速度」と「節約」がトレードオフじゃなく両立したこと。常時は軽く、深掘りは必要時だけ、に分けたのが効きました。
設計のキモ②:モデルを意図で使い分ける
雑談にまで賢いモデルを使うのは無駄。そこで、まず軽量モデル(Haiku)で発話を分類してから、モデルとラウンド数を切り替えます。
| 種別 | モデル | ラウンド | web検索 | 用途 |
|---|---|---|---|---|
| chat | Haiku | 2 | ❌ | 雑談・軽い質問(速度優先) |
| info | Sonnet | 3 | ✅ | 天気・ニュースなど「今の情報」の検索即答(出典URL付き) |
| engineer | Sonnet | 3 | ✅ | 開発相談(「上司でなく相棒」口調) |
| task | Sonnet | 4 | ✅ | 予定追加など実作業(堅実) |
雑談中でも「今日の天気は?」のような"今の情報"が要る発話は、分類器が info に振り分けて web検索してから答えます(chat 自体は検索せず、速度優先のまま)。
設計のキモ③:書き込みは入口ごとにゲートの高さを変える
書き込み系ツール(add_todo add_event add_expense…)の実行は、入口によって守り方を変えています。
- アプリ内チャット:即実行しません。いったん proposal(提案)で止めて画面に確認カードを出し、人が「実行する」を押して初めて走ります。AIの誤解釈を、人が最後に弾けるようにするためです。
-
LINE経由:トークには確認カードを出せません。そこで、低リスクな追加系ツール(買い物・ToDo・予定など)だけを fail-closed の許可リストで自動実行し、それ以外(家計=
add_expense・外部書き込み=GitHub issue・削除系)は自動実行せずアプリでの確認へ誘導します。外部から届く文面(LINEの受信メッセージ)を起点にした間接プロンプトインジェクションで「勝手に書き込み」が起きないための線引きです。
設計のキモ④:長期記憶は2人で分ける
remember ツールで、相手のことをユーザー単位で覚えます(user_id で2人を分離)。毎回のシステムプロンプトには少量だけ注入。2人の記憶は混ざりません。
💭 本人メモ(実発言)
「Loadingで待たされるのは本当にいやだ」── 執事も家計簿も、この一言が設計の裏にずっと効いてます。
5. LINE連携:アプリを開かなくても生活が回る
「アプリを開く」より一段ハードルの低い入口として、LINEを統合しました。公式アカウントのトークで執事に話しかけられます。
無言ゼロ設計(と、30秒の罠)
LINEは返信が遅いと「無言」になって不安。そこでこうしています。
- 受信した瞬間にローディング表示を出す
- すぐ
200を返す - 処理はバックグラウンドで回す
- できたら push で後追い返信
⚠️ ハマったのがここ。バックグラウンド処理は応答後およそ30秒で強制キャンセルされます。なので「25秒で打ち切ってフォールバック文言を push」して、黙ったままを物理的に防いでいます。
アカウント連携:ミニアプリをやめて「DMにコードを送るだけ」に
当初はLINEミニアプリ(LIFF)内でコードを入力させる方式でした。が、ミニアプリの起動が環境設定に依存して不安定で、正直詰まりました。そこで、トークにコードを送るだけの方式に切り替えました。
アプリの[設定→アカウント]→「LINEで連携」を押す
→ 公式アカウントのトークが、コード入力済みで開く
→ そのまま送信するだけ
→ webhookがコードを検証して、そのLINEを本人に紐づけ
- 連携コードは短命・単回・高エントロピー。「コードを持っている=許可ユーザーである証明」という設計
- トークを開くディープリンクは、ボット情報APIから自分のIDを引いて組み立てる(IDを別管理しない)
注目してほしいのは、結局いちばん確実な道がいちばんシンプルだったこと。ミニアプリで粘るより、「DMにコード送るだけ」のほうが、設定に左右されず確実に動きました。
🛠 Claude Codeメモ:この入れ替えは、調査→設計レビュー→実装→デプロイ→実機確認まで一気通貫で任せました。実際に投げたのはこんな指示です(マスク済み・ほぼ原文)。
わかりやすい連携方式を策定してデプロイまでせよ。
Futariアプリから連携押したらLINEに飛んでなんとかして紐づけられるようにせよ。
最後までやれ。
「最後までやれ」で任せつつ、認証まわりの不可逆な判断だけは途中でレビューを挟む。この距離感が、いまのところ自分には合っています。
リマインドとリッチメニュー
- リマインド:5分毎の cron が、予定/ToDoの指定時刻が近いものを LINE のグループへ push(二重送信は送信済みフラグで防止、文面は固定テンプレート)
- リッチメニュー:トーク下部の常設ボタン。LINEは相手のダークモードを通知しないので、1枚で両対応の画像にしています
6. 性能:体感の遅さを、具体策に落とす
非機能要件、とくに速度にはかなりこだわりました。きっかけはいつも体感です。
💭 本人メモ(実発言)
「画面表示が遅い時が多いよね?/ある程度キャッシュに持たせていいものはキャッシュ化していいんじゃない?/全てデータベースから毎回じゃなくても?/特に画像ある系はね!」
ここから、こう落としました。
- 毎回DBをやめる:表示用キャッシュ(KV)+件数の事前計算で、開いた瞬間に出す
- 非同期前提のUI:仮保存で画面描画を崩さない(崩れると「押し漏れ」という実害が出る)
💭 本人メモ(実発言)
「仮保存される際には画面描画に影響は残さないでほしい。押した後に描画崩れると押し漏れの可能性が出てくるから非同期で全て走らせる前提」
「遅い・崩れる」を放置せず、毎回ちゃんと具体策に落とす。地味だけど、これが「毎日開ける」の正体だと思っています。
7. 設計判断ログ(なぜそうしたか)
後から「なぜこうなってる?」を掘り直さないための、自分用の記録です。個人開発こそ、この"なぜ"が効きます。
| 判断 | なぜ |
|---|---|
| 2人専用(ユーザー2行) | 汎用化の複雑さを捨てる。RLSも招待も消える |
| 認証はGoogle OAuth+自前JWTに一本化 | 本人確認はGoogleに任せ、認可は「2人のどっちか」判定だけ。私的データは2人に閉じる |
| runAsUser(AsyncLocalStorage) | cookieの無いLINE文脈でも本人として安全に書き込む |
| 執事は件数常時+詳細オンデマンド | 速度と節約を両立(毎回フル送信をやめた) |
| 執事のモデル使い分け | 雑談まで賢いモデルは無駄。意図で軽重を最適化 |
| 書き込みはアプリでは確認カードで一回止める | AIの誤解釈を人が最後に弾く |
| 高影響ツールはLINE自動実行しない | 外部入力起点の間接プロンプトインジェクション対策 |
| 無言ゼロ+25秒打ち切り | バックグラウンド処理の30秒強制キャンセル対策 |
| DMコード連携を本筋に | ミニアプリ起動が設定依存で不安定。DMが確実 |
| 生SQL+framework経由migration | 手動適用でのスキーマdrift対策 |
8. 本音:まだ手探りなところ
正直に書いておきます。
- AIコストの最適化はまだ途中。件数インデックス+オンデマンドで軽くはしたけど、ベストかは分からない。
-
生SQLの型は手当て。
<ExpenseWithMeta>を手で当てているので、スキーマとズレたら実行時まで気づかない。ここはORMの型安全が恋しくなる瞬間。 - Claude Codeの使い方も模索中。「任せる×分岐だけ握る」が今の距離感だけど、これが正解とも思ってない。
- 2人専用=拡張性は捨てた。これは弱点じゃなく選択。でも「他の家族にも」と言われたら、いまの設計だと作り直しになる。
それでも、土台を軽くしてからは毎日開けています。「続いた理由」は根性じゃなく、設計だったというのが、約1年やってみての結論です。
おわりに
「2人専用・永続」に振り切ったことで、マルチテナント分離も複雑な権限管理も要らなくなり、その分の体力を手触りとAI執事に注げました。同じ思想で、家族向け・少人数向けのアプリを作る人に、何か一つでも参考になれば嬉しいです。
もっといいやり方あったら、逆に教えてください。まだ探してます。
関連記事
同じアプリ(Futari)の開発から生まれた記事です。








