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

個人開発SaaSのLTV設計2026 — アップセル×リテンションで1ユーザーから最大収益を引き出す実装ガイド

個人開発SaaSStripeSupabase収益化

結論:新規獲得より先に「1ユーザーを長く・深く」の設計をする

個人開発SaaSで収益が伸び悩む理由の多くは、新規ユーザー獲得に集中しすぎることにあります。しかし実際には、すでに登録しているユーザーの LTV(顧客生涯価値)を高める 方が、同じ時間投資で大きな収益インパクトをもたらします。

LTVを高める施策は3つに集約されます:

  1. アップセル — Free→有料、Standard→Premiumへの自然な誘導
  2. リテンション — 習慣化・価値実感・離脱防止
  3. チャーン防止 — 解約予兆の早期検知と対処

この3つを Stripe + Supabase + PostHog で実装する方法を、実コード付きで解説します。

masatoman.net でClaude Crew Lab(Free MVP)を運用中です。Standard(¥1,980)・Premium(¥4,980)はローンチ準備段階のため、自分のLTVデータはまだ計測段階にあります。この記事で紹介する実装パターンは、Stripe公式ドキュメント・PostHog公式事例・SaaS業界のベンチマーク(ChartMogul / Baremetrics)をベースにしており、筆者が実際に動作確認したコードを含みます。

この記事でわかること:

  • LTVの計算式と個人開発における現実的な目標設定
  • PostHogで「アップセルタイミング」を特定する方法
  • Stripe + Supabase でアップセルトリガーを実装するコード
  • リテンション施策(メールシーケンス・機能解放)の組み方
  • 「で、どう稼ぐ?」— LTV設計が個人開発の収益構造をどう変えるか

LTVとは何か — 個人開発者が知っておくべき最低限の定義

LTVの計算式

LTV = 月額料金 × 平均継続月数
    = ARPU ÷ 月次チャーン率

たとえば業界ベンチマーク試算:

  • 月額 ¥1,980、月次チャーン率 5%(SaaS業界の中央値付近 / ChartMogulレポート参照)の場合
  • LTV = ¥1,980 ÷ 0.05 = ¥39,600

この試算はあくまで業界ベンチマークを使った計算例です。実際のチャーン率は製品・価格・ターゲットによって大きく異なります。

なぜ個人開発でLTVが重要か

新規ユーザー獲得には、記事執筆・SNS発信・LP改善という時間コストがかかります。対してアップセルは、すでに信頼している既存ユーザーへの提案 なので転換コストが格段に低い。

アクション時間コスト収益インパクト
新規獲得(Zenn記事1本経由)3〜5時間¥1,980〜
既存FreeユーザーをStandardへアップセル1〜2時間(UI変更)¥1,980×
チャーン防止メール(1通)30分¥1,980 の損失回避

LTVを高めることで、同じ時間投資でより大きな収益変化を生み出せます。


ステップ1:PostHog でアップセルタイミングを特定する

アップセルの最大の失敗は「タイミングが早すぎる」ことです。ユーザーが価値を実感する前に課金を促すと離脱します。

「価値実感イベント」を定義する

// lib/analytics.ts
import posthog from 'posthog-js'

export const trackValueEvent = (eventName: string, properties?: Record<string, unknown>) => {
  posthog.capture(eventName, properties)
}

// 価値実感イベントの例
export const VALUE_EVENTS = {
  FIRST_EXPORT: 'first_export_completed',       // 初回エクスポート
  THIRD_SESSION: 'third_session_started',        // 3回目のセッション
  FEATURE_LIMIT_HIT: 'free_limit_reached',       // 無料制限に到達
  SHARE_CREATED: 'share_link_created',           // 共有機能を使用
} as const

PostHog でファネルを可視化する

PostHog の「Funnel」機能を使い、以下のステップで分析します:

登録完了 → 価値実感イベント発火 → アップセルページ表示 → 決済完了

価値実感イベントが発火したユーザーと、発火していないユーザーの転換率差を見ることで、「いつ声をかけるか」の根拠が得られます。


ステップ2:Supabase にアップセルトリガーを実装する

価値実感イベントが一定回数に達したら、アップセル提案のフラグを立てます。

Supabase テーブル設計

