結論:月額課金の実装は2日で完成する
Stripe SubscriptionsとSupabase RLSを組み合わせれば、個人開発者でも月額課金機能を2日以内に動かせます。この記事では、¥980〜¥4,980の3階層サブスクを実際に実装する手順をコード付きで解説します。
実装後の全体構成はこうなります:
User → Next.js Frontend
↓
Stripe Checkout (hosted)
↓
Stripe Webhooks
↓
Supabase Edge Function
↓
subscriptions テーブル更新
↓
Supabase RLS でコンテンツ制御
環境準備
npm install stripe @stripe/stripe-js
必要な環境変数:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...
Step 1: Stripe Products と Prices を作成
Stripeダッシュボードから3つのプランを作成してください:
Free: $0/月 (price_id: price_free_xxx)
Standard: ¥1,980/月 (price_id: price_standard_xxx)
Premium: ¥4,980/月 (price_id: price_premium_xxx)
または Stripe CLI で:
stripe products create --name="Claude Crew Lab Standard"
stripe prices create \
--unit-amount=1980 \
--currency=jpy \
--recurring[interval]=month \
--product=prod_xxx
Step 2: Supabase テーブル設計
CREATE TABLE subscriptions (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
stripe_customer_id text UNIQUE,
stripe_subscription_id text UNIQUE,
plan text DEFAULT 'free' CHECK (plan IN ('free', 'standard', 'premium')),
status text DEFAULT 'active',
current_period_end timestamptz,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users can read own subscription"
ON subscriptions FOR SELECT
USING (auth.uid() = user_id);
Step 3: Stripe Checkout セッション作成 API
// app/api/stripe/checkout/route.ts
import Stripe from 'stripe';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { priceId } = await request.json();
const supabase = createRouteHandlerClient({ cookies });
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
metadata: { userId: user.id },
locale: 'ja',
});
return Response.json({ url: session.url });
}
Step 4: Stripe Webhook ハンドラ
// app/api/stripe/webhooks/route.ts
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(request: Request) {
const body = await request.text();
const sig = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Webhook signature verification failed', { status: 400 });
}
const getPlanFromPriceId = (priceId: string): string => {
const priceMap: Record<string, string> = {
[process.env.STRIPE_PRICE_STANDARD!]: 'standard',
[process.env.STRIPE_PRICE_PREMIUM!]: 'premium',
};
return priceMap[priceId] ?? 'free';
};
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
const plan = getPlanFromPriceId(sub.items.data[0].price.id);
await supabase.from('subscriptions').upsert({
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
plan,
status: sub.status,
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
}, { onConflict: 'stripe_subscription_id' });
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await supabase.from('subscriptions')
.update({ plan: 'free', status: 'canceled' })
.eq('stripe_subscription_id', sub.id);
break;
}
}
return new Response(null, { status: 200 });
}
Step 5: サブスクプランによるコンテンツ制御
// lib/subscription.ts
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
export async function getUserPlan(): Promise<'free' | 'standard' | 'premium'> {
const supabase = createServerComponentClient({ cookies });
const { data: { user } } = await supabase.auth.getUser();
if (!user) return 'free';
const { data } = await supabase
.from('subscriptions')
.select('plan, status')
.eq('user_id', user.id)
.single();
if (!data || data.status !== 'active') return 'free';
return data.plan as 'free' | 'standard' | 'premium';
}
コンポーネントでの使用:
// app/content/page.tsx (Server Component)
import { getUserPlan } from '@/lib/subscription';
export default async function ContentPage() {
const plan = await getUserPlan();
return (
<div>
<h1>コンテンツライブラリ</h1>
{plan === 'free' && (
<p>このコンテンツはStandard以上で閲覧できます。
<a href="/pricing">プランをアップグレード →</a>
</p>
)}
{plan !== 'free' && (
<PremiumContent />
)}
</div>
);
}
Step 6: チャーン防止のオンボーディングメール(Supabase Edge Function)
// supabase/functions/onboarding-email/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
serve(async (req) => {
const { userId, email, plan } = await req.json();
// Day 0: ウェルカムメール
await sendEmail({
to: email,
subject: `${plan === 'standard' ? 'Standard' : 'Premium'}プランへようこそ!`,
html: welcomeEmailHtml(plan),
});
// Day 1, Day 3 のメールをスケジュール(pg_cronやresend scheduledを使用)
await scheduleFollowUpEmails(userId, email);
return new Response(JSON.stringify({ success: true }));
});
まとめ:実装チェックリスト
- Stripe Products/Prices 作成(3プラン)
-
Supabase
subscriptionsテーブル + RLS 設定 - Checkout セッション作成 API
- Webhook ハンドラ(created/updated/deleted)
-
getUserPlan()ユーティリティ関数 - コンテンツ制御(Server Component or middleware)
- オンボーディングメールシーケンス
設計パターンの詳細(5パターン・価格帯の選び方・チャーン対策)は masatoman.net の記事 で解説しています。