Use Cases (更新: 2026/6/1)

Claude CodeでECストアを構築する実装ガイド|Next.js・Stripe Checkout・在庫管理

Claude CodeでECストアを作る手順を、商品一覧、カート、在庫、Stripe Checkout、Webhook、管理画面まで実装コード付きで解説。

Claude CodeでECストアを構築する実装ガイド|Next.js・Stripe Checkout・在庫管理

ECストア構築でClaude Codeに任せる範囲

ECストアは「商品ページを並べて決済ボタンを置く」だけでは公開できません。商品一覧、商品詳細、カート、在庫引き当て、注文作成、Stripe Checkout、Webhookによる注文確定、管理画面、SEO、計測、返品やキャンセルの運用までが1本の導線としてつながって初めて売上を受けられます。

Claude Codeを使う価値は、個別の部品を速く書くことよりも、こうした業務境界を崩さずに実装タスクへ分解できる点にあります。Masaが小規模な物販デモを作ったときも、最初に「決済成功画面で注文確定する」設計にしてしまい、Webhookの再送や顧客が戻ってこないケースを後から直すことになりました。最初からClaude Codeに「支払い確認はWebhookを正とする」「在庫はサーバー側で再計算する」と条件を渡すだけで、手戻りはかなり減ります。

この記事では、Next.js App RouterとTypeScriptを前提に、コピペして検証できる最小構成を示します。実運用ではPrisma、PostgreSQL、Shopify連携、倉庫システムなどへ置き換える部分がありますが、注文確定の考え方は同じです。Stripe Checkoutの仕様は公式のCheckout Sessions APICheckoutのfulfillmentガイドを確認しながら進めてください。Next.js側はRoute HandlersMetadata APIを参照します。

関連して、決済だけを深掘りするならClaude CodeでStripe Checkout決済を実装する方法、検索流入を伸ばすならClaude CodeでSEO対策を自動化する方法、管理画面を強化するならClaude Codeでダッシュボードを開発する方法も合わせて読むと設計がつながります。

全体アーキテクチャ

ECストアでは、ユーザー画面と管理画面を同じ注文データに接続します。特に「注文を作る」「在庫を引き当てる」「支払いを確認する」「発送やキャンセルを記録する」の4点を分けると、Claude Codeへの依頼もレビューも明確になります。

flowchart LR
  A["商品一覧・商品詳細"] --> B["カート"]
  B --> C["注文作成API"]
  C --> D["在庫引き当て"]
  D --> E["Stripe Checkout Session"]
  E --> F["Webhook"]
  F --> G["注文確定・メール・発送待ち"]
  G --> H["管理画面"]
  H --> I["発送・返品・キャンセル"]
  A --> J["SEO・構造化データ"]
  B --> K["計測イベント"]

この図をそのままClaude Codeへの作業単位にできます。例えば「注文作成APIと在庫引き当てを実装」「Webhookで支払い済み注文だけを確定」「管理画面に発送・キャンセル操作を追加」のように分けます。1回のプロンプトでEC全体を丸投げすると、決済成功画面、Webhook、在庫復元、返金処理が混ざってレビューしづらくなります。

Claude Codeへの初回依頼は、次のように制約を具体化します。

Next.js App Router + TypeScriptで小規模ECストアの最小実装を作ってください。
商品一覧、カート、在庫引き当て、Stripe Checkout Session作成、
Webhookによる注文確定、管理画面の発送・キャンセル操作を分けてください。
金額と在庫はクライアント値を信用せず、サーバー側の商品データから再計算してください。
Webhookは署名検証し、checkout.session.completedとasync_payment_succeededを扱ってください。
成功URLだけで注文確定しないでください。

商品・在庫・注文のデータモデル

最初に、商品と注文の境界を決めます。以下はデモ用のインメモリ実装です。ローカル検証ではそのまま動かせますが、Vercelや複数サーバーで運用する場合はPostgreSQLなどの永続DBへ置き換えてください。重要なのは「価格」「在庫」「注文ステータス」をサーバー側で管理することです。

// src/lib/store.ts
export type Product = {
  id: string;
  slug: string;
  name: string;
  description: string;
  priceJPY: number;
  stock: number;
  active: boolean;
  image: string;
};

export type CartLine = {
  productId: string;
  quantity: number;
};

export type OrderLine = CartLine & {
  name: string;
  unitAmount: number;
};

