言語を切り替える
テーマを切り替える

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

午前3時、私は画面上のエラーログを見つめながら、Stripe Webhook の設定を27回目のチェックをしていました。ユーザーから「お金は引かれたのに、注文ステータスが『支払い待ち』のままだ」というクレームが入ったのです。私は混乱しました——テスト環境では完璧に動いていたのに、なぜリリースした途端に失敗するのか?

正直なところ、初めて Next.js でEコマースプロジェクトを作ったとき、一番難しいのはインターフェース構築やスタイル作成だと思っていました。しかし実際に手を動かしてみると、ショッピングカートの状態管理、決済連携、注文フロー、すべての段階に落とし穴があることに気づきました。Redux は重すぎて学ぶ気が起きないし、Context API はパフォーマンスが悪いし、Stripe のドキュメントは英語ばかりで頭が痛いし、Webhook に至っては意味不明でした。

最も挫折感を味わったのは、ネット上のチュートリアルが「カートだけ」か「決済だけ」しか扱っておらず、全体のフローをつなげて説明してくれるものがほとんどなかったことです。「結局どの状態管理ライブラリを選べばいいの?」「Webhook って何に使うの?」「注文ステータスと決済ステータスをどうやって合わせるの?」

この記事では、これらの落とし穴を一度に埋めたいと思います。Zustand でカートの状態を管理し(軽量で使いやすい)、Stripe で決済を連携し(国際的な主流)、Webhook で注文を処理します(唯一の信頼できる方法)。各ステップには完全なコードがあり、コピペですぐに動かせます。

「やっと動いた!」という達成感を味わったことはありますか? この記事に従えば、間違いなく味わえます。

なぜショッピングカートの管理に Zustand を選ぶのか?

2025年の状態管理選定:もう迷わない

正直に言うと、状態管理ライブラリの選択は本当に頭が痛いです。Redux のドキュメントは辞書のように分厚いし、Context API のパフォーマンス問題は検索すれば山ほど出てくるし、Zustand は新しすぎて使うのが怖い。私もこの3つの間で揺れ動いていましたが、あるデータを見て決心しました。

2021年以降、Zustand は最も急成長している React 状態管理ライブラリになりました。2025年までに、その設計思想——関数型、Hooks 重視、シンプルでエレガントな API——は証明されました。さらに重要なのは、学習曲線が非常に緩やかで、Redux のように大量の概念(action, reducer, dispatch, middleware…)を理解する必要がないことです。

では、どう選べばいいのでしょうか? 図解します:

  • 小規模プロジェクト(10ページ未満):Context API で十分。複雑にしない。
  • 中規模プロジェクト(10-50ページ):Zustand。軽量で十分な機能。
  • 大規模プロジェクト(50ページ以上、多人数開発):Redux Toolkit。ツールチェーンが充実。

ショッピングカートのようなシナリオは、実は Zustand に非常に適しています。なぜなら、コンポーネント間での共有(商品リスト、カートアイコン、決済ページなど)が必要で、永続化(リロードしてもデータを失わない)が必要で、高性能(関連コンポーネントだけ更新し、全体を再レンダリングしない)である必要があるからです。これらすべてを Zustand は解決でき、コード量は Redux の半分以下です。

Zustand カート実践コード

理屈はこれくらいにして、コードを見てみましょう。まずは依存関係をインストール:

npm install zustand

そしてカートの Store を作成します(/store/cartStore.js):

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCartStore = create(
  persist(
    (set, get) => ({
      // 状態
      items: [], // [{ id, name, price, quantity, image }]

      // 計算プロパティ
      get total() {
        return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0)
      },
      get count() {
        return get().items.reduce((sum, item) => sum + item.quantity, 0)
      },

      // メソッド
      addItem: (product) => set((state) => {
        const existing = state.items.find(item => item.id === product.id)
        if (existing) {
          // 既に存在する場合は数量+1
          return {
            items: state.items.map(item =>
              item.id === product.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            )
          }
        } else {
          // 新商品ならカートに追加
          return { items: [...state.items, { ...product, quantity: 1 }] }
        }
      }),

      removeItem: (productId) => set((state) => ({
        items: state.items.filter(item => item.id !== productId)
      })),

      updateQuantity: (productId, quantity) => set((state) => ({
        items: state.items.map(item =>
          item.id === productId ? { ...item, quantity } : item
        )
      })),

      clearCart: () => set({ items: [] })
    }),
    {
      name: 'shopping-cart', // localStorage の key
    }
  )
)

コードは長く見えますが、ロジックは単純です。items 配列に商品を保存し、totalcount は計算され(合計金額と総数量)、いくつかのメソッドで追加・削除・更新を処理します。persist ミドルウェアが自動的にデータを localStorage に保存するので、ページをリロードしてもデータは消えません。