-- アップセルトリガー管理テーブル
create table upsell_triggers (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users(id) not null,
  trigger_type text not null,
  triggered_at timestamptz default now(),
  shown_at timestamptz,
  converted_at timestamptz,
  dismissed_at timestamptz
);

alter table upsell_triggers enable row level security;
create policy "users can read own triggers"
  on upsell_triggers for select
  using (auth.uid() = user_id);

Next.js API Route でトリガーを発火させる

// app/api/track-value-event/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

const UPSELL_THRESHOLDS: Record<string, number> = {
  first_export_completed: 1,
  third_session_started: 3,
  free_limit_reached: 1,
}

export async function POST(req: NextRequest) {
  const { userId, eventName } = await req.json()
  const threshold = UPSELL_THRESHOLDS[eventName]
  if (!threshold) return NextResponse.json({ ok: true })

  const { count } = await supabase
    .from('usage_events')
    .select('*', { count: 'exact', head: true })
    .eq('user_id', userId)
    .eq('event_name', eventName)

  if ((count ?? 0) >= threshold) {
    await supabase.from('upsell_triggers').upsert({
      user_id: userId,
      trigger_type: eventName,
    }, { onConflict: 'user_id,trigger_type' })
  }

  return NextResponse.json({ ok: true })
}

ステップ3:フロントエンドでアップセルバナーを表示する

// hooks/useUpsellTrigger.ts
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function useUpsellTrigger(userId: string) {
  const [shouldShowUpsell, setShouldShowUpsell] = useState(false)
  const supabase = createClient()

  useEffect(() => {
    const checkTrigger = async () => {
      const { data } = await supabase
        .from('upsell_triggers')
        .select('id')
        .eq('user_id', userId)
        .is('shown_at', null)
        .limit(1)
        .single()

      if (data) {
        setShouldShowUpsell(true)
        await supabase
          .from('upsell_triggers')
          .update({ shown_at: new Date().toISOString() })
          .eq('id', data.id)
      }
    }
    checkTrigger()
  }, [userId])

  return { shouldShowUpsell }
}
// components/UpsellBanner.tsx
'use client'
import { useUpsellTrigger } from '@/hooks/useUpsellTrigger'

export function UpsellBanner({ userId }: { userId: string }) {
  const { shouldShowUpsell } = useUpsellTrigger(userId)

  if (!shouldShowUpsell) return null

  return (
    <div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
      <p className="font-semibold">機能をもっと使いたいですか?</p>
      <p className="mt-1 text-sm text-gray-600">
        Standardプランにアップグレードすると、制限なく使えます。
      </p>
      <a
        href="/pricing"
        className="mt-3 inline-block rounded bg-blue-600 px-4 py-2 text-sm text-white"
      >
        プランを確認する →
      </a>
    </div>
  )
}

ステップ4:リテンション施策 — 習慣化を促すメールシーケンス

アップセルと同様に重要なのが「既存ユーザーを継続させる」施策です。Resend + Supabase で自動メールを設計します。

リテンションメールのタイミング設計

タイミング内容目的
登録3日後「○○機能を試しましたか?」価値実感の加速
登録7日後週次利用サマリー継続習慣の形成
14日間ログインなし「久しぶりです、〇〇さん」離脱防止
請求7日前「今月の利用状況」解約前の価値再確認
// lib/retention-emails.ts
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

type RetentionEmailType = 'day3_nudge' | 'day7_summary' | 'inactive_14d' | 'pre_billing'

export async function sendRetentionEmail({
  to,
  name,
  type,
}: {
  to: string
  name: string
  type: RetentionEmailType
}) {
  const templates: Record<RetentionEmailType, { subject: string; text: string }> = {
    day3_nudge: {
      subject: `${name}さん、〇〇機能はもう試しましたか?`,
      text: `登録から3日が経ちました。まだ試していない機能があれば、ぜひ確認してみてください。`,
    },
    day7_summary: {
      subject: `今週の利用サマリー`,
      text: `今週もご利用ありがとうございます。来週もお待ちしています。`,
    },
    inactive_14d: {
      subject: `久しぶりです、${name}さん`,
      text: `しばらくログインがなかったので、ご様子を伺いに来ました。何かお困りのことがあればお知らせください。`,
    },
    pre_billing: {
      subject: `今月の利用状況のご確認`,
      text: `次の請求日が近づいています。今月の利用状況を確認して、プランが合っているか見直してみてください。`,
    },
  }

  const { subject, text } = templates[type]
  await resend.emails.send({
    from: 'hello@your-domain.com',
    to,
    subject,
    text: `${name} さん\n\n${text}\n\nご不明な点はいつでも返信ください。`,
  })
}

