💳

個人開発で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