コンポーネントでの使い方も超簡単です:

import { useCartStore } from '@/store/cartStore'

function ProductCard({ product }) {
  const addItem = useCartStore(state => state.addItem)

  return (
    <button onClick={() => addItem(product)}>
      カートに追加
    </button>
  )
}

function CartIcon() {
  const count = useCartStore(state => state.count)

  return <div>カート ({count})</div>
}

注目してください。useCartStore(state => state.addItem) という書き方はセレクターです。これは addItem メソッドだけを購読し、他のカートデータが変更されても再レンダリングされません。これが Zustand のパフォーマンスが良い秘密——正確な購読です。

ちなみに、以前 Redux の useSelectoruseDispatch を使っていたなら、Zustand がどれだけシンプルかわかるでしょう。action type も reducer 関数も不要で、Store 内にメソッドを書くだけです。

「プロジェクトですでに Redux を使っているけど、Zustand に乗り換えるべき?」と聞かれるかもしれません。不要です。Redux Toolkit も実は非常に優秀で、オープンソースのEコマースプロジェクト C-Shopping などは Redux Toolkit + RTK Query を使用しており、データフローの追跡や安定性は抜群です。ただ、新規プロジェクトなら、学習コストが低く、すぐに成果が出せる Zustand を個人的にはお勧めします。

Stripe 決済統合の完全フロー

まず Stripe の決済フローを理解する

初めて Stripe のドキュメントを見たとき、頭の中は「?」だらけでした。Checkout Session って何? Payment Intent って? なぜ Stripe のページに飛ぶの? 自社サイトで決済完了できないの?

今振り返ると、フローは非常に明確です:

  1. フロントエンド:ユーザーが「支払う」をクリックし、API を呼び出して Checkout Session を作成する
  2. バックエンド:Session を作成し、session.id を返す
  3. フロントエンド:session.id を受け取り、Stripe.js を使って Stripe 托管の決済ページへ遷移する
  4. ユーザー:Stripe ページでカード情報を入力し、支払いを完了する
  5. Stripe:決済成功後、Webhook でバックエンドに通知を送る
  6. バックエンド:Webhook を受信し、注文作成、在庫減算、メール送信を行う
  7. Stripe:ユーザーをあなたのサイト(success_url)にリダイレクトする

重要な点:絶対にフロントエンドで決済成功ロジックを処理しないこと。なぜなら、ユーザーは支払いを終えた後に「完了」ボタンを押さずにブラウザを閉じるかもしれないし、ネットワークが切れるかもしれないし、意図的にリダイレクトを回避してタダ乗りしようとするかもしれないからです。唯一の信頼できる方法は Webhook です。これについては後で詳しく説明します。

Stripe Checkout Session の作成

ます依存関係をインストール:

npm install stripe @stripe/stripe-js

次に環境変数を設定(.env.local):

STRIPE_SECRET_KEY=sk_test_xxxxx  # バックエンド用、絶対に漏洩させてはいけない
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx  # フロントエンド用
STRIPE_WEBHOOK_SECRET=whsec_xxxxx  # Webhook 署名検証用

