2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発でStripe+Supabase月額課金(サブスク)を実装する実践ガイド2026

2
Posted at

結論:月額課金の実装は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 の記事 で解説しています。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?