こんにちは!前回のエピソードでは、Supabase Storageを使ってビデオレッスンを管理・配信し、React Playerで再生機能を実装しました。今回は、Stripeを統合してコース購入機能を実装します。チェックアウトページを構築し、Next.jsのAPIルートでStripeセッションを処理、購入情報をSupabaseのpurchases
テーブルに保存します。これで、ユーザーがコースを購入してアクセスできるようになります!
このエピソードのゴール
- Stripe Checkoutを統合して決済処理を実装。
- Next.jsのAPIルートでStripeセッションを作成。
- チェックアウトページをReact Hook Formで構築。
- 購入情報をSupabaseの
purchases
テーブルに保存。
必要なもの
- 前回のプロジェクト(
next-lms
)がセットアップ済み。 - Supabaseプロジェクト(
courses
とlessons
テーブル設定済み)。 - Stripeアカウント(テストモードで十分)。
-
stripe
と@stripe/stripe-js
パッケージ。 - 基本的なTypeScript、React、Next.jsの知識。
ステップ1: Stripeのセットアップ
Stripeアカウントを設定し、必要なパッケージをインストールします。
-
Stripeアカウントの作成:
- Stripeダッシュボードでアカウントを作成。
- テストモードでAPIキー(公開キーとシークレットキー)を取得。
- 「Products」セクションでコースを登録(手動またはプログラムで同期)。
-
パッケージのインストール:
Stripe関連のパッケージをインストール:
npm install stripe @stripe/stripe-js
-
環境変数の設定:
.env.local
にStripeのキーを追加:
STRIPE_SECRET_KEY=あなたのStripeシークレットキー
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=あなたのStripe公開キー
ステップ2: purchases
テーブルの設計
購入情報を保存するテーブルを作成します。
-
Supabaseダッシュボードでテーブル作成:
- Supabaseダッシュボードの「Table Editor」→「New Table」で
purchases
テーブルを作成。 - 以下のカラムを定義:
- Supabaseダッシュボードの「Table Editor」→「New Table」で
カラム名 | タイプ | 説明 |
---|---|---|
id | uuid | 主キー(自動生成) |
user_id | uuid | 購入者のユーザーID |
course_id | uuid | 購入したコースID |
stripe_session_id | text | StripeセッションID |
amount | numeric | 支払い金額 |
status | text | 購入ステータス(例: completed) |
created_at | timestamptz | 作成日時(自動生成) |
-
外部キー制約を追加:
ALTER TABLE purchases ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE; ALTER TABLE purchases ADD CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES courses (id) ON DELETE CASCADE;
-
「Enable Row Level Security (RLS)」を有効化し、ポリシーを設定:
-
Select Policy: 自分の購入履歴のみ閲覧(
auth.uid() = user_id
)。 -
Insert Policy: 認証済みユーザーのみ(
auth.uid() = user_id
)。
-
Select Policy: 自分の購入履歴のみ閲覧(
ステップ3: StripeクライアントとAPIルートの作成
Stripeセッションを処理するAPIルートを構築します。
-
Stripeクライアントの初期化:
src/lib/stripe.ts
を作成:
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
-
チェックアウトAPIルートの作成:
src/app/api/checkout/route.ts
を作成:
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const { courseId } = await request.json();
const supabase = supabaseServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '認証が必要です' }, { status: 401 });
}
const course = await supabase
.from('courses')
.select('id, title, price')
.eq('id', courseId)
.single();
if (!course.data) {
return NextResponse.json({ error: 'コースが見つかりません' }, { status: 404 });
}
try {
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'jpy',
product_data: {
name: course.data.title,
},
unit_amount: course.data.price,
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${request.headers.get('origin')}/courses/${courseId}?success=true`,
cancel_url: `${request.headers.get('origin')}/courses/${courseId}?canceled=true`,
metadata: {
userId: user.id,
courseId: course.data.id,
},
});
await supabase
.from('purchases')
.insert({
user_id: user.id,
course_id: course.data.id,
stripe_session_id: session.id,
amount: course.data.price,
status: 'pending',
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error('Stripeエラー:', error);
return NextResponse.json({ error: '決済セッションの作成に失敗しました' }, { status: 500 });
}
}
このコードは:
- 認証済みユーザーのみを許可。
- コース情報を取得し、Stripe Checkoutセッションを作成。
- 購入情報を
purchases
テーブルに保存(pending
ステータス)。 - 成功/キャンセル時にCDPにリダイレクト。
ステップ4: チェックアウトページの構築
チェックアウトページをReact Hook Formで構築します。src/app/checkout/page.tsx
を作成:
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';
type CheckoutForm = {
courseId: string;
};
export default async function CheckoutPage() {
const supabase = supabaseServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return (
<main className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
<p className="text-red-500">購入するにはログインしてください。</p>
<a href="/login" className="text-primary hover:underline">ログイン</a>
</main>
);
}
return (
<main className="container mx-auto p-4 max-w-md">
<h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
<CheckoutForm />
</main>
);
}
function CheckoutForm() {
const { register, handleSubmit, formState: { errors } } = useForm<CheckoutForm>();
const router = useRouter();
const searchParams = useSearchParams();
const courseId = searchParams.get('courseId');
const onSubmit = async (data: CheckoutForm) => {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ courseId: data.courseId }),
});
const result = await response.json();
if (result.url) {
router.push(result.url);
} else {
alert(result.error || '決済処理に失敗しました');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium">コースID</label>
<input
type="text"
defaultValue={courseId || ''}
{...register('courseId', { required: 'コースIDは必須です' })}
className="w-full border p-2 rounded"
/>
{errors.courseId && <p className="text-red-500 text-sm">{errors.courseId.message}</p>}
</div>
<button type="submit" className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 w-full">
決済に進む
</button>
</form>
);
}
このコードは:
- 認証済みユーザーのみを許可。
- React Hook FormでコースIDを入力(クエリパラメータから初期値設定)。
-
/api/checkout
にリクエストを送信し、Stripe Checkoutにリダイレクト。
ステップ5: 購入ステータスの更新
StripeのWebhookを使って購入完了を処理します(簡略化のため、CDPで手動確認)。
-
CDPの更新:
src/app/courses/[id]/page.tsx
を更新して購入ステータスを確認:
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getCourseById, getLessonsByCourseId, getSignedVideoUrl, Course, Lesson } from '@/lib/supabase';
import { Metadata } from 'next';
import ReactPlayer from 'react-player';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';
export async function generateStaticParams() {
const courses = await getAllCourseIds();
return courses.map((course) => ({
id: course.id,
}));
}
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const course = await getCourseById(params.id);
if (!course) return {};
return {
title: `${course.title} | Next.js LMS`,
description: course.description,
openGraph: {
title: course.title,
description: course.description,
images: [{ url: course.image_url || 'https://via.placeholder.com/600x400' }],
},
twitter: {
card: 'summary_large_image',
title: course.title,
description: course.description,
images: [course.image_url || 'https://via.placeholder.com/600x400'],
},
};
}
export default async function CourseDetailPage({ params, searchParams }: { params: { id: string }, searchParams: { success?: string, canceled?: string } }) {
const course = await getCourseById(params.id);
const lessons = await getLessonsByCourseId(params.id);
const supabase = supabaseServer();
const { data: { user } } = await supabase.auth.getUser();
if (!course) {
notFound();
}
const { data: purchase } = await supabase
.from('purchases')
.select('*')
.eq('user_id', user?.id || '')
.eq('course_id', course.id)
.single();
const isPurchased = purchase && purchase.status === 'completed';
const lessonsWithSignedUrls = isPurchased
? await Promise.all(
lessons.map(async (lesson) => ({
...lesson,
signedUrl: await getSignedVideoUrl(lesson.video_path),
}))
)
: [];
if (searchParams.success) {
await supabase
.from('purchases')
.update({ status: 'completed' })
.eq('course_id', course.id)
.eq('user_id', user?.id || '');
}
return (
<main className="container mx-auto p-4">
<div className="bg-white rounded-lg shadow-lg p-6">
<Image
src={course.image_url || 'https://via.placeholder.com/600x400'}
alt={course.title}
width={600}
height={400}
className="w-full h-64 object-cover rounded-lg mb-6"
priority
/>
<h1 className="text-3xl font-bold mb-4">{course.title}</h1>
<p className="text-gray-600 mb-4">{course.description}</p>
<p className="text-2xl font-semibold text-primary mb-6">{course.price.toLocaleString()} 円</p>
{isPurchased ? (
<p className="text-green-500 mb-6">このコースは購入済みです!</p>
) : (
<a
href={`/checkout?courseId=${course.id}`}
className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 mb-6 inline-block"
>
このコースを購入
</a>
)}
{searchParams.success && <p className="text-green-500 mb-6">購入が完了しました!</p>}
{searchParams.canceled && <p className="text-red-500 mb-6">購入がキャンセルされました。</p>}
<h2 className="text-2xl font-semibold mb-4">レッスン一覧</h2>
{isPurchased ? (
<div className="space-y-6">
{lessonsWithSignedUrls.map((lesson) => (
<div key={lesson.id} className="border rounded-lg p-4">
<h3 className="text-xl font-semibold">{lesson.title}</h3>
<p className="text-gray-600 mb-4">{lesson.description}</p>
<ReactPlayer
url={lesson.signedUrl}
controls
width="100%"
height="auto"
className="rounded-lg"
/>
</div>
))}
</div>
) : (
<p className="text-gray-600">コースを購入するとレッスンを視聴できます。</p>
)}
</div>
</main>
);
}
このコードは:
- 購入ステータスを確認し、購入済みの場合のみビデオを表示。
- 決済成功時に
purchases
テーブルのステータスをcompleted
に更新。 - 成功/キャンセルメッセージを表示。
ステップ6: 動作確認
- Supabaseダッシュボードで以下を確認:
-
purchases
テーブルとRLSポリシーが正しい。 - Stripeダッシュボードでテストモードが有効。
-
- 開発サーバーを起動:
npm run dev
-
http://localhost:3000/courses/[course-id]
にアクセスし、以下の点を確認:- 未購入の場合、「このコースを購入」ボタンが表示され、
/checkout?courseId=[id]
に遷移。 - チェックアウトページでコースIDを入力し、Stripe Checkoutにリダイレクト。
- テストカード(例:
4242 4242 4242 4242
)で決済を完了し、成功メッセージが表示。 - 購入後、レッスンビデオが表示され、再生可能。
- キャンセルした場合、キャンセルメッセージが表示。
- 未購入の場合、「このコースを購入」ボタンが表示され、
- Supabaseの
purchases
テーブルで、購入データ(user_id
,course_id
,status
)が正しいことを確認。
エラーがあれば、StripeのAPIキー、SupabaseのRLS、またはAPIルートのログを確認してください。
まとめと次のステップ
このエピソードでは、Stripeを使ってコース購入機能を実装しました。チェックアウトページを構築し、APIルートでStripeセッションを処理、購入情報をSupabaseに保存しました。これで、ユーザーがコースを購入してコンテンツにアクセスできるようになりました!
次回のエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装します。comments
テーブルを設計し、ユーザーがレッスンにコメントを投稿・閲覧できるUIを構築しますので、引き続きお楽しみに!
この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです!次回のエピソードもお楽しみに!