💳
個人開発でStripe+Supabase月額課金(サブスク)を実装する実践ガイド2026
結論:月額課金の実装は2日で完成する
Stripe SubscriptionsとSupabase RLSを組み合わせれば、個人開発者でも月額課金機能を2日以内に動かせます。¥980〜¥4,980の3階層サブスクを実装するコードを解説します。
システム全体像:
User → Next.js → Stripe Checkout → Webhook → 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
SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...
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);
Stripe Checkout セッション作成
// app/api/stripe/checkout/route.ts
import Stripe from 'stripe';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
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`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
metadata: { userId: user.id },
locale: 'ja',
});
return Response.json({ url: session.url });
}
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 error', { status: 400 });
}
const priceToplan: Record<string, string> = {
[process.env.STRIPE_PRICE_STANDARD!]: 'standard',
[process.env.STRIPE_PRICE_PREMIUM!]: 'premium',
};
if (event.type === 'customer.subscription.created' ||
event.type === 'customer.subscription.updated') {
const sub = event.data.object as Stripe.Subscription;
const plan = priceToplan[sub.items.data[0].price.id] ?? 'free';
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' });
}
if (event.type === '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);
}
return new Response(null, { status: 200 });
}
プランによるコンテンツ制御
// lib/subscription.ts
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
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();
if (plan === 'free') {
return (
<div>
<p>このコンテンツはStandard以上で閲覧できます。</p>
<a href="/pricing">プランをアップグレード →</a>
</div>
);
}
return <PremiumContent />;
}
実装チェックリスト
- Stripe Products/Prices 作成(Free/Standard¥1,980/Premium¥4,980)
-
Supabase
subscriptionsテーブル + RLS ポリシー -
Checkout セッション作成 API (
/api/stripe/checkout) -
Webhook ハンドラ(
created/updated/deleted) -
getUserPlan()関数でServer Component利用 -
ローカルでWebhookテスト:
stripe listen --forward-to localhost:3000/api/stripe/webhooks
設計パターン(5パターン・価格帯の選び方・チャーン対策)の詳細解説は masatoman.net の記事 にまとめています。個人開発で月5万を目指す設計論から実装まで通して読めます。
Discussion