Next.jsでショップを作る #4 チェックアウトと決済シミュレーション

前回はクライアント側に永続化されたカートを完成させました。カートはユーザーのブラウザの中だけにあり、サーバーはまだその存在を知りません。今回の記事でその境界が初めて破られます — チェックアウトフォームを埋めて決済に進むを押した瞬間、カートが注文となってサーバーに渡ります。Server Actionsがその橋渡し役を担います。

コアの流れ。

  1. ユーザーが/checkoutで配送情報を入力
  2. 「決済する」クリック → Server Action呼び出し
  3. サーバーが決済シミュレーションを実行 (80%成功 / 20%失敗)
  4. 成功なら注文をメモリストアに保存して注文IDを返す
  5. クライアントはカートを空にして/orders/[orderId]に移動
  6. 失敗ならユーザーにエラーを表示して再試行できるように

決済シミュレーション関数 #

まず偽の決済ゲートウェイを作ります。実サービスならStripeのような外部決済サービスのAPIを呼び出すところですが、学習段階では任意の結果を作って分岐を練習します。

src/app/lib/payments.js

src/app/lib/payments.js
const SUCCESS_RATE = 0.8;
const NETWORK_DELAY_MS = 800;

export async function processPayment({ amount }) {
  await new Promise((resolve) => setTimeout(resolve, NETWORK_DELAY_MS));

  if (Math.random() > SUCCESS_RATE) {
    return { ok: false, error: '決済が拒否されました。もう一度お試しください。' };
  }

  const transactionId = `tx_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  return { ok: true, transactionId, amount };
}

NETWORK_DELAY_MSは意図された遅延です。実際の決済サービスの呼び出しが即座には終わらないという事実を、学習コードでも体感するためです — フォームのローディング状態が画面に見えるには、ある程度の時間がかかる必要があります。

SUCCESS_RATE = 0.8は失敗ケースにも頻繁に出会うための意図された値です。5回決済すると平均1回は失敗します。失敗UIを確実に確認できます。

saveOrderを埋める #

第1回で輪郭だけ描いたorders.jsを埋めます。注文IDの生成と保存が入ります。

src/app/lib/orders.js

src/app/lib/orders.js
const orders = new Map();

export function createOrderId() {
  return `ord_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}

export function saveOrder(order) {
  orders.set(order.id, order);
}

export function getOrder(orderId) {
  return orders.get(orderId) ?? null;
}

IDの形式が決済トランザクションIDと似ているのは意図された一貫性です。どちらも時間 + 短いランダム文字列の組み合わせで、人が読むのに負担がなく、衝突の可能性も低いです。

ストアがモジュールスコープのMapであるという点だけ、もう一度強調しておきます。サーバーを再起動するとすべての注文が消えます。学習の流れには十分ですが、最終回でどんなツールに移すべきかを押さえます。

checkoutAction — Server Action #

フォームが呼び出すサーバーアクションを別ファイルで作ります。Server Actionは、ファイルの先頭に'use server'が付いたモジュールのexport関数です。

src/app/checkout/actions.js

src/app/checkout/actions.js
'use server';

import { createOrderId, saveOrder } from '../lib/orders';
import { processPayment } from '../lib/payments';

export async function checkoutAction(_prevState, formData) {
  const name = String(formData.get('name') ?? '').trim();
  const phone = String(formData.get('phone') ?? '').trim();
  const address = String(formData.get('address') ?? '').trim();
  const itemsJson = String(formData.get('items') ?? '[]');

  if (!name || !phone || !address) {
    return { ok: false, error: '配送情報をすべて入力してください。' };
  }

  let items;
  try {
    items = JSON.parse(itemsJson);
  } catch {
    return { ok: false, error: 'カートのデータを読み取れませんでした。' };
  }

  if (!Array.isArray(items) || items.length === 0) {
    return { ok: false, error: 'カートが空です。' };
  }

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

  const payment = await processPayment({ amount: total });
  if (!payment.ok) {
    return { ok: false, error: payment.error };
  }

  const order = {
    id: createOrderId(),
    createdAt: new Date().toISOString(),
    items,
    total,
    shipping: { name, phone, address },
    status: 'paid',
  };

  saveOrder(order);

  return { ok: true, orderId: order.id };
}

