この記事に書いている実装は、THINK YOU LAB の本番環境(https://think-you-lab.vercel.app)で実際に動いているコードをそのまま公開したものです。Stripe の公式ドキュメントに書いてあることの繰り返しではなく、Next.js App Router + Server Action という構成で実際に決済を通すまでに出てきた設計判断を中心に解説します。
決済フロー全体の確認
コードを読む前に、「ユーザーが購入ボタンを押してから会員になるまで」の全体像を把握しておきましょう。
ユーザーが「購入」ボタンをクリック
↓
[Server Action] startCheckout()
├─ Supabase Auth でログイン確認
├─ plans テーブルから stripe_price_id を取得
└─ Stripe Checkout Session を作成 → Stripe のページへ redirect
↓
[Stripe] カード情報入力 → 決済処理
↓(決済成功)
[Stripe] success_url へリダイレクト(/payment/success)
↓(非同期・独立して)
[Stripe] サーバーに Webhook POST(checkout.session.completed)
↓
[Webhook エンドポイント] route.ts
└─ Supabase memberships テーブルを upsert(status: "active")
↓
ユーザーは /member/courses にアクセス可能になる
重要なのは「success_url へのリダイレクト」と「Webhook による DB 更新」が独立している点です。success_url の到達だけを根拠に DB を更新してはいけません。
Stripe の事前準備
Stripe ダッシュボードで商品・価格を作成する
Stripe ダッシュボードにログインし、「製品カタログ」から商品を追加します。
設定する項目:
- 商品名: 例)THINK YOU LAB メンバーシップ
- 料金モデル: 定額料金(月額)
- 請求期間: 毎月
- 金額: 例)¥2,980
作成後、「料金 ID」(price_ で始まる文字列)が発行されます。これを次のステップで使います。
stripe_price_id を Supabase の plans テーブルに保存する
-- plans テーブルのイメージ
id | name | stripe_price_id | is_active
------------|------------------|------------------------------|----------
plan_xxx | スタンダード月額 | price_1T8avw4stqdVQcpp... | truestripe_price_id カラムに Stripe で発行した料金 ID を入力してください。ここが空のまま実装を進めると、Checkout Session 作成時にエラーになります(よくある詰まりポイントとして後述)。
環境変数の設定
STRIPE_SECRET_KEY=sk_test_xxxxx # Stripe ダッシュボード → API キー → シークレットキーVercel への環境変数の設定方法はこちら → Vercel に Next.js をデプロイする手順
Server Action の実装(THINK YOU LAB 実例)
pricing/actions.ts の全コード
"use server";
import { getAppOrigin } from "@/lib/app-url";
import { getStripe } from "@/lib/stripe";
import { track } from "@/lib/track";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
export async function startCheckout(formData: FormData) {
const planId = formData.get("planId") as string;
const appOrigin = await getAppOrigin();
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login?next=/pricing");
}
const { data: plan, error: planError } = await supabase
.from("plans")
.select("id, name, stripe_price_id")
.eq("id", planId)
.eq("is_active", true)
.single();
if (planError || !plan?.stripe_price_id) {
redirect("/pricing?error=plan_unavailable");
}
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: user.email,
line_items: [
{
price: plan.stripe_price_id,
quantity: 1,
},
],
metadata: {
user_id: user.id,
plan_id: plan.id,
},
success_url: `${appOrigin}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appOrigin}/pricing?canceled=1`,
});
if (!session.url) {
redirect("/pricing?error=checkout_error");
}
await track("checkout_start", user.id, { plan_id: plan.id });
redirect(session.url);
}認証確認 → プラン取得 → Checkout 作成の3ステップ
ステップ1: 認証確認
if (!user) {
redirect("/login?next=/pricing");
}未ログインの場合は /login?next=/pricing にリダイレクトします。?next=/pricing をつけることで、ログイン後に pricing ページに戻ってきます。
ステップ2: プラン取得
const { data: plan, error: planError } = await supabase
.from("plans")
.select("id, name, stripe_price_id")
.eq("id", planId)
.eq("is_active", true)
.single();
if (planError || !plan?.stripe_price_id) {
redirect("/pricing?error=plan_unavailable");
}is_active = true の条件を入れることで、無効化されたプランへの申し込みを防いでいます。
ステップ3: Checkout Session 作成
mode: "subscription" は継続課金(月額・年額)を指定します。都度払いの場合は mode: "payment" を使います。
metadata に user_id と plan_id を埋める理由
metadata: {
user_id: user.id,
plan_id: plan.id,
},これは絶対に省略してはいけない設定です。
Stripe の Checkout が完了すると、Stripe がサーバーに checkout.session.completed イベントを送ってきます。このリクエストには「どのユーザーが決済したか」という情報が含まれていません。metadata を介して user_id を渡しておくことで、Webhook 側で「誰の memberships を更新すればいいか」を判断できます。
success_url / cancel_url の設定と動的 URL 生成
success_url: `${appOrigin}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appOrigin}/pricing?canceled=1`,{CHECKOUT_SESSION_ID} は Stripe が自動で実際のセッション ID に置き換えてくれるプレースホルダです。
appOrigin の取得には getAppOrigin() を使っています:
// lib/app-url.ts
export async function getAppOrigin(): Promise<string> {
const headerList = await headers();
const forwardedProto = headerList.get("x-forwarded-proto");
const forwardedHost = headerList.get("x-forwarded-host");
if (forwardedHost) {
return `${forwardedProto ?? "http"}://${forwardedHost}`;
}
// ...(ローカル・本番の両方に対応)
}x-forwarded-host ヘッダーを参照することで、ローカル(http://localhost:3000)と本番(https://think-you-lab.vercel.app)のどちらでも正しい URL が返ります。Vercel Preview URL にも対応できます。
getStripe() — lazy init で Cold Start を回避する
// lib/stripe.ts
import Stripe from "stripe";
let stripeClient: Stripe | null = null;
export function getStripe(): Stripe {
const apiKey = process.env.STRIPE_SECRET_KEY;
if (!apiKey) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
stripeClient ??= new Stripe(apiKey, {
apiVersion: "2026-02-25.clover",
});
return stripeClient;
}stripeClient ??= new Stripe(...) は nullish 代入演算子で、new Stripe() はプロセス内で最初の一回しか実行されません。Vercel などのサーバーレス環境では、毎回 new Stripe() を呼ぶとその分の初期化コストが積み上がります。
料金ページ(UI 側)の実装
Server Action は <form> タグの action 属性に渡すことで呼び出せます。
// app/(public)/pricing/page.tsx(概要)
import { startCheckout } from "./actions";
export default function PricingPage() {
return (
<form action={startCheckout}>
<input type="hidden" name="planId" value="plan_xxx" />
<button type="submit">購入する</button>
</form>
);
}Server Action を選んだ理由
| 比較軸 | API Route | Server Action |
|--------|-----------|---------------|
| ファイル数 | route.ts + フォームの fetch 処理が必要 | actions.ts 1ファイルで完結 |
| 認証情報の受け渡し | リクエストから cookie を手動で取得 | サーバー側コンテキストをそのまま利用可能 |
| redirect() の扱い | Response でリダイレクト先を返す | redirect() を直接呼べる |
| CSRF 対策 | 手動で実装が必要 | Next.js が自動で対応 |
よくある詰まりポイント
redirect() を try-catch で囲まない
// NG: redirect が catch に引っかかる
try {
redirect(session.url);
} catch (e) {
console.error(e); // NEXT_REDIRECT エラーとして扱われてしまう
}
// OK: redirect は try-catch の外で呼ぶ
redirect(session.url);Next.js の redirect() は内部的に例外を throw して動作します。try-catch で囲むと catch ブロックに入ってしまいます。
stripe_price_id が null でエラー
Supabase の plans テーブルに stripe_price_id を入力し忘れると /pricing?error=plan_unavailable に飛ばされます。まず Supabase のテーブルエディタで stripe_price_id カラムを確認してください。
success_url が localhost になる
本番環境で getAppOrigin() が正しく機能しない場合のフォールバックとして、Vercel に NEXT_PUBLIC_APP_URL=https://think-you-lab.vercel.app を設定しておくと確実です。
まとめ
- Server Action を使った Checkout Session 作成の全手順
metadataにuser_id/plan_idを必ず渡す理由getStripe()による lazy init でコールドスタートの影響を最小化する方法getAppOrigin()で環境に依存しない URL を動的生成する方法
決済完了後の DB 更新(memberships テーブルへの upsert)は Stripe Webhook で行います。
- 決済完了後の DB 更新は Stripe Webhook で → Supabase + Stripe Webhook で会員ステータスを自動更新する
- Supabase Auth でログイン実装はこちら → Supabase magic link でログイン機能を実装する
Stripe の実装がドキュメントを読んでも分からない方向けに、Next.js Server Action × Stripe Checkout 実装コード一式を LINE で無料配布中です。
→ Stripe × Next.js 実装コードを受け取る(LINE登録・無料)
LINE 公式アカウント
俺が実際に使っているプロンプト集を LINE で配布中