Next.jsで高速eコマースアプリを構築する | エピソード5: Stripeを使った決済機能の統合

こんにちは！前回のエピソードでは、Zustandを使ってカート機能を実装し、商品の追加や数量変更を可能にしました。今回は、Stripeを統合して安全でスムーズな決済機能を追加します。チェックアウトページを構築し、フォームバリデーションや決済処理の実装を通じて、ユーザーが簡単に購入を完了できるようにします。

## このエピソードのゴール

- Stripe CheckoutをNext.jsに統合。
- チェックアウトページを構築し、フォームバリデーションを実装。
- カートデータを基に決済セッションを作成。
- Tailwind CSSでレスポンシブなチェックアウトUIをデザイン。

## 必要なもの

- 前回のプロジェクト（`next-ecommerce`）がセットアップ済み。
- Stripeアカウントと公開鍵/秘密鍵（Stripeダッシュボードから取得）。
- `@stripe/stripe-js`と`stripe`パッケージ。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: Stripeのセットアップ

Stripeをプロジェクトに統合するために、必要なパッケージをインストールします：

```bash
npm install @stripe/stripe-js stripe
```

Stripeの秘密鍵を安全に管理するため、`.env.local`に以下を追加：

```env
STRIPE_SECRET_KEY=あなたのStripe秘密鍵
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=あなたのStripe公開鍵
```

**注意**: 秘密鍵はサーバーサイドでのみ使用し、公開鍵はクライアントサイドで使用します。

---

## ステップ2: Stripe APIクライアントの作成

サーバーサイドでStripeと通信するためのクライアントを作成します。`src/lib/stripe.ts`ファイルを作成し、以下のコードを追加：

```ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-09-30.acacia',
});
```

このコードは、Stripeの秘密鍵を使ってAPIクライアントを初期化します。

---

## ステップ3: チェックアウトページの構築

チェックアウトページを作成し、カートデータを表示して決済に進むフォームを構築します。`src/app/checkout/page.tsx`ファイルを作成し、以下のコードを追加：

```tsx
import { useCartStore } from '@/lib/cartStore';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function CheckoutPage() {
  const { items, clearCart } = useCartStore();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();

  const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  const handleCheckout = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      });

      const { url } = await response.json();
      if (url) {
        clearCart();
        router.push(url);
      } else {
        setError('決済セッションの作成に失敗しました。');
      }
    } catch (err) {
      setError('エラーが発生しました。もう一度お試しください。');
    } finally {
      setLoading(false);
    }
  };

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
      {items.length === 0 ? (
        <p className="text-gray-600">カートに商品がありません。</p>
      ) : (
        <div className="grid md:grid-cols-2 gap-8">
          {/* カート内容 */}
          <div>
            <h2 className="text-xl font-semibold mb-4">注文内容</h2>
            {items.map((item) => (
              <div key={item.variantId} className="flex justify-between border-b py-2">
                <span>
                  {item.title} x {item.quantity}
                </span>
                <span>
                  {(item.price * item.quantity).toFixed(2)} JPY
                </span>
              </div>
            ))}
            <div className="text-right font-bold mt-4">
              合計: {totalPrice.toFixed(2)} JPY
            </div>
          </div>
          {/* 決済フォーム */}
          <div>
            <h2 className="text-xl font-semibold mb-4">決済情報</h2>
            <div className="space-y-4">
              <div>
                <label className="block text-sm font-medium">メールアドレス</label>
                <input
                  type="email"
                  className="w-full border p-2 rounded"
                  placeholder="example@email.com"
                  required
                />
              </div>
              <div>
                <label className="block text-sm font-medium">配送先住所</label>
                <input
                  type="text"
                  className="w-full border p-2 rounded"
                  placeholder="東京都渋谷区..."
                  required
                />
              </div>
              {error && <p className="text-red-500">{error}</p>}
              <button
                onClick={handleCheckout}
                disabled={loading}
                className="w-full bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 disabled:opacity-50"
              >
                {loading ? '処理中...' : '決済に進む'}
              </button>
            </div>
          </div>
        </div>
      )}
    </main>
  );
}
```

このコードは：
- カート内容と合計金額を表示。
- メールアドレスと住所の簡易フォームを用意（本番ではバリデーションを強化）。
- `/api/checkout`エンドポイントにリクエストを送信してStripe Checkoutセッションを作成。

---

## ステップ4: Stripe Checkout APIエンドポイントの作成

Stripe Checkoutセッションを作成するAPIルートを追加します。`src/app/api/checkout/route.ts`ファイルを作成し、以下のコードを追加：

```ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';

export async function POST(request: Request) {
  try {
    const { items } = await request.json();

    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: items.map((item: any) => ({
        price_data: {
          currency: 'jpy',
          product_data: {
            name: item.title,
            images: [item.image],
          },
          unit_amount: Math.round(item.price * 100), // 円をセントに変換
        },
        quantity: item.quantity,
      })),
      mode: 'payment',
      success_url: `${request.headers.get('origin')}/success`,
      cancel_url: `${request.headers.get('origin')}/cart`,
    });

    return NextResponse.json({ url: session.url });
  } catch (error) {
    console.error('Stripeエラー:', error);
    return NextResponse.json({ error: '決済セッションの作成に失敗しました' }, { status: 500 });
  }
}
```

このコードは：
- カートアイテムを基にStripe Checkoutセッションを作成。
- 成功時またはキャンセル時のリダイレクトURLを指定。
- セッションのURLをクライアントに返す。

---

## ステップ5: 決済成功ページの作成

決済完了後に表示するページを作成します。`src/app/success/page.tsx`ファイルを作成：

```tsx
export default function SuccessPage() {
  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">ご購入ありがとうございます！</h1>
      <p className="text-gray-600">
        ご注文が正常に処理されました。詳細はメールでご確認ください。
      </p>
      <a href="/" className="mt-4 inline-block bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90">
        ホームに戻る
      </a>
    </main>
  );
}
```

このページは、決済成功後にユーザーに感謝のメッセージを表示します。

---

## ステップ6: 動作確認

1. `.env.local`にStripeの公開鍵と秘密鍵を設定。
2. 開発サーバーを起動（`npm run dev`）。
3. カートに商品を追加し、`/checkout`にアクセス。
4. フォームにテスト情報を入力し、「決済に進む」をクリック。
5. Stripeのテスト決済ページにリダイレクトされ、以下の点を確認：
   - 商品名、数量、金額が正しく表示される。
   - 決済完了後に`/success`ページにリダイレクトされる。
   - カートが空になる（`clearCart`が機能）。
6. エラーがあれば、コンソールログやStripeダッシュボードを確認。

**テストカード**: Stripeのテスト環境では、`4242 4242 4242 4242`（有効期限: 未来の日付、CVC: 任意の3桁）を使用。

---

## まとめと次のステップ

このエピソードでは、Stripe Checkoutを統合し、チェックアウトページと決済機能を構築しました。フォームバリデーションやエラーハンドリングも追加し、ユーザーが安全に購入を完了できるようにしました。

次回のエピソードでは、パフォーマンス最適化に焦点を当て、Core Web Vitals（LCP、CLSなど）を改善します。Lighthouseを使った分析やReact Server Componentsの活用も紹介しますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！