設計ポイント。

  • useActionStateのシグネチャ — 第1引数が前の状態、第2引数がFormData。戻り値が次の状態になります。
  • カートはhidden fieldで渡す — カートはクライアント状態なので、フォームに<input type="hidden" name="items" value={JSON} />としてシリアライズして一緒に送ります。
  • 金額はサーバーで再計算 — クライアントが送った価格をそのまま信用しません。学習段階では同じJSONの価格をそのまま合算しますが、実サービスならサーバーの商品マスターから価格を再取得して合算する必要があります。クライアントが送った価格を信頼すると、任意の価格で決済できる脆弱性が生まれます。
  • 検証もサーバーで再度 — クライアントが空欄入力を防いでも、サーバーはそれを再確認します。フォームが直接呼び出される経路 (例: curl) は常に存在します。
  • redirectはクライアントが担当 — サーバーアクションでredirect()を呼ぶこともできますが、カートを空にするクライアント側の処理とまとめるために、redirectはクライアントで処理します。アクションは結果オブジェクトだけを返します。

CheckoutForm — Client Component #

フォームはカート状態(useCart)に依存するのでClient Componentです。useActionStateuseFormStatusでアクションの状態と送信中の状態を扱います。

src/app/checkout/CheckoutForm.js

src/app/checkout/CheckoutForm.js
'use client';

import { useActionState, useEffect } from 'react';
import { useFormStatus } from 'react-dom';
import { useRouter } from 'next/navigation';
import { useCart } from '../components/CartProvider';
import { formatPrice } from '../lib/format';
import { checkoutAction } from './actions';

const INITIAL_STATE = { ok: false, error: null, orderId: null };

export default function CheckoutForm() {
  const { items, totalPrice, clear } = useCart();
  const router = useRouter();
  const [state, formAction] = useActionState(checkoutAction, INITIAL_STATE);

  useEffect(() => {
    if (state.ok && state.orderId) {
      clear();
      router.push(`/orders/${state.orderId}`);
    }
  }, [state, clear, router]);

  return (
    <form action={formAction} className="space-y-6">
      <input type="hidden" name="items" value={JSON.stringify(items)} />

      <section>
        <h2 className="text-lg font-semibold">配送情報</h2>
        <div className="mt-4 space-y-3">
          <LabeledInput name="name" label="名前" autoComplete="name" />
          <LabeledInput name="phone" label="電話番号" autoComplete="tel" type="tel" />
          <LabeledInput name="address" label="住所" autoComplete="street-address" />
        </div>
      </section>

      <section>
        <h2 className="text-lg font-semibold">注文サマリー</h2>
        <ul className="mt-2 divide-y border-y text-sm">
          {items.map((it) => (
            <li key={it.slug} className="flex justify-between py-2">
              <span>{it.name} × {it.quantity}</span>
              <span>{formatPrice(it.price * it.quantity)}</span>
            </li>
          ))}
        </ul>
        <div className="mt-3 flex justify-between text-lg font-semibold">
          <span>合計</span>
          <span>{formatPrice(totalPrice)}</span>
        </div>
      </section>

      {state.error && (
        <div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
          {state.error}
        </div>
      )}

      <SubmitButton />
    </form>
  );
}

function LabeledInput({ name, label, type = 'text', autoComplete }) {
  return (
    <label className="block">
      <span className="block text-sm text-gray-700">{label}</span>
      <input
        name={name}
        type={type}
        autoComplete={autoComplete}
        required
        className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
      />
    </label>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full rounded-md bg-black px-4 py-3 text-white disabled:bg-gray-400"
    >
      {pending ? '決済処理中…' : '決済する'}
    </button>
  );
}

3つのポイントが核心です。

1. useActionState #