export type OrderStatus = "pending" | "paid" | "shipped" | "canceled" | "refunded";

export type Order = {
  id: string;
  lines: OrderLine[];
  amountTotal: number;
  status: OrderStatus;
  reserved: boolean;
  stripeSessionId?: string;
  customerEmail?: string;
  createdAt: string;
  updatedAt: string;
};

const products: Product[] = [
  {
    id: "tea-001",
    slug: "roasted-green-tea",
    name: "ほうじ茶ギフトセット",
    description: "初回購入向けのギフト箱付きセット。",
    priceJPY: 3200,
    stock: 12,
    active: true,
    image: "/images/products/tea.jpg",
  },
  {
    id: "mug-001",
    slug: "ceramic-mug",
    name: "手仕事マグカップ",
    description: "少量生産の陶器マグ。返品時は割れ確認が必要です。",
    priceJPY: 4800,
    stock: 6,
    active: true,
    image: "/images/products/mug.jpg",
  },
];

const stock = new Map<string, number>(products.map((product) => [product.id, product.stock]));
const orders = new Map<string, Order>();

export function listProducts(): Product[] {
  return products
    .filter((product) => product.active)
    .map((product) => ({ ...product, stock: stock.get(product.id) ?? 0 }));
}

export function getProduct(productIdOrSlug: string): Product | undefined {
  return listProducts().find(
    (product) => product.id === productIdOrSlug || product.slug === productIdOrSlug,
  );
}

function normalizeLines(lines: CartLine[]): CartLine[] {
  const merged = new Map<string, number>();

  for (const line of lines) {
    if (!Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > 20) {
      throw new Error("数量は1から20の範囲で指定してください。");
    }
    merged.set(line.productId, (merged.get(line.productId) ?? 0) + line.quantity);
  }

  return Array.from(merged, ([productId, quantity]) => ({ productId, quantity }));
}

function requireOrder(orderId: string): Order {
  const order = orders.get(orderId);
  if (!order) throw new Error("注文が見つかりません。");
  return order;
}

export function createPendingOrder(lines: CartLine[]): Order {
  const normalized = normalizeLines(lines);
  const orderLines = normalized.map((line) => {
    const product = getProduct(line.productId);
    if (!product) throw new Error(`商品が見つかりません: ${line.productId}`);

    const availableStock = stock.get(product.id) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${product.name}の在庫が不足しています。`);
    }

    return {
      productId: product.id,
      quantity: line.quantity,
      name: product.name,
      unitAmount: product.priceJPY,
    };
  });

  const now = new Date().toISOString();
  const order: Order = {
    id: crypto.randomUUID(),
    lines: orderLines,
    amountTotal: orderLines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0),
    status: "pending",
    reserved: false,
    createdAt: now,
    updatedAt: now,
  };

  orders.set(order.id, order);
  return order;
}

export function reserveOrderStock(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.reserved) return order;

  for (const line of order.lines) {
    const availableStock = stock.get(line.productId) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${line.name}の在庫が不足しています。`);
    }
  }

  for (const line of order.lines) {
    stock.set(line.productId, (stock.get(line.productId) ?? 0) - line.quantity);
  }

  order.reserved = true;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function attachStripeSession(orderId: string, stripeSessionId: string): Order {
  const order = requireOrder(orderId);
  order.stripeSessionId = stripeSessionId;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function fulfillPaidOrder(input: {
  orderId: string;
  stripeSessionId: string;
  customerEmail?: string;
}): Order {
  const order = requireOrder(input.orderId);
  if (order.status === "paid" || order.status === "shipped") return order;

  if (!order.reserved) reserveOrderStock(order.id);

  order.status = "paid";
  order.stripeSessionId = input.stripeSessionId;
  order.customerEmail = input.customerEmail;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function markOrderShipped(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.status !== "paid") throw new Error("発送できるのは支払い済み注文だけです。");
  order.status = "shipped";
  order.updatedAt = new Date().toISOString();
  return order;
}

export function cancelOrder(orderId: string, reason = "customer_canceled"): Order {
  const order = requireOrder(orderId);
  if (order.status === "canceled" || order.status === "refunded") return order;

  if (order.status === "pending" && order.reserved) {
    for (const line of order.lines) {
      stock.set(line.productId, (stock.get(line.productId) ?? 0) + line.quantity);
    }
    order.reserved = false;
  }

  order.status = "canceled";
  order.updatedAt = new Date().toISOString();
  console.info(`Order ${order.id} canceled: ${reason}`);
  return order;
}

export function markOrderRefunded(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.status !== "paid" && order.status !== "shipped") {
    throw new Error("返金対象は支払い済みまたは発送済み注文です。");
  }
  order.status = "refunded";
  order.updatedAt = new Date().toISOString();
  return order;
}

