個人開発SaaSに年払いプランを追加する2026 — Stripe年払い×Supabaseで月払い→年払い転換を仕組み化する
結論:4ファイルで年払いプランが動く
| ファイル | 役割 |
|---|---|
| Stripe Dashboard | 年払い Price を追加(月払いと同一 Product) |
app/api/subscription/upgrade-to-annual/route.ts | 月払い→年払いの Checkout Session 発行 |
app/api/stripe/webhook/route.ts | サブスク変更の Supabase 反映 |
lib/annual-upgrade-emails.ts | Resend で転換オファーメール送信 |
月払いユーザーに「年払いにすると2ヶ月分お得」と伝える仕組みを、Stripe + Supabase + Resend で実装します。
masatoman.net で Claude Crew Lab(Free MVP)を運用中です。Standard(¥1,980)・Premium(¥4,980)はローンチ準備段階のため、実際の年払い転換データはまだありません。この記事の実装パターンはStripe公式ドキュメント・Baremetrics年払い転換ガイド・業界ベンチマークをベースにしており、テスト環境で動作確認したコードを含みます。
この記事でわかること:
- 年払いプランを Stripe に追加する方法(Price 設計)
- 既存月払いユーザーが年払いに移行できる Checkout Session の実装
- Webhook でサブスク変更を Supabase に反映する方法
- Resend で月払いユーザーへ転換オファーメールを送る方法
- 「で、どう稼ぐ?」— 年払い転換が収益にどう効くか
なぜ年払いプランが重要か
個人開発 SaaS で月額課金を始めると、最初の壁は「毎月の解約」です。月払いユーザーは「使わなかった月」に解約しやすく、解約理由として「料金がもったいない」が上位に入ります。
年払いプランはこの問題を構造的に解決します。
| 観点 | 月払い | 年払い |
|---|---|---|
| キャッシュフロー | 月ごとに¥1,980 | 初月に¥19,800(2ヶ月分お得) |
| 解約タイミング | 毎月 | 年1回 |
| 解約率(業界参考) | 月3〜8%(Baremetrics) | 年3〜5%(実質月0.25〜0.4%) |
業界ベンチマーク(Baremetrics・SaaS Capital等)では、年払い比率が30〜40%を超えると収益の安定性が大きく改善すると言われています。個人開発の文脈でも、年払いユーザーが10人いれば、月末の「解約が出るかも」という不安を大幅に減らせます。
Step 0:Stripe に年払い Price を追加する
Stripe Dashboard の「製品」から既存の Standard / Premium Product を開き、「別の価格を追加」で年払い Price を作成します。
| 項目 | 設定値(例) |
|---|---|
| 請求サイクル | 年単位(every year) |
| 金額 | ¥19,800(月払い¥1,980 × 10ヶ月分=2ヶ月お得) |
| メタデータ | plan: standard_annual |
Price ID(price_XXXXXXXX)を控えて .env.local に追加します:
NEXT_PUBLIC_STRIPE_ANNUAL_STANDARD_PRICE_ID=price_XXXXXXXX
NEXT_PUBLIC_STRIPE_ANNUAL_PREMIUM_PRICE_ID=price_YYYYYYYY
Step 1:Supabase subscriptions テーブルの拡張
既存の subscriptions テーブルに billing_interval カラムを追加します。
ALTER TABLE subscriptions
ADD COLUMN billing_interval TEXT NOT NULL DEFAULT 'month'
CHECK (billing_interval IN ('month', 'year'));
Step 2:月払い→年払いの Checkout Session
既存のアクティブサブスクを持つユーザーが年払いに切り替える場合、mode: 'subscription' の新規セッションを作るよりも、customer.subscriptions.update で items を差し替える方がシームレスです。ただし初回の年払いスタートは Checkout Session 経由がエラーハンドリングしやすいため、ここでは新規 Checkout Session アプローチを採用します。
// app/api/subscription/upgrade-to-annual/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 { plan } = await req.json()
const priceId = plan === 'premium'
? process.env.NEXT_PUBLIC_STRIPE_ANNUAL_PREMIUM_PRICE_ID!
: process.env.NEXT_PUBLIC_STRIPE_ANNUAL_STANDARD_PRICE_ID!
const { data: sub } = await supabase
.from('subscriptions')
.select('stripe_customer_id, stripe_subscription_id, billing_interval')
.eq('user_id', user.id)
.eq('status', 'active')
.single()
if (!sub) return NextResponse.json({ error: 'No active subscription' }, { status: 404 })
if (sub.billing_interval === 'year') {
return NextResponse.json({ error: 'Already on annual plan' }, { status: 400 })
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: sub.stripe_customer_id,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?annual=upgraded`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
subscription_data: {
metadata: { previous_subscription_id: sub.stripe_subscription_id },
},
})
return NextResponse.json({ checkoutUrl: session.url })
}
既存月払いサブスクの処理(解約)は Webhook 受信後に行います(checkout.session.completed で前サブスクをキャンセル)。
Step 3:Checkout Session 完了 Webhook
// app/api/stripe/webhook/route.ts — checkout.session.completed に追加
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
if (session.mode !== 'subscription') break
const subscription = await stripe.subscriptions.retrieve(session.subscription as string)
const interval = subscription.items.data[0]?.price?.recurring?.interval ?? 'month'
const { data: { user } } = await supabase
.from('profiles')
.select('id')
.eq('stripe_customer_id', session.customer)
.single()
if (user) {
await supabase.from('subscriptions').upsert({
user_id: user.id,
stripe_customer_id: session.customer as string,
stripe_subscription_id: subscription.id,
status: 'active',
plan: subscription.metadata?.plan ?? 'standard',
billing_interval: interval,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
}, { onConflict: 'user_id' })
const prevSubId = subscription.metadata?.previous_subscription_id
if (prevSubId && interval === 'year') {
await stripe.subscriptions.cancel(prevSubId, { prorate: true })
}
}
break
}
prorate: true により、月払いの残日数が日割りで返金(Stripe クレジット)されます。
Step 4:年払いアップグレードUI
ダッシュボードのBillingページに「年払いにする」ボタンを追加します。
// components/AnnualUpgradeBanner.tsx
'use client'
import { useState } from 'react'
interface Props {
plan: 'standard' | 'premium'
billingInterval: 'month' | 'year'
}
export function AnnualUpgradeBanner({ plan, billingInterval }: Props) {
const [loading, setLoading] = useState(false)
if (billingInterval === 'year') return null
const monthlyCost = plan === 'premium' ? 4980 : 1980
const annualCost = plan === 'premium' ? 49800 : 19800
const savings = monthlyCost * 12 - annualCost
const handleUpgrade = async () => {
setLoading(true)
const res = await fetch('/api/subscription/upgrade-to-annual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan }),
})
const data = await res.json()
if (data.checkoutUrl) window.location.href = data.checkoutUrl
setLoading(false)
}
return (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<p className="font-semibold text-yellow-800">
年払いにすると ¥{savings.toLocaleString()} お得(2ヶ月分無料)
</p>
<p className="mt-1 text-sm text-yellow-700">
年払いプランは ¥{annualCost.toLocaleString()}/年。
月払い継続(¥{(monthlyCost * 12).toLocaleString()}/年)より2ヶ月分節約できます。
</p>
<button
onClick={handleUpgrade}
disabled={loading}
className="mt-3 rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white hover:bg-yellow-600 disabled:opacity-50"
>
{loading ? '処理中...' : `年払いプランに切り替える(¥${annualCost.toLocaleString()}/年)`}
</button>
</div>
)
}
Step 5:月払いユーザーへの転換オファーメール(Resend)
登録から30日後に、月払いユーザーへ自動で年払いオファーメールを送ります。
// lib/annual-upgrade-emails.ts
import { Resend } from 'resend'
import { createClient } from '@/lib/supabase/server'
const resend = new Resend(process.env.RESEND_API_KEY!)
export async function sendAnnualUpgradeOffer(userId: string, plan: 'standard' | 'premium') {
const supabase = createClient()
const { data: profile } = await supabase
.from('profiles')
.select('email, display_name')
.eq('id', userId)
.single()
if (!profile?.email) return
const monthlyCost = plan === 'premium' ? 4980 : 1980
const annualCost = plan === 'premium' ? 49800 : 19800
const savings = monthlyCost * 12 - annualCost
await resend.emails.send({
from: 'masato@masatoman.net',
to: profile.email,
subject: `年払いで¥${savings.toLocaleString()}節約できます(${plan === 'premium' ? 'Premium' : 'Standard'}プラン)`,
html: `
<p>${profile.display_name ?? 'こんにちは'}さん</p>
<p>先月からご利用ありがとうございます。</p>
<p>
年払いプランに切り替えると、
<strong>¥${savings.toLocaleString()}お得(2ヶ月分無料)</strong> になります。
</p>
<p>
<a href="${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing">
年払いプランを確認する →
</a>
</p>
`,
})
}
Supabase Edge Function または cron で、billing_interval = 'month' かつ登録30日以上のユーザーに定期配信します。
SELECT id FROM subscriptions
WHERE billing_interval = 'month'
AND status = 'active'
AND created_at < NOW() - INTERVAL '30 days'
AND annual_offer_sent_at IS NULL;
で、どう稼ぐ? — 年払い転換が収益に効く理由
1. キャッシュフローの改善(試算例)
月払い Standard ユーザーが10人いる場合の試算(想定値):
| シナリオ | 月次収益 | 年間収益(想定) |
|---|---|---|
| 全員月払い | ¥19,800/月 | ¥237,600 |
| 3人が年払いに転換(想定) | ¥13,860/月 + 初月¥59,400一括 | ¥226,260 + 初月大幅増 |
年払いユーザーが増えるほど、月末の解約減少と初月の大きな入金が同時に得られます。
※ 上記はあくまで試算例。実際の転換率は運用後に計測します。
2. 解約率の構造的な低下
業界ベンチマーク(Baremetrics Annual Benchmark Report)によると、年払いサブスクの実質月次解約率は月払いの5〜10分の1になるケースが多いとされています。個人開発 SaaS でも、年払いユーザー1人は月払いユーザーの安定性換算で5〜10人分に相当します。
3. LTV(顧客生涯価値)の向上
年払いへの転換は LTV を直接伸ばします。月払いで毎月解約リスクにさらされるよりも、年払いで1年間のコミットを得た方が、プロダクト改善・マーケティングへの再投資予算も確保しやすくなります。
実装コスト目安
| 工程 | 目安 |
|---|---|
| Stripe Price 追加 + env 設定 | 30分 |
| Checkout Session API | 1〜2時間 |
| Webhook 対応 | 1〜2時間 |
| アップグレードUI(Banner) | 1時間 |
| Resend 転換オファーメール | 30分〜1時間 |
合計 5〜7時間 で年払いプランが動くようになります。
次のステップ
- 年払いユーザーへのリニューアル通知: 更新1ヶ月前に Resend でリマインドメールを送る
- アップグレードモーダルをダッシュボードトップに設置: 使用機能数・ログイン回数が一定を超えたユーザーにだけ表示
- 解約フローとの連携: 解約しようとした月払いユーザーに「年払いで¥XXXXお得」とオファーする
Next Step
次に読むならこの導線です
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude Crew」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
【第6回】Claude Code で SaaS を 1 週間で組む開発フロー — Skill × MCP の統合設計
Skills、MCP、サブエージェント、Hooks を統合した個人開発の SaaS 構築フローを解説。LP 公開から課金開始までを 1 週間で組み立てるパイプラインの設計と実装を、月 500 万トークン運用の筆者が公開。「売れるかどうか」は別途検証が必要なので、本記事は「作る速度を上げる」フローに特化しています。
【第10回】Claude Code × Supabase で管理画面を30分で生成する Skill 実装ガイド
Supabase のテーブルを参照して管理画面を自動生成する Skill を、実コード付きで解説。Claude Code が DB スキーマから Next.js 管理画面を30分で生成する仕組みを公開します。
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude Crew」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
次の実験記録も追う
Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。
個人開発の実験ログを月1回、無料で
失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。
この記事が役に立ったらシェア