Supabase Cron でメールを自動送信する(Edge Functions)

// supabase/functions/retention-email-scheduler/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async () => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  const now = new Date()
  const day3Ago = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
  const day14Ago = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)

  const { data: day3Users } = await supabase
    .from('profiles')
    .select('id, email, name')
    .gte('created_at', day3Ago.toISOString())
    .lt('created_at', new Date(day3Ago.getTime() + 24 * 60 * 60 * 1000).toISOString())

  for (const user of day3Users ?? []) {
    await fetch(`${Deno.env.get('APP_URL')}/api/send-retention-email`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId: user.id, type: 'day3_nudge' }),
    })
  }

  return new Response(JSON.stringify({ ok: true }), { status: 200 })
})

ステップ5:Stripe でアップセル決済を実装する

既存の有料ユーザーがアップグレードする場合、Stripe の subscription.update を使います。

// app/api/upgrade-plan/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { userId, newPriceId } = await req.json()

  const { data: profile } = await supabase
    .from('profiles')
    .select('stripe_subscription_id, stripe_customer_id')
    .eq('id', userId)
    .single()

  if (!profile?.stripe_subscription_id) {
    return NextResponse.json({ error: 'No active subscription' }, { status: 400 })
  }

  const subscription = await stripe.subscriptions.retrieve(profile.stripe_subscription_id)
  const currentItemId = subscription.items.data[0].id

  const updated = await stripe.subscriptions.update(profile.stripe_subscription_id, {
    items: [{ id: currentItemId, price: newPriceId }],
    proration_behavior: 'always_invoice',
  })

  await supabase.from('upsell_triggers')
    .update({ converted_at: new Date().toISOString() })
    .eq('user_id', userId)
    .is('converted_at', null)

  return NextResponse.json({ status: updated.status })
}

proration_behavior: 'always_invoice' を使うと、アップグレード時に差額を即時請求できます。日割り計算はStripeが自動で行います。


で、どう稼ぐ?

LTV設計を「稼ぐ」視点で整理します。

個人開発のLTV向上ロードマップ(試算例)

以下は 業界ベンチマークを使った試算例(実測値ではなく計算モデル)です:

施策前提(業界ベンチマーク)試算インパクト
アップセルバナー表示Free→有料 転換率 3〜8%(SaaS業界水準)100Free登録時 ¥5,940〜¥15,840/月の試算
離脱防止メール解約予防率 15〜25%(Stripe調査)解約1件 = ¥1,980 の損失回避
14日不活性メールチャーン率低下 10〜20%(業界平均)リテンション改善 → LTV延長

これはあくまで試算です。実際の数字は製品・ターゲット・価格帯によって大きく異なります。

優先順位の付け方

個人開発者が1人で実装するなら、ROI順に着手するのが合理的です:

  1. チャーン防止(最優先): Stripe Webhookで支払い失敗を検知 → 自動メール(実装2〜3時間)
  2. 離脱防止メール: 14日不活性ユーザーへのリマインダー(実装1〜2時間)
  3. アップセルトリガー: 価値実感イベントで表示(実装3〜4時間)

まずチャーン防止から始めて、失う収益を止めてからアップセルに取り組む順序が効果的です。

Claude Crew Lab との連携

このLTV設計は、Claude Crew Lab Free→Standard(¥1,980)の転換設計にも直接応用できます。Freeユーザーが「制限に到達」したタイミングでアップセルバナーを表示し、価値を体験したユーザーに自然にアップグレードを促す設計です。


関連記事

個人開発SaaSのFree→有料転換トリガー設計2026

「制限の壁」でなく「価値の予告」で転換率を上げる実装ガイド

Stripe Webhook × Supabase でチャーン検知を自動化する2026

解約の予兆を捕まえて収益を守る実装ガイド

個人開発のメール自動化2026

Resend+Supabaseで無料→有料転換を仕組み化する実践ガイド

Claude Crew Lab Free — 毎月の実験記録をメールで

Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。

個人開発の実験ログを月1回、無料で

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

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