export function listOrders(): Order[] {
  return Array.from(orders.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

このコードでClaude Codeにレビューさせる観点は、数量の上限、在庫不足時のエラー、同じ注文を複数回処理したときの安全性です。fulfillPaidOrderが支払い済み注文を二重に処理しないこと、cancelOrderが未払い注文の在庫だけを戻すことを必ず確認します。

商品一覧とカートを実装する

クライアント側のカートは便利ですが、金額や在庫の正しさは信用しません。ユーザー体験としては小計を表示し、チェックアウト時にサーバーへ商品IDと数量だけを送ります。価格をクライアントから送る設計は改ざんに弱いため避けます。

// src/components/product-grid-with-cart.tsx
"use client";

import { useMemo, useState } from "react";
import type { CartLine, Product } from "@/lib/store";

type CheckoutResponse = {
  url?: string;
  error?: string;
};

export function ProductGridWithCart({ products }: { products: Product[] }) {
  const [cart, setCart] = useState<CartLine[]>([]);
  const [loading, setLoading] = useState(false);

  const subtotal = useMemo(() => {
    return cart.reduce((sum, line) => {
      const product = products.find((item) => item.id === line.productId);
      return sum + (product?.priceJPY ?? 0) * line.quantity;
    }, 0);
  }, [cart, products]);

  function addToCart(productId: string) {
    setCart((current) => {
      const existing = current.find((line) => line.productId === productId);
      if (existing) {
        return current.map((line) =>
          line.productId === productId ? { ...line, quantity: line.quantity + 1 } : line,
        );
      }
      return [...current, { productId, quantity: 1 }];
    });
  }

  async function checkout() {
    try {
      setLoading(true);
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ lines: cart }),
      });
      const data = (await response.json()) as CheckoutResponse;

      if (!response.ok || !data.url) {
        throw new Error(data.error ?? "チェックアウトを開始できませんでした。");
      }

      window.location.href = data.url;
    } catch (error) {
      alert(error instanceof Error ? error.message : "チェックアウトに失敗しました。");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="grid gap-8 lg:grid-cols-[1fr_320px]">
      <div className="grid gap-6 sm:grid-cols-2">
        {products.map((product) => (
          <article key={product.id} className="rounded-lg border p-4">
            <img src={product.image} alt={product.name} className="aspect-square w-full object-cover" />
            <h2 className="mt-3 text-lg font-semibold">{product.name}</h2>
            <p className="mt-1 text-sm text-gray-600">{product.description}</p>
            <p className="mt-3 font-bold">{product.priceJPY.toLocaleString()}円</p>
            <p className="text-sm text-gray-500">在庫: {product.stock}</p>
            <button
              type="button"
              disabled={product.stock < 1}
              onClick={() => addToCart(product.id)}
              className="mt-4 w-full rounded bg-black px-4 py-2 text-white disabled:bg-gray-300"
            >
              カートに追加
            </button>
          </article>
        ))}
      </div>

      <aside className="h-fit rounded-lg border p-4">
        <h2 className="text-lg font-semibold">カート</h2>
        {cart.length === 0 ? (
          <p className="mt-3 text-sm text-gray-500">商品が入っていません。</p>
        ) : (
          <ul className="mt-3 space-y-2">
            {cart.map((line) => {
              const product = products.find((item) => item.id === line.productId);
              return (
                <li key={line.productId} className="flex justify-between text-sm">
                  <span>{product?.name}</span>
                  <span>{line.quantity}点</span>
                </li>
              );
            })}
          </ul>
        )}
        <p className="mt-4 font-bold">小計: {subtotal.toLocaleString()}円</p>
        <button
          type="button"
          disabled={cart.length === 0 || loading}
          onClick={checkout}
          className="mt-4 w-full rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
        >
          {loading ? "移動中..." : "Stripe Checkoutへ進む"}
        </button>
      </aside>
    </div>
  );
}

Claude Codeにこのコンポーネントを作らせる場合は、「見た目」だけでなく「サーバーに送る値」を指定します。送る値はproductIdquantityだけです。商品名、価格、在庫数、送料、税額はサーバーで再取得します。