API ルートを作成(/pages/api/create-checkout.js):

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  try {
    const { items } = req.body // カートデータ

    // line_items を作成(Stripe の要求フォーマット)
    const lineItems = items.map(item => ({
      price_data: {
        currency: 'usd',
        product_data: {
          name: item.name,
          images: [item.image],
        },
        unit_amount: Math.round(item.price * 100), // Stripe は「セント」単位
      },
      quantity: item.quantity,
    }))

    // Checkout Session 作成
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: lineItems,
      mode: 'payment', // 一回払い(サブスクなら 'subscription')
      success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${req.headers.origin}/cart`,
      metadata: {
        // カスタムデータを保存、後で Webhook で取得可能
        userId: req.user?.id || 'guest',
      },
    })

    res.status(200).json({ sessionId: session.id })
  } catch (err) {
    console.error('Checkout Session 作成失敗:', err)
    res.status(500).json({ error: err.message })
  }
}

いくつかの詳細に注意してください:

  • unit_amount は100倍する必要があります。Stripe は**セント(分)**単位だからです(99.99ドル = 9999セント)。
  • success_url 内の {CHECKOUT_SESSION_ID} はプレースホルダーで、Stripe が自動的に実際の session_id に置換します。
  • metadata には独自のデータ(ユーザーID、注文メモなど)を保存でき、Webhook で取得できます。

フロントエンドでの Checkout 呼び出し

決済ページ(/pages/checkout.js):

import { loadStripe } from '@stripe/stripe-js'
import { useCartStore } from '@/store/cartStore'

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)

export default function CheckoutPage() {
  const { items, total } = useCartStore()

  const handleCheckout = async () => {
    try {
      // バックエンド API を呼び出して Session 作成
      const response = await fetch('/api/create-checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      })

      const { sessionId } = await response.json()

      // Stripe 決済ページへ遷移
      const stripe = await stripePromise
      const { error } = await stripe.redirectToCheckout({ sessionId })

      if (error) {
        console.error('決済ページへの遷移失敗:', error)
        alert(error.message)
      }
    } catch (err) {
      console.error('支払い開始失敗:', err)
      alert('支払いに失敗しました。後でもう一度お試しください')
    }
  }

  return (
    <div>
      <h1>決済</h1>
      {items.map(item => (
        <div key={item.id}>
          {item.name} x {item.quantity} = ${item.price * item.quantity}
        </div>
      ))}
      <div>合計: ${total}</div>
      <button onClick={handleCheckout}>支払う</button>
    </div>
  )
}

「支払う」をクリックすると、ユーザーは Stripe がホストする決済ページにリダイレクトされます。このページは Stripe がやってくれるので、フォーム作成やクレジットカード検証、不正検知などの面倒な作業を自分でする必要がありません。

「決済ページのデザインをカスタマイズできる?」とよく聞かれますが、可能です。カラー、ロゴ、フォントなどはカスタマイズできますが、レイアウトは固定です。もし UI を完全に制御したいなら、Stripe Elements(フロントエンド埋め込み決済フォーム)を使えますが、複雑度は跳ね上がるので初心者にはお勧めしません。

決済成功後のリダイレクト

ユーザーが支払いを完了すると、Stripe は設定した success_url にリダイレクトします。このページで注文詳細を表示できます:

// /pages/success.js
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'

export default function SuccessPage() {
  const router = useRouter()
  const { session_id } = router.query
  const [order, setOrder] = useState(null)

  useEffect(() => {
    if (session_id) {
      // バックエンドから注文情報を取得
      fetch(`/api/order?session_id=${session_id}`)
        .then(res => res.json())
        .then(data => setOrder(data))
    }
  }, [session_id])

  if (!order) return <div>読み込み中...</div>

  return (
    <div>
      <h1>支払い成功!</h1>
      <p>注文番号: {order.id}</p>
      <p>金額: ${order.total}</p>
    </div>
  )
}

しかし覚えておいてください。このページは表示用であり、実際の注文作成は Webhook で行う必要があります。次に Webhook のやり方について話しましょう。

Webhook による注文処理と状態同期

なぜ Webhook が重要なのか?

初めて決済機能を実装した時、ユーザーが success ページに戻ってきたら支払い成功とみなして、そこに注文ロジックを書いていました。しかしテスト中に、支払いを終えたユーザーがブラウザを閉じてしまい、注文が作成されず、返金されたかどうかもわからないという事態が発生し、パニックになりました。

後に Stripe の公式ドキュメントを見て理解しました:Webhook は唯一の信頼できる注文処理方法です。なぜなら:

  • ユーザーのリダイレクトは信頼できない:ブラウザを閉じる、ネットワーク切断、完了ボタンを押さない等、様々な状況が起こり得る
  • セキュリティ要件:注文作成、在庫減算、発送などの機密操作はバックエンドで行う必要があり、フロントエンドに制御させてはいけない
  • Stripe 公式推奨:すべての重要なビジネスロジックは Webhook に置くべき

Webhook とは何でしょうか? 簡単に言えば、Stripe のサーバーがあなたのサーバーを能動的に呼び出し、「おい、支払いが成功したぞ」とか「サブスクリプションがキャンセルされたぞ」と教えてくれる仕組みです。通知を受け取ったら、それに応じた処理を行います。

Webhook エンドポイントの作成

Next.js で /pages/api/stripe-webhook.js を作成します:

import Stripe from 'stripe'
import { buffer } from 'micro'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET

// 重要設定:Next.js のデフォルト body 解析を無効化
export const config = {
  api: {
    bodyParser: false,
  },
}

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).send('Method not allowed')
  }

  const buf = await buffer(req)
  const sig = req.headers['stripe-signature']

  let event

  try {
    // Webhook 署名を検証(超重要!)
    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret)
  } catch (err) {
    console.error('Webhook 署名検証失敗:', err.message)
    return res.status(400).send(`Webhook Error: ${err.message}`)
  }

  // イベントタイプごとに処理
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutSessionCompleted(event.data.object)
      break
    case 'payment_intent.succeeded':
      await handlePaymentIntentSucceeded(event.data.object)
      break
    case 'invoice.payment_failed':
      await handleInvoicePaymentFailed(event.data.object)
      break
    default:
      console.log(`未処理のイベントタイプ: ${event.type}`)
  }

  res.status(200).json({ received: true })
}

async function handleCheckoutSessionCompleted(session) {
  console.log('支払い成功!', session.id)

  // カートデータを取得(metadata または session.id でクエリ)
  const userId = session.metadata.userId
  const sessionId = session.id
  const total = session.amount_total / 100 // ドルに戻す

  // 注文が既に存在するか確認(冪等性保証)
  const existingOrder = await db.order.findUnique({
    where: { stripeSessionId: sessionId }
  })

  if (existingOrder) {
    console.log('注文は既に存在します、スキップ')
    return
  }

  // 注文作成
  const order = await db.order.create({
    data: {
      userId,
      stripeSessionId: sessionId,
      status: 'paid',
      total,
      // ... その他のフィールド
    }
  })

  // 在庫減算
  await updateInventory(order.items)

  // 確認メール送信
  await sendOrderConfirmationEmail(userId, order)

  console.log('注文作成成功:', order.id)
}

async function handlePaymentIntentSucceeded(paymentIntent) {
  // 支払い着金確認
  console.log('支払い確認:', paymentIntent.id)
}

async function handleInvoicePaymentFailed(invoice) {
  // サブスク支払い失敗処理
  console.log('支払い失敗:', invoice.id)
  // リマインダーメール送信、サービス停止など
}

いくつかの重要ポイント:

  1. bodyParser の無効化必須:Stripe は署名検証に生のリスエストボディ(raw body)を必要とします。Next.js が body を事前に解析してしまうと、署名検証に失敗します。
  2. 署名検証必須stripe.webhooks.constructEvent() はリクエストが確かに Stripe から来たことを検証し、悪意ある第三者の偽造リクエストを防ぎます。
  3. 冪等性(Idempotency)処理:Stripe は Webhook を重複して送る可能性があります(ネットワーク問題、リトライメカニズム)。あなたのコードは重複呼び出しを処理できなければなりません。stripeSessionId を一意なインデックスとして使えば、同じ支払いで複数の注文が作られるのを防げます。

Webhook のローカルテスト

Stripe はあなたの localhost を直接呼び出せないので、ローカル開発には Stripe CLI を使う必要があります。まずは CLI をインストール:

# Mac
brew install stripe/stripe-cli/stripe

# Windows (Scoop 使用)
scoop install stripe

# または公式サイトからダウンロード
# https://stripe.com/docs/stripe-cli

ログインして Webhook をリッスン:

stripe login
stripe listen --forward-to localhost:3000/api/stripe-webhook

CLI は一時的な webhook secret(whsec_xxxxx みたいなの)を表示するので、それを .env.local にコピーします:

STRIPE_WEBHOOK_SECRET=whsec_xxxxx

そして別のターミナルでテストイベントをトリガーします:

stripe trigger checkout.session.completed

CLI と Next.js のコンソールにログが表示されれば、Webhook が受信できたことになります。これで注文作成ロジックをデバッグできます。

私はここで長いことハマりました。ずっと署名検証エラーが出ていたんです。後に Next.js の bodyParser をオフにし忘れていたせいだとわかりました。export const config を忘れずに追加してください!

注文ステータス管理

注文ステータスの遷移は大体こんな感じです:

支払い待ち → 支払い済み → 準備中 → 発送済み → 完了

           キャンセル/返金済み

データベースには enum でステータスを保存します:

// schema.prisma
model Order {
  id               String   @id @default(cuid())
  stripeSessionId  String   @unique  // 冪等性保証
  userId           String
  status           OrderStatus @default(PENDING)
  total            Float
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

enum OrderStatus {
  PENDING      // 支払い待ち
  PAID         // 支払い済み
  PREPARING    // 準備中
  SHIPPED      // 発送済み
  COMPLETED    // 完了
  CANCELLED    // キャンセル
  REFUNDED     // 返金済み
}

Webhook が checkout.session.completed を受信したら、ステータスを PAID にします。その後の発送や完了ステータスは、バックオフィスシステムから手動または自動で更新します。

エラー処理

Webhook は失敗する可能性があります(DB接続断、サードパーティサービスダウンなど)。Stripe にはリトライ機能がありますが、失敗ログは記録しておくべきです:

async function handleCheckoutSessionCompleted(session) {
  try {
    // ビジネスロジック
  } catch (error) {
    console.error('注文処理失敗:', error)
    // ログシステム(Sentry, LogRocket 等)に記録
    await logError({
      type: 'webhook_error',
      event: 'checkout.session.completed',
      sessionId: session.id,
      error: error.message,
    })
    throw error // エラーを投げて Stripe に失敗を知らせ、自動リトライさせる
  }
}

Webhook が失敗したら? Stripe は3日間自動リトライします。その間、Stripe Dashboard で失敗した Webhook を確認し、手動で再送することもできます。

完全な注文フロー実践

ここまでで、すべてのパーツが揃いました。これらをつなげて、完全な注文フローがどう動くか見てみましょう。

ユーザー注文の完全パス

  1. 商品ページ:ユーザーが「カートに追加」をクリック、Zustand Store 更新、カートアイコンの数字+1
  2. カートページ:ユーザーがカートを確認、数量調整、「決済へ進む」をクリック
  3. 決済ページ:注文概要を表示、「支払う」ボタンをクリック
  4. フロントエンド/api/create-checkout を呼び出し、カートデータを送信
  5. バックエンド:Stripe Session を作成、sessionId を返す
  6. フロントエンド:Stripe 托管決済ページへリダイレクト
  7. ユーザー:カード情報を入力、「Pay」をクリック
  8. Stripe:支払いを処理し、成功すると Webhook を /api/stripe-webhook に送信
  9. バックエンド Webhook:署名検証 → 注文作成 → 在庫減算 → メール送信
  10. Stripe:ユーザーを /success?session_id=xxx にリダイレクト
  11. フロントエンド:success ページが /api/order?session_id=xxx を呼び出し、注文詳細を表示

プロセスは複雑に見えますが、各ステップは明確です。重要なのは、ステップ9が Webhook 内で完了しなければならず、ステップ11に依存してはいけないということです。

データベース設計のポイント

model Order {
  id               String      @id @default(cuid())
  stripeSessionId  String      @unique  // 冪等性
  userId           String
  status           OrderStatus @default(PENDING)
  total            Float
  items            OrderItem[]
  createdAt        DateTime    @default(now())
  updatedAt        DateTime    @updatedAt

  user User @relation(fields: [userId], references: [id])
}

model OrderItem {
  id        String @id @default(cuid())
  orderId   String
  productId String
  quantity  Int
  price     Float  // 注文時の価格。後の値上げが影響しないように
  // ...
  order   Order   @relation(fields: [orderId], references: [id])
  product Product @relation(fields: [productId], references: [id])
}

OrderItemprice フィールドに注目してください。ここには Product の現在の価格ではなく、注文時の価格を保存します。これにより、後で商品が値上げされても、過去の注文記録は当時の価格のままになります。

エッジケースの処理

1. 在庫不足の場合は?

Checkout Session 作成前にチェックします:

// /pages/api/create-checkout.js
const { items } = req.body

// 在庫チェック
for (const item of items) {
  const product = await db.product.findUnique({ where: { id: item.id } })
  if (product.stock < item.quantity) {
    return res.status(400).json({ error: `${product.name} の在庫が不足しています` })
  }
}

// 在庫十分、Session 作成へ...

2. 支払いは成功したが Webhook が失敗した?

Stripe は3日間自動リトライします。Stripe Dashboard で手動再送も可能です。あるいは、「支払い成功だが注文未作成」の Session を定期チェックして補完するバッチ処理を書くのも手です。

3. 支払い済みだが発送前に在庫切れ?

発送前に再度在庫チェックします。在庫がなければ、ユーザーに連絡して返金か交換を行います。

本番環境デプロイの注意点

テスト環境で動いたからといって、そのまま本番に出してはいけません。いくつか注意すべき落とし穴があります。

環境変数設定

本番環境のキーはテスト環境とは異なります。混同しないように:

# .env.production
STRIPE_SECRET_KEY=sk_live_xxxxx  # live であることに注意
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx  # 本番環境の Webhook Secret

Vercel 等にデプロイする際、環境変数を設定してください。Secret Key を Git にコミットしてはいけません

Webhook エンドポイント設定

テスト環境では Stripe CLI 転送を使っていましたが、本番環境では Stripe Dashboard で手動設定が必要です:

  1. Stripe Dashboard にログイン
  2. “Developers” → “Webhooks” へ移動
  3. “Add endpoint” をクリック
  4. 本番環境の URL を入力:https://yourdomain.com/api/stripe-webhook
  5. リッスンするイベントを選択:checkout.session.completedpayment_intent.succeeded
  6. 保存後、Signing secretwhsec_xxxxx)をコピーし、環境変数に設定

私は初デプロイでこれを忘れ、リリース後に注文が作成されず、原因究明に多大な時間を費やしました。

セキュリティチェックリスト

デプロイ前にこのリストを確認してください:

  • ✅ すべての支払いロジックはバックエンドで行う(フロントエンドは遷移のみ)
  • ✅ Webhook 署名を検証する(stripe.webhooks.constructEvent
  • ✅ 支払い金額と注文金額が一致するか検証する(フロントエンドでの価格改ざん防止)
  • ✅ 冪等性を実装する(stripeSessionId 一意インデックス)
  • ✅ 支払い関連ログを記録する(Sentry, Datadog 等)
  • ✅ 異常アラートを設定する(Webhook 失敗率、決済成功率)

3番目は特に重要です。Session 作成時にバックエンドで価格設定していても、念のため Webhook でも再検証し、誰かがフロントエンドをバイパスして Stripe API を直接叩くのを防ぐべきです(確率は低いですが、安全第一です)。

監視とアラート

本番環境には監視システムを導入しましょう:

// /pages/api/stripe-webhook.js
import * as Sentry from '@sentry/nextjs'

export default async function handler(req, res) {
  try {
    // ... Webhook ロジック
  } catch (error) {
    Sentry.captureException(error, {
      tags: {
        type: 'stripe_webhook',
        event: event.type,
      },
    })
    throw error
  }
}

監視指標の推奨:

  • Webhook 失敗率(>5% でアラート)
  • 決済成功率(急激な低下は Stripe のダウンや設定ミスを疑う)
  • 注文作成時間(>3秒なら原因調査)

まとめ:ゼロからリリースへの道のり

長くなりましたが、重要なステップを振り返ります:

  1. カート状態管理:Zustand(軽量)か Redux Toolkit(大規模)を選び、persist 中間件で永続化
  2. 決済統合:Stripe Checkout Session を作成し、托管ページへ遷移させ、フォーム検証の手間を省く
  3. 注文処理:Webhook で注文作成、在庫減算、メール送信を行い、信頼性を保証
  4. 本番デプロイ:環境変数設定、Webhook エンドポイント設定、監視アラート導入

3つの核心原則を忘れないでください:

  • 決済ロジックは必ずバックエンドで:フロントエンドは信用できない
  • Webhook は唯一の信頼できる情報源:ユーザーリダイレクトは信用できない
  • セキュリティは最優先:署名検証、リプレイ攻撃防止、ログ記録

初めて Eコマース決済を実装するなら、まず Stripe テスト環境で全フローを通すことをお勧めします。テストカード番号 4242 4242 4242 4242(有効期限と CVV は適当でOK)を使えます。テスト環境で問題なければ、本番環境へ切り替えましょう。

最後に、いくつかの参考資料をお勧めします:

  • Stripe 公式ドキュメントhttps://stripe.com/docs(英語ですが非常に詳細です)
  • Next.js + Stripe 完全チュートリアル:Pedro Alonso の 2025 年ガイド(“Stripe Next.js 15 complete guide” で検索)
  • オープンソースプロジェクト:C-Shopping Eコマースプラットフォーム(Redux Toolkit + Stripe を使用)

この記事があなたの落とし穴を減らす助けになれば幸いです。注文が自動的に作成され、在庫が正しく引かれ、ユーザーに確認メールが届くのを初めて見たとき、その達成感は格別です。頑張ってください!

Next.js Eコマースカートと Stripe 決済の完全実装フロー

状態管理、決済統合、注文処理の全フローを含む、Eコマースカートと決済システム構築の詳細手順

⏱️ Estimated time: 2 hr

  1. 1

    Step1: 依存関係のインストールと Zustand カートの設定

    Zustand 状態管理ライブラリをインストール:
    • npm install zustand

    カート Store の作成(/store/cartStore.js):
    • items 配列を定義して商品を保存
    • total と count 計算プロパティを追加
    • addItem, removeItem, updateQuantity, clearCart メソッドを実装
    • persist ミドルウェアを使用して localStorage に保存

    重要な設定:
    • persist ミドルウェアによる自動永続化で、リロードしてもデータを保持
    • セレクターによる購読(useCartStore(state => state.addItem))で不要な再レンダリングを回避

    適用シナリオ:中小型プロジェクト(10-50ページ)、軽量な状態管理が必要な場合
  2. 2

    Step2: Stripe Checkout Session API の作成

    Stripe 依存関係のインストール:
    • npm install stripe @stripe/stripe-js

    環境変数の設定(.env.local):
    • STRIPE_SECRET_KEY=sk_test_xxxxx(バックエンド用、漏洩厳禁)
    • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx(フロントエンド用)
    • STRIPE_WEBHOOK_SECRET=whsec_xxxxx(Webhook 検証用)

    API ルートの作成(/pages/api/create-checkout.js):
    • カート items データを受信
    • Stripe line_items フォーマットに変換(unit_amount は100倍する点に注意)
    • checkout.sessions を作成(success_url と cancel_url を設定)
    • metadata にカスタムデータ(userId 等)を保存
    • フロントエンドに sessionId を返す

    重要な詳細:
    • Stripe はセント単位なので、価格は100倍する
    • success_url にプレースホルダー {CHECKOUT_SESSION_ID} を使用
    • metadata に業務データを保存し、Webhook で取得可能にする
  3. 3

    Step3: フロントエンドでの Stripe Checkout 呼び出し

    決済ページの実装(/pages/checkout.js):
    • loadStripe を使用して Stripe.js をロード
    • /api/create-checkout を呼び出して Session 作成
    • stripe.redirectToCheckout() を使用して決済ページへ遷移

    エラー処理:
    • catch でネットワークエラーを捕捉
    • stripe.redirectToCheckout が返す error をチェック
    • ユーザーにフレンドリーなエラーメッセージを表示

    決済ページの説明:
    • Stripe がホストする決済ページにより、フォーム作成の手間を省略
    • クレジットカード検証、不正検知を自動処理
    • カラー、ロゴ、フォントのカスタマイズが可能

    注意事項:
    • フロントエンドで決済成功ロジックを決して処理しない
    • ユーザーはブラウザを閉じるか完了ボタンを押さない可能性がある
    • 実際の注文作成は Webhook で行う必要がある
  4. 4

    Step4: 注文処理用 Webhook エンドポイントの設定

    Webhook API の作成(/pages/api/stripe-webhook.js):

    必須設定:
    • export const config = { api: { bodyParser: false } }(body 解析無効化)
    • buffer(req) を使用して生のリスエストボディを取得
    • stripe.webhooks.constructEvent() で署名を検証

    イベントタイプの処理:
    • checkout.session.completed:決済成功、注文作成
    • payment_intent.succeeded:支払い着金確認
    • invoice.payment_failed:サブスク支払い失敗

    冪等性の保証:
    • stripeSessionId が既に存在するかチェック
    • データベースに unique インデックスを追加
    • Webhook の重複呼び出しによる多重注文作成を防止

    ビジネスロジック:
    • 注文レコード作成(status: 'paid')
    • 在庫減算(updateInventory)
    • 確認メール送信(sendOrderConfirmationEmail)
    • ログとエラーの記録

    ローカルテスト:
    • stripe login(CLI ログイン)
    • stripe listen --forward-to localhost:3000/api/stripe-webhook
    • stripe trigger checkout.session.completed(テストイベント)

    重要事項:
    • bodyParser を無効化しないと署名検証に失敗する
    • 悪意ある偽造リクエストを防ぐため署名検証は必須
    • Webhook が失敗した場合、Stripe は3日間自動リトライする
  5. 5

    Step5: 本番環境デプロイとセキュリティ設定

    環境変数設定:
    • 本番環境キーを使用(sk_live_xxxxx と pk_live_xxxxx)
    • プラットフォーム(Vercel/Netlify)で環境変数を設定
    • Secret Key を Git にコミットしない

    Stripe Dashboard 設定:
    • Developers → Webhooks へ移動
    • 本番環境エンドポイントを追加(https://yourdomain.com/api/stripe-webhook)
    • 監視イベントを選択(checkout.session.completed 等)
    • Signing secret をコピーして環境変数に設定

    セキュリティチェックリスト:
    • ✅ すべての支払いロジックはバックエンドで完了
    • ✅ Webhook 署名検証
    • ✅ 支払い金額と注文金額の一致検証
    • ✅ 冪等性の実装(stripeSessionId 一意インデックス)
    • ✅ すべての支払いログの記録
    • ✅ 監視アラートの設定(Webhook 失敗率 >5% でアラート)

    監視指標:
    • Webhook 失敗率
    • 決済成功率
    • 注文作成時間(>3秒なら調査)

    監視システムへの接続:
    • Sentry/LogRocket を使用してエラーを記録
    • アラートルールを設定
    • Stripe Dashboard の Webhook ログを定期的にチェック

    テストフロー:
    • テストカード番号 4242 4242 4242 4242 を使用
    • 完全なフローを検証(カート → 決済 → Webhook → 注文作成)
    • 失敗シナリオ(在庫不足、Webhook 失敗等)をテスト

FAQ

Redux と Zustand どちらを選ぶべき?
プロジェクトの規模とチームの状況によります:

• 小規模プロジェクト(<10ページ):Context API で十分。追加のライブラリは不要。
• 中規模プロジェクト(10-50ページ):Zustand が最適。軽量で学習コストが低く、コード量も少ない。
• 大規模プロジェクト(50ページ以上、多チーム連携):Redux Toolkit。ツールチェーンが充実し、デバッグ能力が高く、コミュニティが成熟しています。

具体的なシナリオ:
• 新規プロジェクト、高速イテレーション:Zustand。立ち上がりが早い。
• 既存の Redux プロジェクト:Redux Toolkit のままでOK。
• チームが状態管理に不慣れ:Zustand は学習曲線が緩やかで導入しやすい。

ショッピングカートの場合、コンポーネント間共有、永続化、パフォーマンス最適化が必要なため、Zustand が推奨されます。
なぜフロントエンドで決済成功ロジックを処理してはいけないのですか?
フロントエンドでの処理には致命的な問題があります:

信頼性:
• ユーザーがブラウザを閉じる可能性がある
• ネットワーク切断でリダイレクト失敗の可能性がある
• 意図的に完了ボタンを押さない可能性がある

セキュリティリスク:
• フロントエンドコードは改ざんやバイパスが可能
• 注文作成や在庫減算などの重要操作をフロントエンドに露出してはいけない
• 悪意あるユーザーによる偽造された成功ステータスを防げない

正しいアプローチ:
• すべての重要なおビジネスロジックは Webhook で行う
• Stripe サーバーがバックエンドに直接通知する(ブラウザを経由しない)
• Webhook には署名検証があり安全
• Stripe 公式推奨:Webhook は注文処理の唯一の信頼できる情報源

フロントエンドの success ページは表示専用とし、ビジネスロジックは持たせません。
Webhook 署名検証が常に失敗します。
署名検証失敗の最も一般的な原因と解決策:

最も多い原因(90%):
• Next.js の bodyParser が無効化されていない
• 解決策:API ルートに export const config = { api: { bodyParser: false } } を追加する

その他の原因:
• Webhook Secret 設定ミス(.env.local の STRIPE_WEBHOOK_SECRET を確認)
• 間違った環境キーを使用(テスト環境と本番環境で secret は異なる)
• ミドルウェアによるリクエストボディの変更(グローバルミドルウェアの確認)

デバッグ手順:
1. bodyParser: false が設定されているか確認
2. req.headers['stripe-signature'] が存在するか確認
3. Stripe CLI でテスト:stripe listen --forward-to localhost:3000/api/stripe-webhook
4. CLI 出力の詳細エラーを確認
5. CLI が提供する一時的な webhook secret を使用しているか確認

ローカルテストの注意:
• ローカル開発では必ず Stripe CLI で転送する
• CLI は一時的な secret(whsec_xxxxx)を提供する
• CLI 再起動ごとに新しい secret が生成されるため、.env.local の更新が必要
Webhook の重複呼び出しによる多重注文を防ぐには?
重複注文防止の鍵は冪等性(Idempotency)の実装です:

データベースレベル:
• stripeSessionId フィールドに unique インデックスを追加
• Prisma 例:stripeSessionId String @unique
• データベースが重複挿入を自動的に拒否する

コードレベル:
• 注文作成前に存在確認を行う
• findUnique({ where: { stripeSessionId } }) を使用
• 既に存在する場合はリターンし、新規作成しない

コード例:
```javascript
const existingOrder = await db.order.findUnique({
where: { stripeSessionId: sessionId }
})

if (existingOrder) {
console.log('注文は既に存在します、スキップ')
return
}

// 存在しない場合のみ新規作成
const order = await db.order.create({ ... })
```

なぜ必要なのか:
• Stripe は Webhook を重複送信する可能性がある(ネットワーク問題、リトライ)
• コードは重複呼び出しを安全に処理できなければならない
• 同一決済での多重注文や二重在庫減算を防止

その他の提案:
• Webhook 呼び出しログの記録
• 重複呼び出し頻度の監視
• アラート設定
本番デプロイ後、注文が作成されない場合のトラブルシューティングは?
以下の順序で問題を特定します:

1. Webhook 受信確認:
• Stripe Dashboard → Developers → Webhooks にログイン
• 呼び出し履歴とステータス(成功/失敗)を確認
• 履歴がなければエンドポイント設定に問題あり

2. エンドポイント設定確認:
• URL は正しいか(https://yourdomain.com/api/stripe-webhook)
• イベントタイプに checkout.session.completed が含まれているか
• エンドポイントが有効か

3. 環境変数確認:
• STRIPE_WEBHOOK_SECRET は正しいか
• 本番環境の secret を使用しているか(テスト用ではない)
• デプロイプラットフォーム(Vercel/Netlify)で設定されているか

4. Webhook コード確認:
• bodyParser は無効化されているか
• 署名検証は正しいか
• エラーログは出力されているか

5. アプリログ確認:
• サーバーログ(Vercel Logs/CloudWatch 等)を確認
• エラースタックの有無
• Webhook ハンドラが実行されたか確認

6. 手動テスト:
• Stripe Dashboard で失敗した Webhook を探す
• "Resend" をクリックして手動再送
• 成功するか、またはエラーメッセージを確認

よくあるミス:
• 本番環境への Webhook エンドポイント追加忘れ
• テスト環境の webhook secret の使用
• プラットフォームのファイアウォールによる遮断

解決後の検証:
• テストカード番号で完全な決済フローをテスト
• 注文作成、在庫減算、メール送信の正常動作を確認

8 min read · 公開日: 2026年1月7日 · 更新日: 2026年1月22日

コメント

GitHubアカウントでログインしてコメントできます

関連記事