個人開発AIアプリのフィードバックループ設計 — Tally × Slack × Build in Public
個人開発で最も見落とされるのが、ユーザーからのフィードバック収集だ。作って公開して終わり。ユーザーが何に困っているか、何を求めているかを知らないまま、開発者の思い込みで機能を積み上げていく。
筆者が開発中の recipe-ai(YouTube 料理動画からレシピを AI 抽出するアプリ)では、MVP の段階からフィードバックループを組み込んだ。Tally でフォームを作り、Webhook で自前の API に飛ばし、Slack に通知する。かかるコストはゼロ円。この記事では、その全体設計と実装コードを公開する。
この記事でわかること:
- ゼロ円で構築できるフィードバック収集の全体設計
- Tally フォームの iframe 埋め込みと設計のコツ
- Webhook 受信 → Slack Block Kit 通知の Next.js API Route 実装
- Build in Public とフィードバックの相乗効果
なぜフィードバックループが必要か
個人開発者にとって、ユーザーの声は最も安い市場調査だ。広告を出す前に、目の前にいるユーザーが何を考えているかを聞くほうがはるかに効率がいい。
Build in Public の本質は「開発の過程を公開すること」ではない。ユーザーとの対話だ。フィードバックを受け取り、改善し、その過程をまた公開する。このループが回り始めると、プロダクトとコンテンツの両方が同時に育つ。
問題は、フィードバック収集の仕組みが面倒に見えることだ。Google Forms で作ってスプレッドシートに溜めるのが定番だが、通知が来ないので見忘れる。SaaS のフィードバックツールを導入すると月額費用がかかる。
必要なのは、ユーザーが送った瞬間に開発者の手元に届く仕組みを、ゼロ円で作ることだ。
全体設計: Tally → Webhook → Slack
今回の構成はシンプルな 3 段階のパイプラインになっている。
ユーザー
|
v
[Tally フォーム(iframe 埋め込み)]
| フォーム送信
v
[Tally Webhook]
| POST リクエスト
v
[recipe-ai API Route: /api/tally-webhook]
| 整形 + 転送
v
[Slack Incoming Webhook]
| Block Kit メッセージ
v
開発者の Slack チャンネル
各ツールの役割は明確に分かれている。
- Tally: フォーム UI の提供。Webhook 送信機能が無料プランで使える
- Next.js API Route: Tally の payload を Slack 用に整形する中継地点
- Slack Incoming Webhook: リッチな通知の受け口
コスト内訳は以下の通り。
| ツール | プラン | 月額 |
|---|---|---|
| Tally | Free | 0 円 |
| Slack | Free | 0 円 |
| Next.js API Route | Vercel Hobby | 0 円 |
| 合計 | 0 円 |
なぜ Tally から直接 Slack に送らないのか。Tally の Slack 連携はプレーンテキストしか送れない。API Route を挟むことで Slack Block Kit を使ったリッチな通知が可能になり、将来的にデータベースへの保存やフィルタリングも追加できる。
Tally フォームの設置
recipe-ai ではフィードバックページ(/feedback)に Tally フォームを iframe で埋め込んでいる。
// app/[locale]/feedback/page.tsx(抜粋)
const TALLY_EMBED_URL = process.env.NEXT_PUBLIC_TALLY_EMBED_URL ?? "";
// フォーム表示部分
{TALLY_EMBED_URL ? (
<>
<iframe
src={TALLY_EMBED_URL}
title="ご要望・お問い合わせフォーム"
width="100%"
height="720"
loading="lazy"
className="block bg-transparent"
data-tally-src={TALLY_EMBED_URL}
/>
<Script
src="https://tally.so/widgets/embed.js"
strategy="lazyOnload"
/>
</>
) : (
<div className="border border-stone-300 bg-white p-6 text-sm text-stone-600">
フォーム準備中です。しばらくお待ちください。
</div>
)}
ポイントは 3 つ。
1. 環境変数で URL を管理する。 NEXT_PUBLIC_TALLY_EMBED_URL にフォームの Embed URL を入れる。ローカル開発時はフォームなしでもページが壊れないよう、フォールバック UI を出す。
2. loading="lazy" で初期表示を軽くする。 Tally の iframe はページ下部にあることが多いので、ビューポートに入るまで読み込まない。
3. tally.so/widgets/embed.js を lazyOnload で読む。 自動リサイズのために必要だが、ページの初期表示をブロックしない。
フォーム設計のコツ
フォームの項目は最小限にする。recipe-ai では以下の 3 項目に絞っている。
- 種別(バグ報告 / 機能要望 / 感想 / その他)-- 選択式
- 内容 -- 自由記述
- メールアドレス -- 任意。返信希望の場合のみ
項目が多いほど離脱率は上がる。MVP の段階で聞くべきことは「何に困っているか」「何が欲しいか」の 2 点だけだ。
Webhook → Slack 通知の実装
Tally のフォーム設定画面で Webhook URL を登録すると、送信のたびに JSON payload が POST される。recipe-ai ではこれを受け取って Slack に転送する API Route を実装している。
以下が app/api/tally-webhook/route.ts の全コードだ。
import { NextResponse } from "next/server";
type TallyField = {
key: string;
label: string;
type: string;
value: string | string[] | number | boolean | null;
options?: { id: string; text: string }[];
};
type TallyPayload = {
eventId?: string;
eventType?: string;
createdAt?: string;
data?: {
formId?: string;
formName?: string;
fields?: TallyField[];
};
};
function formatFieldValue(f: TallyField): string {
const v = f.value;
if (v == null || v === "") return "(未入力)";
if (Array.isArray(v)) {
// Multiple choice は option ID の配列で来るので label に変換
if (f.options && f.options.length > 0) {
const labels = v
.map((id) => f.options?.find((o) => o.id === id)?.text ?? String(id))
.filter(Boolean);
return labels.join(", ");
}
return v.join(", ");
}
return String(v);
}
export async function POST(request: Request) {
const slackUrl = process.env.SLACK_WEBHOOK_URL;
if (!slackUrl) {
console.error("[tally-webhook] SLACK_WEBHOOK_URL is not set");
return NextResponse.json(
{ ok: false, error: "not_configured" },
{ status: 500 }
);
}
let payload: TallyPayload;
try {
payload = (await request.json()) as TallyPayload;
} catch {
return NextResponse.json(
{ ok: false, error: "invalid_json" },
{ status: 400 }
);
}
const formName = payload.data?.formName ?? "Tally Form";
const fields = payload.data?.fields ?? [];
const created = payload.createdAt ?? new Date().toISOString();
const fieldLines = fields
.map((f) => `*${f.label}*: ${formatFieldValue(f)}`)
.join("\n");
const slackPayload = {
text: `新しいフィードバックが届きました(${formName})`,
blocks: [
{
type: "header",
text: { type: "plain_text", text: `${formName} に新着` },
},
{
type: "section",
text: { type: "mrkdwn", text: fieldLines || "(フィールドなし)" },
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `送信時刻: ${new Date(created).toLocaleString("ja-JP", {
timeZone: "Asia/Tokyo",
})}`,
},
],
},
],
};
try {
const res = await fetch(slackUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(slackPayload),
});
if (!res.ok) {
const body = await res.text();
console.error("[tally-webhook] Slack error:", res.status, body);
return NextResponse.json(
{ ok: false, error: "slack_error", status: res.status },
{ status: 502 }
);
}
} catch (e) {
console.error("[tally-webhook] fetch failed:", e);
return NextResponse.json(
{ ok: false, error: "fetch_failed" },
{ status: 502 }
);
}
return NextResponse.json({ ok: true });
}
コードの要点
型定義で Tally の payload を安全に受け取る。 TallyField には value が string | string[] | number | boolean | null と複数の型がありえる。選択式フィールドは option ID の配列として送られてくるため、options からラベルに変換する処理が必要だ。
Slack Block Kit で構造化する。 header ブロックでフォーム名、section ブロックで各フィールドの内容、context ブロックで送信時刻を表示する。text フィールドのフォールバックテキストも設定しているので、通知が表示できない環境でも内容が読める。
エラーハンドリングは 3 段階。 環境変数未設定(500)、JSON パース失敗(400)、Slack 送信失敗(502)をそれぞれ分けて返す。Tally はリトライ機能があるので、5xx を返せば再送してくれる。
環境変数の設定
必要な環境変数は 2 つだけ。
# Slack Incoming Webhook の URL
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx
# Tally の署名検証用(任意)
TALLY_SIGNING_SECRET=your_signing_secret
Slack Incoming Webhook は、Slack のワークスペース設定 → アプリ → Incoming Webhooks から取得する。チャンネルを指定して Webhook URL を発行するだけだ。
X シェアボタンによる拡散導線
recipe-ai のトップページにはフィードバックページへのリンクに加え、ナビゲーションに「ご要望」リンクを設置している。
<Link
href="/ja/feedback"
className="text-stone-700 hover:text-stone-900 px-2 py-1 hidden sm:inline"
>
ご要望
</Link>
SNS シェアも間接的なフィードバックシグナルとして機能する。ユーザーがアプリを X でシェアしてくれること自体が「使えた」「面白かった」という肯定的なフィードバックだ。
シェアボタンを設置する際のポイントは、プリフィルテキストの設計にある。ユーザーにゼロから文章を書かせない。アプリ名と URL が入った状態で X の投稿画面を開く。ハッシュタグも入れておくと、後からエゴサーチで拾える。
https://twitter.com/intent/tweet?text=recipe-ai で料理動画からレシピを抽出してみた&url=https://recipe-ai.example.com&hashtags=recipe_ai,個人開発
フィードバックフォームとシェアボタンは役割が違う。フォームは「困っていること」「欲しいもの」を拾う。シェアは「満足度」と「認知拡大」を同時に取る。両方あることで、ネガティブとポジティブの両面からユーザーの声が集まる。
Build in Public との相乗効果
フィードバックループと Build in Public は、組み合わせると掛け算になる。
フィードバックをコンテンツに変換できる。 「バグ報告が来た → 原因を調べた → 修正した」という流れは、そのまま技術記事のネタになる。ユーザーの声が起点なので、需要があることが最初から担保されている。
ユーザーの声が次のユーザーを呼ぶ。 「このフィードバックを受けて改善しました」と X で報告すると、他の潜在ユーザーに「自分の声も聞いてもらえそうだ」という印象を与える。個人開発の強みは、大企業にはできないスピード感のある対話だ。
改善の証拠が蓄積される。 Slack に通知が溜まっていくこと自体が、プロダクトが使われている証拠になる。Build in Public で「フィードバック N 件いただきました」と報告できる。数字は信頼を生む。
具体的な運用フローを整理すると、こうなる。
- ユーザーが Tally フォームからフィードバックを送信
- Slack に即座に通知が届く
- 内容を確認し、対応可能なものは即日修正
- 修正内容を X で報告(Build in Public)
- その報告を見た別のユーザーが使い始める
- 新しいユーザーからまたフィードバックが届く
このループが回り始めると、開発のモチベーションも維持しやすい。自分のためだけに作っているのではなく、使ってくれている人がいるという実感がある。
まとめ
Tally(無料)+ Next.js API Route + Slack(無料)で、ゼロ円のフィードバックループが作れる。MVP の段階でこの仕組みを入れておくと、Build in Public のコンテンツ素材とプロダクト改善の両方が同時に手に入る。作って終わりにしない仕組みを、最初から組み込もう。
この記事で作っている YouTube 料理レシピ抽出アプリ本体。動画 URL を貼るだけで AI がレシピに変換。月 5 本まで無料。
関連記事
課金ゼロで始めるAIアプリ収益化
Stripe Payment Links とチップモデルの実装
Claude Code で 0→MVP を1日で作る全記録
recipe-ai Build in Public の全工程を公開
Claude Crew Lab Free — 毎月の実験記録をメールで
Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。
Lab Free 登録(月1回・無料)
この記事が役に立ったらシェア