メインコンテンツへスキップ
← 記事一覧に戻る
·収益化·17 min read

個人開発SaaSの解約フロー設計 — Stripe Billing Portal × Supabase で「やめた理由」を収集しチャーン原因を潰す

個人開発SaaSStripeSupabase収益化

結論: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.deleted Webhook で解約を Supabase に記録する実装
  • Resend でウィンバックメールを送る実装
  • 「で、どう稼ぐ?」— 解約理由データの収益的活用法

解約フローを設計しない代償

個人開発者の多くは「解約」を「終わり」だと思っています。だから解約ボタンに何も仕掛けない。Stripe のデフォルト Billing Portal にそのままリダイレクトして、データは何も残らない。

これは大きな機会損失です。

解約したユーザーは 今まさに最も正直になっている瞬間 です。プロダクトの何が足りなかったか、何が高すぎたか、何を求めていたかを語る準備ができています。この瞬間を素通りすると、以下の情報を永遠に失います。

失う情報収益への影響
やめた理由PMF(プロダクトマーケットフィット)の改善機会
価格への不満値付け戦略の見直し機会
競合への移行先競合分析の素材
ウィンバックのタイミング再サブスク の可能性

ChartMogul の業界レポート(2024)によると、SaaS の年間チャーンのうち 30〜40% が「一時的な問題」(一時的な資金不足・機能不満が後に解消)に起因しており、ウィンバックキャンペーンの有効性が報告されています。個人開発 SaaS のスケールでは数字は変わりますが、「解約理由を聞く」という基本設計は同じです。

解約フロー設計の目的は2つです。

  1. PMF データの収集: 理由を知ることで次の機能開発・値付け改善に活かす
  2. ウィンバック起点の確保: 「一時的な不満」なら再アプローチするチャンスが生まれる

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回、無料で

失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。

この記事が役に立ったらシェア