個人開発SaaSの解約フロー設計 — Stripe Billing Portal × Supabase で「やめた理由」を収集しチャーン原因を潰す
結論:4ファイルで解約フロー設計の骨格が完成する
解約前モーダル + Supabase 記録 + Stripe Billing Portal 連携 + Resend ウィンバックメールの4点セットで、ユーザーが「やめる理由」を自動収集しながら解約フローを完成させる基盤が作れます。
// 解約フローの最小構成
ユーザーが「解約する」ボタンをクリック
→ カスタムモーダルで「やめた理由」を収集 (React)
→ POST /api/subscription/cancel-intent (理由を Supabase に保存)
→ Stripe Billing Portal URL を返す → リダイレクト
→ ユーザーが Stripe 側で解約を確定
→ customer.subscription.deleted Webhook (Stripe)
→ subscriptions テーブルを更新 + Resend でウィンバックメール送信
この記事では、個人開発 SaaS で「解約を学びに変える」フロー設計を、実コード付きで解説します。
masatoman.net で Claude Crew Lab(Free MVP)を運用中です。Standard(¥1,980)・Premium(¥4,980)はローンチ準備段階のため、実際の解約データはまだありません。この記事の実装パターンは Stripe 公式ドキュメント・ChartMogul 業界レポート・Baremetrics ベンチマークをベースにしており、テスト環境で動作確認したコードを含みます。
この記事でわかること:
- なぜ解約フロー設計が個人開発 SaaS で収益に直結するか
- Supabase の
cancellation_reasonsテーブル設計 - 解約前ポップアップ(カスタムモーダル)の実装
- Stripe Billing Portal との連携方法
customer.subscription.deletedWebhook で解約を Supabase に記録する実装- Resend でウィンバックメールを送る実装
- 「で、どう稼ぐ?」— 解約理由データの収益的活用法
解約フローを設計しない代償
個人開発者の多くは「解約」を「終わり」だと思っています。だから解約ボタンに何も仕掛けない。Stripe のデフォルト Billing Portal にそのままリダイレクトして、データは何も残らない。
これは大きな機会損失です。
解約したユーザーは 今まさに最も正直になっている瞬間 です。プロダクトの何が足りなかったか、何が高すぎたか、何を求めていたかを語る準備ができています。この瞬間を素通りすると、以下の情報を永遠に失います。
| 失う情報 | 収益への影響 |
|---|---|
| やめた理由 | PMF(プロダクトマーケットフィット)の改善機会 |
| 価格への不満 | 値付け戦略の見直し機会 |
| 競合への移行先 | 競合分析の素材 |
| ウィンバックのタイミング | 再サブスク の可能性 |
ChartMogul の業界レポート(2024)によると、SaaS の年間チャーンのうち 30〜40% が「一時的な問題」(一時的な資金不足・機能不満が後に解消)に起因しており、ウィンバックキャンペーンの有効性が報告されています。個人開発 SaaS のスケールでは数字は変わりますが、「解約理由を聞く」という基本設計は同じです。
解約フロー設計の目的は2つです。
- PMF データの収集: 理由を知ることで次の機能開発・値付け改善に活かす
- ウィンバック起点の確保: 「一時的な不満」なら再アプローチするチャンスが生まれる
Step 0:Supabase の cancellation_reasons テーブル設計
まず解約理由を保存するテーブルを作ります。
-- Supabase SQL Editor で実行
CREATE TABLE IF NOT EXISTS cancellation_reasons (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
subscription_id text NOT NULL,
reason text NOT NULL CHECK (reason IN (
'too_expensive',
'missing_features',
'not_using',
'switching_to_competitor',
'technical_issues',
'other'
)),
comment text,
canceled_at timestamptz DEFAULT now(),
win_back_sent_at timestamptz,
win_back_email_id text
);
-- RLS: 本人のみ自分の解約理由を参照可能
ALTER TABLE cancellation_reasons ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can insert own cancellation reason"
ON cancellation_reasons FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Admins can read all"
ON cancellation_reasons FOR SELECT
TO service_role
USING (true);
reason は SELECT 型にして自由記述を comment で補完する設計です。自由記述だけだと分析しにくく、選択肢だけだと粒度が粗い。両方を記録します。
Step 1:解約前ポップアップ(カスタムモーダル)の実装
Stripe Billing Portal にそのままリダイレクトする前に、カスタムモーダルを挟みます。ユーザー体験としては「解約する前に少し聞かせてください」という1画面を追加するだけです。
// components/CancelSubscriptionModal.tsx
'use client'
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
const CANCEL_REASONS = [
{ value: 'too_expensive', label: '料金が高い' },
{ value: 'missing_features', label: '欲しい機能がない' },
{ value: 'not_using', label: 'あまり使っていない' },
{ value: 'switching_to_competitor', label: '他のサービスに乗り換える' },
{ value: 'technical_issues', label: '技術的な問題があった' },
{ value: 'other', label: 'その他' },
] as const
type CancelReason = (typeof CANCEL_REASONS)[number]['value']
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CancelSubscriptionModal({ open, onOpenChange }: Props) {
const [reason, setReason] = useState<CancelReason | ''>('')
const [comment, setComment] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
if (!reason) return
setLoading(true)
const res = await fetch('/api/subscription/cancel-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, comment }),
})
const data = await res.json()
if (data.billingPortalUrl) {
window.location.href = data.billingPortalUrl
}
setLoading(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>解約する前に教えてください</DialogTitle>
</DialogHeader>
<p className="text-sm text-gray-500">
フィードバックはプロダクト改善に直接活かします(1分以内)
</p>
<div className="space-y-2 mt-4">
{CANCEL_REASONS.map((r) => (
<label key={r.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="reason"
value={r.value}
checked={reason === r.value}
onChange={() => setReason(r.value)}
/>
<span>{r.label}</span>
</label>
))}
</div>
<textarea
className="w-full mt-3 p-2 border rounded text-sm"
placeholder="詳しく教えてもらえると助かります(任意)"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
/>
<button
onClick={handleSubmit}
disabled={!reason || loading}
className="w-full mt-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
>
{loading ? '処理中...' : '解約を続ける'}
</button>
</DialogContent>
</Dialog>
)
}
Step 2:/api/subscription/cancel-intent の実装
モーダルから POST される API Route です。Supabase に理由を保存し、Stripe Billing Portal の URL を返します。
// app/api/subscription/cancel-intent/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-27.acacia' })
export async function POST(req: NextRequest) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { reason, comment } = await req.json()
if (!reason) {
return NextResponse.json({ error: 'reason is required' }, { status: 400 })
}
// アクティブなサブスクリプションを取得
const { data: sub } = await supabase
.from('subscriptions')
.select('stripe_subscription_id, stripe_customer_id')
.eq('user_id', user.id)
.eq('status', 'active')
.single()
if (!sub) {
return NextResponse.json({ error: 'No active subscription' }, { status: 404 })
}
// 解約理由を Supabase に保存
await supabase.from('cancellation_reasons').insert({
user_id: user.id,
subscription_id: sub.stripe_subscription_id,
reason,
comment: comment || null,
})
// Stripe Billing Portal URL を生成
const session = await stripe.billingPortal.sessions.create({
customer: sub.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
})
return NextResponse.json({ billingPortalUrl: session.url })
}
Step 3:customer.subscription.deleted Webhook でサブスク解約を記録する
Stripe でユーザーが解約を確定すると、customer.subscription.deleted イベントが発火します。このイベントを受け取り、Supabase の subscriptions テーブルを更新します。
既存の Webhook ハンドラに以下のケースを追加します。
// app/api/stripe/webhook/route.ts(既存ハンドラへの追加)
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await supabase
.from('subscriptions')
.update({
status: 'canceled',
canceled_at: new Date(subscription.canceled_at! * 1000).toISOString(),
})
.eq('stripe_subscription_id', subscription.id)
// ウィンバックメールをキューに積む(3日後に送信)
const { data: cancelReason } = await supabase
.from('cancellation_reasons')
.select('id, reason, user_id')
.eq('subscription_id', subscription.id)
.order('canceled_at', { ascending: false })
.limit(1)
.single()
if (cancelReason) {
// Supabase pg_cron や外部スケジューラで3日後に送信するトリガーを記録
// ここでは即時キューへの記録のみ(実装は Step 4 参照)
await supabase.from('win_back_queue').insert({
user_id: cancelReason.user_id,
cancellation_reason_id: cancelReason.id,
reason: cancelReason.reason,
send_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
})
}
break
}
win_back_queue テーブルの簡単な設計:
CREATE TABLE IF NOT EXISTS win_back_queue (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users(id),
cancellation_reason_id uuid REFERENCES cancellation_reasons(id),
reason text,
send_at timestamptz NOT NULL,
sent_at timestamptz,
email_id text
);
Step 4:Resend でウィンバックメールを送る
win_back_queue を定期的に処理する Supabase Edge Function または Vercel Cron を用意し、send_at <= now() のレコードにメールを送ります。
// supabase/functions/send-win-back-emails/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { Resend } from 'https://esm.sh/resend@3'
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const resend = new Resend(Deno.env.get('RESEND_API_KEY')!)
Deno.serve(async () => {
const now = new Date().toISOString()
const { data: queue } = await supabase
.from('win_back_queue')
.select('*, user:user_id(email)')
.lte('send_at', now)
.is('sent_at', null)
.limit(50)
for (const item of queue ?? []) {
const subject = getSubjectByReason(item.reason)
const html = getBodyByReason(item.reason)
const { data: emailData } = await resend.emails.send({
from: 'masato@masatoman.net',
to: item.user.email,
subject,
html,
})
await supabase
.from('win_back_queue')
.update({ sent_at: now, email_id: emailData?.id ?? null })
.eq('id', item.id)
await supabase
.from('cancellation_reasons')
.update({ win_back_sent_at: now, win_back_email_id: emailData?.id ?? null })
.eq('id', item.cancellation_reason_id)
}
return new Response(JSON.stringify({ sent: queue?.length ?? 0 }))
})
function getSubjectByReason(reason: string): string {
const subjects: Record<string, string> = {
too_expensive: '【ご相談】料金について相談できますか?',
missing_features: 'ご要望の機能、実装予定があります',
not_using: '使いこなすためのヒントをお送りします',
switching_to_competitor: '改めてお礼と、今後のご連絡',
technical_issues: '技術的な問題、解決しました',
other: 'またいつでもお声がけください',
}
return subjects[reason] ?? 'またいつでもお声がけください'
}
function getBodyByReason(reason: string): string {
const baseClose = `
<p>引き続き masatoman.net を見ていただければ幸いです。</p>
<p>masato</p>
`
if (reason === 'too_expensive') {
return `
<p>先日はご利用いただきありがとうございました。</p>
<p>「料金が高い」という理由でご解約いただいたことを確認しました。</p>
<p>もし引き続き使ってみたいという気持ちがあれば、個別に相談に乗ります。
まずはこのメールに返信してみてください。</p>
${baseClose}
`
}
return `
<p>先日はご利用いただきありがとうございました。</p>
<p>ご意見を参考にプロダクト改善を続けます。</p>
<p>またいつでもお気軽にどうぞ。</p>
${baseClose}
`
}
ウィンバックメールの件名・本文は解約理由ごとにパーソナライズします。「料金が高い」なら相談の余地を示す。「使っていない」なら使い方のヒントを送る。これだけで、一律の「またご利用ください」メールとは温度感が変わります。
解約データを運用に活かす
解約理由が溜まってきたら、Supabase のダッシュボードで分析します。
-- 解約理由の集計(月次)
SELECT
reason,
COUNT(*) AS count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS pct
FROM cancellation_reasons
WHERE canceled_at >= date_trunc('month', NOW() - INTERVAL '1 month')
GROUP BY reason
ORDER BY count DESC;
このクエリの結果を見て、例えば「too_expensive が全体の〇割」なら値付けの再検討を、「missing_features が多い」なら機能開発の優先順位を変える判断材料になります。
個人開発 SaaS の初期段階では、解約1件1件が貴重なデータです。エラーログではなく プロダクトの声 として扱います。
で、どう稼ぐ? — 解約フロー設計の収益的意味
解約フロー設計が収益に貢献する経路は3つあります。
1. ウィンバックによる MRR 回復(試算例)
仮に月の解約者が10人いたとします(試算例・業界ベンチマーク参照)。
| シナリオ | 月の解約者 | ウィンバック率(業界参考値) | 回収 MRR(¥1,980/月の場合) |
|---|---|---|---|
| フロー設計なし | 10人 | — | ¥0 |
| ウィンバックメールあり | 10人 | 5〜10%(試算) | ¥990〜¥1,980/月 |
※ 上記はあくまで試算例。実際の数字は運用後に計測します。
2. PMF 改善による解約率自体の低下
「missing_features が多い」と気づいて機能を追加する。「too_expensive が多い」と気づいてプランを再設計する。解約理由データは、チャーン率(解約率)そのものを下げる施策の根拠になります。
月額収益は「新規獲得 − 解約」のバランスです。新規獲得を増やすことだけに注目しがちですが、解約率を1〜2%下げることは、獲得コストをかけずに LTV(顧客生涯価値)を伸ばす最速の方法です。
3. 解約フロー自体がブランドになる
「解約しようとしたらありがとうメールが来た」「理由を聞いてくれた」という体験は、個人開発の文脈では珍しい体験です。SNS でポジティブに言及されるケースもあります。個人開発 SaaS の信頼形成において、「解約のしかた」はブランドの一部です。
次のステップ
この記事の実装をベースに、次のフェーズとして以下を追加できます。
- 解約防止オファー: モーダルで「もう1ヶ月無料で使い続けますか?」と提示する(Stripe クーポン自動付与)
- 解約理由の Slack 通知: 解約発生時にリアルタイムで Slack に通知し、重要なユーザーには手動でフォローする
- PostHog との連携: 解約者の行動ログ(最後にログインした日・使った機能)と解約理由を突き合わせて、チャーン予測スコアを作る
関連記事
個人開発SaaSの支払い失敗リカバリー設計 — Stripe Dunning × Supabase で不本意チャーンを自動回収する
解約フロー設計の前提として、支払い失敗(不本意チャーン)への対処も合わせて実装しておきましょう。
個人開発SaaSのリファラルプログラム実装 — Stripe クーポン × Supabase × Resend で紹介報酬を自動化する
チャーンを下げた後は、新規獲得を仕組み化する。紹介プログラムで広告費ゼロの獲得チャネルを作ります。
個人開発SaaSのチャーン検知 — Stripe Webhook × Supabase でサブスク解約をリアルタイム把握する
Webhook でのチャーン検知と組み合わせると、解約フロー設計の効果測定がより正確になります。
Claude Crew Lab Free — 毎月の実験記録をメールで
Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。
個人開発の実験ログを月1回、無料で
失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。
この記事が役に立ったらシェア