Next.jsでショップを作る #4 チェックアウトと決済シミュレーション
前回はクライアント側に永続化されたカートを完成させました。カートはユーザーのブラウザの中だけにあり、サーバーはまだその存在を知りません。今回の記事でその境界が初めて破られます — チェックアウトフォームを埋めて決済に進むを押した瞬間、カートが注文となってサーバーに渡ります。Server Actionsがその橋渡し役を担います。
コアの流れ。
- ユーザーが
/checkoutで配送情報を入力 - 「決済する」クリック → Server Action呼び出し
- サーバーが決済シミュレーションを実行 (80%成功 / 20%失敗)
- 成功なら注文をメモリストアに保存して注文IDを返す
- クライアントはカートを空にして
/orders/[orderId]に移動 - 失敗ならユーザーにエラーを表示して再試行できるように
決済シミュレーション関数 #
まず偽の決済ゲートウェイを作ります。実サービスならStripeのような外部決済サービスのAPIを呼び出すところですが、学習段階では任意の結果を作って分岐を練習します。
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。
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。
'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です。useActionStateとuseFormStatusでアクションの状態と送信中の状態を扱います。
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。
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されます。おなじみの分離パターンです。
動作確認 #
正常フロー #
- 商品をカートに入れる
- ヘッダーの「カート」 → 「決済に進む」 →
/checkoutに移動 - 名前/電話番号/住所を入力して「決済する」
- ボタンが「決済処理中…」に変わり、約0.8秒待機
- 運が良ければ (80%)
/orders/[orderId]に移動。カートは空になる - 再読み込みしてもカートは空のまま (localStorageが更新済み)
失敗フロー #
運が悪ければ (20%) フォームの下に赤いエラーボックスが現れます。カートと入力値はそのまま残っているので、「決済する」をもう一度押すだけで再試行できます。入力を打ち直す必要はなく、カートも空にしません — ユーザーにとって最も滑らかな失敗体験です。
空欄の検証 #
名前を空にして決済を試す → HTML5のrequiredが止めてくれます。クライアントのガードを通過してサーバーまで届いても、サーバーアクションの空欄チェックがもう一度捕まえてくれます。
空カートで直接チェックアウトを試す #
/cartを経由せずに/checkoutのURLを直接開くことができます。カートが空のまま「決済する」を押すと、サーバーアクションが「カートが空です」のエラーで止めます。クライアント側でitems.length === 0のときに「決済する」ボタンを非活性にしてもいいですが、サーバー側のガードだけでもデータ整合性には十分です。
おわりに #
今回の記事では、カートが初めてサーバーと出会う境界を作りました。
- 決済シミュレーション関数で外部決済サービスへの依存を学習段階から切り離しました
- 注文IDの発行とメモリ保存を埋めました
- Server Actionで検証・決済・保存を1つの流れにまとめました
useActionStateとuseFormStatusでフォーム状態と進行中の状態を扱いました- 成功/失敗の分岐をユーザー目線で自然に処理しました (再試行フレンドリー)
- クライアントガードとサーバーガードの両方を置きました (データ整合性)
次の記事が最終回です。「Next.jsでショップを作る #5 注文完了ページとデプロイ」では、注文照会ページを作り、SEOメタデータを整え、Vercelにデプロイして実際にインターネットに公開します。シリーズの振り返りと「実サービスに行くにはどこに手を入れるべきか」まで整理して締めくくります。