Stripe Checkout Sessionを作るAPI

Stripe Checkoutは決済画面、3Dセキュア、配送先入力などを任せられるため、小規模ECでは最初の選択肢にしやすいです。ただし、Checkout Sessionを作る前に注文を作り、在庫を引き当て、metadataに自社の注文IDを入れます。Stripeのmetadataにはカード情報や住所などの機微情報を入れません。

// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import {
  attachStripeSession,
  createPendingOrder,
  getProduct,
  reserveOrderStock,
} from "@/lib/store";

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

export async function POST(request: NextRequest) {
  try {
    const { lines } = (await request.json()) as {
      lines: { productId: string; quantity: number }[];
    };

    const order = createPendingOrder(lines);
    reserveOrderStock(order.id);

    const session = await stripe.checkout.sessions.create({
      mode: "payment",
      line_items: order.lines.map((line) => {
        const product = getProduct(line.productId);
        if (!product) throw new Error(`商品が見つかりません: ${line.productId}`);

        return {
          price_data: {
            currency: "jpy",
            product_data: {
              name: product.name,
              images: [`${process.env.NEXT_PUBLIC_APP_URL}${product.image}`],
              metadata: { productId: product.id },
            },
            unit_amount: product.priceJPY,
          },
          quantity: line.quantity,
        };
      }),
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart?canceled=1`,
      shipping_address_collection: {
        allowed_countries: ["JP"],
      },
      metadata: {
        orderId: order.id,
      },
    });

    attachStripeSession(order.id, session.id);
    return NextResponse.json({ url: session.url }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "チェックアウトに失敗しました。" },
      { status: 400 },
    );
  }
}

環境変数は最低限このように置きます。公開リポジトリへ秘密鍵をコミットしないでください。

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000

ここでの落とし穴は、Stripe Checkoutへ渡すunit_amountをクライアントの小計から作ることです。必ずサーバーの商品データから金額を作ります。また、在庫引き当て後にCheckoutを完了しないユーザーがいるため、checkout.session.expiredや一定時間経過のバッチで在庫を戻す運用も必要です。

Webhookで注文を確定する

注文確定は成功URLではなくWebhookで行います。Stripe公式のfulfillmentガイドでも、顧客が成功ページに戻らない場合があるためWebhookが必要だと説明されています。さらに、Webhookは再送されることがあるので、同じCheckout Sessionを複数回受け取っても二重発送や二重メールにならないようにします。

// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { cancelOrder, fulfillPaidOrder } from "@/lib/store";

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

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Invalid webhook" },
      { status: 400 },
    );
  }

  if (
    event.type === "checkout.session.completed" ||
    event.type === "checkout.session.async_payment_succeeded"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    const orderId = session.metadata?.orderId;

    if (orderId && session.payment_status === "paid") {
      fulfillPaidOrder({
        orderId,
        stripeSessionId: session.id,
        customerEmail: session.customer_details?.email ?? undefined,
      });
    }
  }

  if (
    event.type === "checkout.session.expired" ||
    event.type === "checkout.session.async_payment_failed"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    const orderId = session.metadata?.orderId;
    if (orderId) cancelOrder(orderId, event.type);
  }

  return NextResponse.json({ received: true });
}

ローカルではStripe CLIでWebhookを転送します。

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

レビュー時は、署名検証、payment_statusの確認、非同期決済の成功・失敗、在庫復元、冪等性を見ます。冪等性とは、同じ処理が複数回呼ばれても結果が壊れない性質です。ECではWebhook再送、管理画面の二重クリック、ネットワーク再試行で必ず必要になります。

管理画面、返品、キャンセル運用

管理画面は「見られる」だけでは不十分です。支払い済み注文を発送済みにする、未払い注文をキャンセルする、返金済み注文を識別する、CSVや倉庫連携に渡す、といった運用が必要です。最初のバージョンでは、権限を持つ管理者だけがアクセスできる前提にし、操作ログを残す設計にします。

// src/app/admin/orders/page.tsx
import { cancelOrder, listOrders, markOrderShipped } from "@/lib/store";

async function shipOrder(formData: FormData) {
  "use server";
  markOrderShipped(String(formData.get("orderId")));
}

async function cancelPendingOrder(formData: FormData) {
  "use server";
  cancelOrder(String(formData.get("orderId")), "admin_canceled");
}

export default function AdminOrdersPage() {
  const orders = listOrders();

  return (
    <main className="mx-auto max-w-5xl p-6">
      <h1 className="text-2xl font-bold">注文管理</h1>
      <div className="mt-6 overflow-x-auto">
        <table className="w-full border-collapse text-sm">
          <thead>
            <tr className="border-b text-left">
              <th className="py-2">注文ID</th>
              <th className="py-2">状態</th>
              <th className="py-2">金額</th>
              <th className="py-2">メール</th>
              <th className="py-2">操作</th>
            </tr>
          </thead>
          <tbody>
            {orders.map((order) => (
              <tr key={order.id} className="border-b">
                <td className="py-3 font-mono text-xs">{order.id}</td>
                <td className="py-3">{order.status}</td>
                <td className="py-3">{order.amountTotal.toLocaleString()}円</td>
                <td className="py-3">{order.customerEmail ?? "-"}</td>
                <td className="flex gap-2 py-3">
                  <form action={shipOrder}>
                    <input type="hidden" name="orderId" value={order.id} />
                    <button
                      type="submit"
                      disabled={order.status !== "paid"}
                      className="rounded bg-black px-3 py-1 text-white disabled:bg-gray-300"
                    >
                      発送済みにする
                    </button>
                  </form>
                  <form action={cancelPendingOrder}>
                    <input type="hidden" name="orderId" value={order.id} />
                    <button
                      type="submit"
                      disabled={order.status !== "pending"}
                      className="rounded border px-3 py-1 disabled:text-gray-300"
                    >
                      キャンセル
                    </button>
                  </form>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </main>
  );
}

返品とキャンセルは事業ルールを先に決めます。発送前キャンセルは在庫を戻せますが、発送後返品は商品状態を確認してから再販可、アウトレット、廃棄のどれかに分けます。返金はStripe Dashboardで手動処理する段階でも構いませんが、注文ステータスには必ず反映します。Claude Codeには「返金APIを自動で叩く」より先に「どの状態で何を許可するか」を実装させる方が安全です。

SEOと計測を後回しにしない

ECは商品ページごとに検索流入を取れるため、SEOを後付けにすると機会損失になります。商品名、用途、素材、配送条件、返品条件を本文に含め、OGP画像とcanonicalを設定します。計測では、商品閲覧、カート追加、Checkout開始、購入完了を同じ命名で送ると改善しやすくなります。

// src/app/products/[slug]/metadata.ts
import type { Metadata } from "next";
import { getProduct } from "@/lib/store";

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const product = getProduct(params.slug);

  if (!product) {
    return {
      title: "商品が見つかりません",
      robots: { index: false, follow: false },
    };
  }

  const title = `${product.name} | ClaudeCodeLab Store`;
  const description = `${product.description} 価格は${product.priceJPY.toLocaleString()}円。在庫、配送、返品条件を確認して購入できます。`;

  return {
    title,
    description,
    alternates: {
      canonical: `/products/${product.slug}`,
    },
    openGraph: {
      title,
      description,
      images: [product.image],
      type: "website",
    },
  };
}
// src/lib/analytics.ts
type CommerceEventName = "view_item" | "add_to_cart" | "begin_checkout" | "purchase";

type CommercePayload = {
  currency: "JPY";
  value: number;
  items: Array<{
    item_id: string;
    item_name: string;
    price: number;
    quantity: number;
  }>;
};

declare global {
  interface Window {
    dataLayer?: unknown[];
  }
}

export function trackCommerceEvent(name: CommerceEventName, payload: CommercePayload) {
  if (typeof window === "undefined") return;
  window.dataLayer = window.dataLayer ?? [];
  window.dataLayer.push({
    event: name,
    ecommerce: payload,
  });
}

SEOと計測もClaude Codeに任せられますが、レビュー観点は「検索意図に合う本文か」「売上改善に使うイベント名がそろっているか」です。タグを入れただけでは改善できません。商品ごとの検索語、購入前の不安、返品条件、配送日数までページに出す必要があります。

3つの実例で考える実装優先度

1つ目は、D2Cブランドの初回販売です。商品点数が少ないため、凝った検索よりも商品ページ、カート、Stripe Checkout、Webhook、発送管理を優先します。キャンペーン開始時に在庫が一気に減るので、クライアント側の在庫表示よりサーバー側の引き当てが重要です。

2つ目は、デジタル教材やPDF販売です。発送は不要ですが、支払い後にダウンロード権限を付与するfulfillmentが必要です。成功URLだけでファイルURLを出すと、支払い確認前にアクセスされる可能性があります。Webhookで注文を支払い済みにしてから、ユーザーにダウンロード権限を付けます。

3つ目は、B2B向けの小ロット発注サイトです。価格表、請求書払い、承認フローが入るため、最初から全自動決済にしない方がよい場合があります。Stripe Checkoutはカード決済用に残しつつ、管理画面に「見積もり中」「請求書送付済み」「入金確認済み」を追加する設計にします。

ユースケース最優先の実装注意点
D2C初回販売在庫引き当て、Checkout、発送管理SNS流入で同時購入が起きる
デジタル教材Webhook後の権限付与成功URLだけで配布しない
B2B発注管理画面、見積もり、ステータス管理決済前後の人手確認が残る

失敗例と落とし穴

一番多い失敗は、success_urlに戻ってきたら注文確定にする実装です。顧客が決済後にブラウザを閉じる、通信が切れる、リダイレクト前に離脱するケースがあります。注文確定はWebhookを正とし、成功ページは「注文状態を表示する補助」と考えます。

次の失敗は、カートの価格を信用することです。ブラウザ上のJavaScriptは変更できます。ユーザーが送った小計、割引率、送料をそのまま使うと、不正な金額でCheckout Sessionを作れてしまいます。APIでは商品IDから価格を再取得し、クーポンもサーバーで検証します。

在庫の落とし穴もあります。Checkout開始時に在庫を減らすと、未決済のまま離脱した注文で在庫が埋まります。逆に支払い後に初めて在庫を減らすと、同時購入で売り越しが起きます。現実的には、短時間の在庫引き当て、期限切れ時の復元、管理画面での手動調整を組み合わせます。

セキュリティ面では、Stripe秘密鍵をクライアントに出す、Webhook署名を検証しない、管理画面を認証なしで公開する、metadataへ個人情報を詰め込む、といったミスが致命的です。Claude Codeに実装させるときは、権限境界と秘密情報の扱いをプロンプトに必ず書きます。

Claude Codeへのレビュー指示

実装後は、Claude Codeに次の観点でセルフレビューさせます。単に「バグを探して」ではなく、ECの事故につながる観点を指定します。

このEC実装をレビューしてください。
特に、金額改ざん、在庫の二重引き当て、Webhook再送時の二重発送、
未払い注文の発送、キャンセル時の在庫復元、管理画面の権限、
Stripe秘密鍵とWebhook secretの露出、metadataへの個人情報混入を確認してください。
問題がある箇所はファイル名、関数名、再現手順、修正案をセットで出してください。

人間側のレビューでは、テストカードでの成功、カード認証失敗、Checkout期限切れ、Webhook再送、同じ注文の発送ボタン二重クリック、返金後の表示を確認します。問い合わせ増加を狙う記事やサービス導線では、最後に「この実装を自社の商品に合わせるならどこを変えるか」を明示すると、相談につながりやすくなります。

まとめ

Claude CodeでECストアを作るなら、商品一覧やカートのUIから始めるだけでなく、注文、在庫、決済、Webhook、管理画面、SEO、計測、返品・キャンセル運用を一つの業務フローとして設計することが重要です。特にStripe Checkout連携では、成功URLではなくWebhookで注文確定すること、金額と在庫をサーバー側で再計算すること、同じイベントを複数回受けても壊れないことが実運用の分かれ目です。

ClaudeCodeLabでは、Claude Codeを使ったECプロトタイプ、Stripe Checkout連携、管理画面、SEO改善、チーム向け研修の相談を受けています。既存のNext.jsアプリに決済を足したい、商品登録から発送管理までの流れを整理したい、Claude Codeを社内開発に安全に入れたい場合は、実装対象と現在の課題を整理して相談してください。

この記事で紹介した内容を実際に試すときの確認ポイントは、stripe listenでWebhookを受けられること、テストカードでcheckout.session.completedが届くこと、成功ページを閉じても注文が支払い済みになること、在庫不足時にCheckout Sessionが作られないこと、管理画面の発送・キャンセル操作が意図した注文状態だけで有効になることです。ここまで確認できれば、公開前レビューに進める土台になります。

#Claude Code #EC #Stripe #Next.js #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。