フォームがアクションと結び付きます。formAction<form action={...}>に渡すと、フォーム送信がアクション呼び出しにつながります。アクションが返した状態(state)は次のレンダリングに自動で反映されます。

2. useFormStatus #

SubmitButtonがフォームの中にあるとき、useFormStatus()pendingが送信進行中かどうかを教えてくれます。別の状態変数を作る必要がなく、コードがすっきりします。

useFormStatusは必ずフォームの子コンポーネントの中で呼び出さなければなりません。同じコンポーネントで呼び出すと常にfalseが返ります。だからこそSubmitButtonを別コンポーネントに分離しました。

3. 成功後の処理はuseEffect #

サーバーアクションが成功レスポンスを返すとstate.okがtrueになります。その変化をeffectで検知してカートを空にし、注文完了ページに移動します。

router.pushがeffectの中で実行されるのは不自然に見えるかもしれませんが、これが最も自然な位置です。サーバーアクションの中でredirect()を呼ぶと、クライアントのclear()を呼ぶ機会が失われます。逆にeffectで2つをまとめると、1つのトランザクションのように処理されます。

/checkoutページ #

ページ自体は薄いラッパーです。

src/app/checkout/page.js

src/app/checkout/page.js
import CheckoutForm from './CheckoutForm';

export const metadata = {
  title: '決済',
  description: '配送情報を入力して決済を進めます。',
};

export default function CheckoutPage() {
  return (
    <main className="mx-auto max-w-2xl px-4 py-8">
      <h1 className="text-2xl font-bold">決済</h1>
      <div className="mt-6">
        <CheckoutForm />
      </div>
    </main>
  );
}

ページをServer Componentのままにして、フォームだけClient Componentに分離します。ページのメタデータと静的マークアップはサーバーで描画され、フォームだけがクライアントでhydrationされます。おなじみの分離パターンです。

動作確認 #

正常フロー #

  1. 商品をカートに入れる
  2. ヘッダーの「カート」 → 「決済に進む」 → /checkoutに移動
  3. 名前/電話番号/住所を入力して「決済する」
  4. ボタンが「決済処理中…」に変わり、約0.8秒待機
  5. 運が良ければ (80%) /orders/[orderId]に移動。カートは空になる
  6. 再読み込みしてもカートは空のまま (localStorageが更新済み)

失敗フロー #

運が悪ければ (20%) フォームの下に赤いエラーボックスが現れます。カートと入力値はそのまま残っているので、「決済する」をもう一度押すだけで再試行できます。入力を打ち直す必要はなく、カートも空にしません — ユーザーにとって最も滑らかな失敗体験です。

空欄の検証 #

名前を空にして決済を試す → HTML5のrequiredが止めてくれます。クライアントのガードを通過してサーバーまで届いても、サーバーアクションの空欄チェックがもう一度捕まえてくれます。

空カートで直接チェックアウトを試す #

/cartを経由せずに/checkoutのURLを直接開くことができます。カートが空のまま「決済する」を押すと、サーバーアクションが「カートが空です」のエラーで止めます。クライアント側でitems.length === 0のときに「決済する」ボタンを非活性にしてもいいですが、サーバー側のガードだけでもデータ整合性には十分です。

おわりに #

今回の記事では、カートが初めてサーバーと出会う境界を作りました。

  • 決済シミュレーション関数で外部決済サービスへの依存を学習段階から切り離しました
  • 注文IDの発行とメモリ保存を埋めました
  • Server Actionで検証・決済・保存を1つの流れにまとめました
  • useActionStateuseFormStatusでフォーム状態と進行中の状態を扱いました
  • 成功/失敗の分岐をユーザー目線で自然に処理しました (再試行フレンドリー)
  • クライアントガードとサーバーガードの両方を置きました (データ整合性)

次の記事が最終回です。「Next.jsでショップを作る #5 注文完了ページとデプロイ」では、注文照会ページを作り、SEOメタデータを整え、Vercelにデプロイして実際にインターネットに公開します。シリーズの振り返りと「実サービスに行くにはどこに手を入れるべきか」まで整理して締めくくります。

X