メインコンテンツへスキップ
Think You Lab
ブログログイン無料で始める
トップ/ブログ/how-to
StripeNext.js決済Server ActionSupabase

Stripe Checkout を Next.js Server Action で実装する — Supabase Auth 連携つき

Next.js App Router + Server Action で Stripe Checkout を実装する手順を解説。THINK YOU LAB 本番稼働中の pricing/actions.ts を全コード公開し、metadata の渡し方・getStripe() lazy init・getAppOrigin() による動的 URL 生成まで説明します。

2026-04-15·約14分

LINE 公式アカウント

AI副業スタートガイドをLINEで無料配布中

目次
  • 決済フロー全体の確認
  • Stripe の事前準備
  • Stripe ダッシュボードで商品・価格を作成する
  • `stripe_price_id` を Supabase の `plans` テーブルに保存する
  • 環境変数の設定
  • Server Action の実装(THINK YOU LAB 実例)
  • `pricing/actions.ts` の全コード
  • 認証確認 → プラン取得 → Checkout 作成の3ステップ
  • `metadata` に `user_id` と `plan_id` を埋める理由
  • `success_url` / `cancel_url` の設定と動的 URL 生成
  • `getStripe()` — lazy init で Cold Start を回避する
  • 料金ページ(UI 側)の実装
  • よくある詰まりポイント
  • `redirect()` を `try-catch` で囲まない
  • `stripe_price_id` が `null` でエラー
  • `success_url` が `localhost` になる
  • まとめ

目次

  • 決済フロー全体の確認
  • Stripe の事前準備
  • Stripe ダッシュボードで商品・価格を作成する
  • `stripe_price_id` を Supabase の `plans` テーブルに保存する
  • 環境変数の設定
  • Server Action の実装(THINK YOU LAB 実例)
  • `pricing/actions.ts` の全コード
  • 認証確認 → プラン取得 → Checkout 作成の3ステップ
  • `metadata` に `user_id` と `plan_id` を埋める理由
  • `success_url` / `cancel_url` の設定と動的 URL 生成
  • `getStripe()` — lazy init で Cold Start を回避する
  • 料金ページ(UI 側)の実装
  • よくある詰まりポイント
  • `redirect()` を `try-catch` で囲まない
  • `stripe_price_id` が `null` でエラー
  • `success_url` が `localhost` になる
  • まとめ

この記事に書いている実装は、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...   | true

stripe_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 で配布中

R

Rikuto (LAB)

非エンジニアが Claude Code × n8n × Supabase で副業システムを作り続ける実験記。 失敗も含めたリアルな一次情報を発信しています。

THINK YOU LAB 運営

関連記事

  • Supabase + Stripe Webhook で会員ステータスを自動更新する — Next.js App Router 実装

    2026-04-15

  • Next.js App Router の middleware で Supabase 認証ガードを作る — 実装コード全公開

    2026-04-15

  • Supabase Auth で magic link ログインを実装する — Next.js App Router 対応

    2026-04-15

← ブログ一覧へ
X でシェアLINE でシェア
← 前の記事Supabase + Stripe Webhook で会員ステータスを自動更新する — Next.js App Router 実装
次の記事 →Next.js App Router の middleware で Supabase 認証ガードを作る — 実装コード全公開

目次

  • 決済フロー全体の確認
  • Stripe の事前準備
  • Stripe ダッシュボードで商品・価格を作成する
  • `stripe_price_id` を Supabase の `plans` テーブルに保存する
  • 環境変数の設定
  • Server Action の実装(THINK YOU LAB 実例)
  • `pricing/actions.ts` の全コード
  • 認証確認 → プラン取得 → Checkout 作成の3ステップ
  • `metadata` に `user_id` と `plan_id` を埋める理由
  • `success_url` / `cancel_url` の設定と動的 URL 生成
  • `getStripe()` — lazy init で Cold Start を回避する
  • 料金ページ(UI 側)の実装
  • よくある詰まりポイント
  • `redirect()` を `try-catch` で囲まない
  • `stripe_price_id` が `null` でエラー
  • `success_url` が `localhost` になる
  • まとめ
Think You Lab
このブログについて料金プランプライバシーポリシーお問い合わせ特定商取引法ログイン

© 2026 Think You Lab