Use Cases (Mis à jour: 01/06/2026)

Créer une boutique e-commerce avec Claude Code : Next.js, Stripe Checkout et stock

Guide pratique pour créer une boutique avec Claude Code : produits, panier, stock, Stripe Checkout, Webhook, admin, SEO et retours.

Créer une boutique e-commerce avec Claude Code : Next.js, Stripe Checkout et stock

Ce que Claude Code doit prendre en charge

Une boutique e-commerce ne se limite pas à une grille de produits et à un bouton de paiement. Pour vendre sans casser l’exploitation, il faut relier catalogue, page produit, panier, réservation de stock, création de commande, Stripe Checkout, confirmation par Webhook, administration, SEO, mesure, retours et annulations.

Claude Code est utile quand on lui confie des tâches avec des frontières métier claires. Sur un petit prototype de produits physiques, Masa avait d’abord confirmé les commandes depuis la page de succès. Ce choix a vite montré ses limites : le client peut fermer le navigateur après le paiement, Stripe peut renvoyer un Webhook, et une session Checkout expirée doit libérer le stock. La règle fiable est donc simple : le Webhook confirme le paiement, et le serveur recalcule prix et stock.

Les exemples utilisent Next.js App Router et TypeScript. Le stockage est en mémoire pour faciliter les essais locaux ; en production, remplacez-le par une base persistante. Pour la partie paiement, vérifiez la documentation officielle de Stripe sur les Checkout Sessions et le fulfillment Checkout. Côté Next.js, consultez les Route Handlers et la Metadata API.

Pour approfondir, lisez aussi Stripe Checkout avec Claude Code, SEO avec Claude Code et développement de dashboard avec Claude Code.

Architecture de référence

Séparez quatre responsabilités : créer la commande, réserver le stock, confirmer le paiement, puis exploiter la commande après paiement. Cette séparation rend le travail de Claude Code plus contrôlable.

flowchart LR
  A["Liste et détail produit"] --> B["Panier"]
  B --> C["API de création de commande"]
  C --> D["Réservation de stock"]
  D --> E["Stripe Checkout Session"]
  E --> F["Webhook"]
  F --> G["Commande payée et fulfillment"]
  G --> H["Administration"]
  H --> I["Expédition, retours, annulations"]
  A --> J["SEO et données structurées"]
  B --> K["Événements analytics"]

Un prompt efficace doit préciser les règles opérationnelles.

Crée une boutique e-commerce avec Next.js App Router et TypeScript.
Sépare catalogue, panier, réservation de stock, création de Stripe Checkout Session,
confirmation de commande par Webhook et actions admin d'expédition/annulation.
Ne fais pas confiance aux prix ni aux stocks envoyés par le client.
Vérifie la signature Webhook Stripe et traite checkout.session.completed et async_payment_succeeded.
Ne marque pas une commande comme payée uniquement parce que success_url a été visitée.

Modèle produit, stock et commande

Ce module est copiable pour un test local. Il n’est pas fait pour la production, car le stockage en mémoire disparaît au redémarrage. Le point important est que le serveur possède le prix, le stock et l’état de commande.

// 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: "Coffret cadeau de hojicha",
    description: "Un coffret pensé pour un premier achat.",
    priceJPY: 3200,
    stock: 12,
    active: true,
    image: "/images/products/tea.jpg",
  },
  {
    id: "mug-001",
    slug: "ceramic-mug",
    name: "Mug en céramique artisanal",
    description: "Petite série. Inspectez les retours avant remise en stock.",
    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("La quantité doit être comprise entre 1 et 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("Commande introuvable.");
  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(`Produit introuvable: ${line.productId}`);

    const availableStock = stock.get(product.id) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${product.name} n'a pas assez de stock.`);
    }

    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} n'a pas assez de stock.`);
    }
  }

  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("Seules les commandes payées peuvent être expédiées.");
  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("Seules les commandes payées ou expédiées peuvent être remboursées.");
  }
  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));
}

Demandez ensuite à Claude Code de vérifier la validation des quantités, la rupture de stock, l’idempotence du fulfillment et la restitution du stock lors d’une annulation.

Catalogue et panier

Le panier côté client améliore l’expérience, mais il ne doit pas décider le prix final. Envoyez uniquement les IDs produits et les quantités.

// 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 ?? "Impossible de démarrer Checkout.");
      }

      window.location.href = data.url;
    } catch (error) {
      alert(error instanceof Error ? error.message : "Checkout a échoué.");
    } 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">JPY {product.priceJPY.toLocaleString()}</p>
            <p className="text-sm text-gray-500">Stock: {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">
              Ajouter au panier
            </button>
          </article>
        ))}
      </div>

      <aside className="h-fit rounded-lg border p-4">
        <h2 className="text-lg font-semibold">Panier</h2>
        {cart.length === 0 ? (
          <p className="mt-3 text-sm text-gray-500">Votre panier est vide.</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">Sous-total: JPY {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 ? "Redirection..." : "Continuer vers Stripe Checkout"}
        </button>
      </aside>
    </div>
  );
}

API Stripe Checkout

Créez la commande avant de rediriger vers Stripe. Réservez le stock, calculez les montants côté serveur et stockez votre ID de commande dansmetadata. N’y mettez pas de données personnelles sensibles.

// 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(`Produit introuvable: ${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 : "Checkout a échoué." },
      { status: 400 },
    );
  }
}
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000

Le piège classique consiste à créerunit_amount depuis le sous-total du navigateur. Le serveur doit recalculer. Prévoyez aussi la libération du stock quand une session Checkout expire.

Confirmation par Webhook

La page de succès n’est pas une preuve de fulfillment. Un client peut payer puis ne jamais revenir sur votre site. Le Webhook est la source de vérité, et son traitement doit être idempotent.

// 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 listen --forward-to localhost:3000/api/stripe/webhook

Vérifiez la signature,payment_status, les moyens de paiement différés, la restitution du stock et l’idempotence.

Admin, retours et annulations

L’administration fait partie du produit. Elle doit afficher les commandes payées, permettre l’expédition, annuler les commandes en attente et distinguer les remboursements.

// 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">Commandes</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">Commande</th>
              <th className="py-2">Statut</th>
              <th className="py-2">Montant</th>
              <th className="py-2">Email</th>
              <th className="py-2">Actions</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">JPY {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">
                      Marquer expédiée
                    </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">
                      Annuler
                    </button>
                  </form>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </main>
  );
}

Définissez les règles de retour avant l’automatisation. Une annulation avant expédition peut rendre le stock disponible ; un retour après expédition demande souvent une inspection.

SEO et mesure

Chaque fiche produit peut capter du trafic de recherche. Ajoutez nom, usage, matière, livraison, retour, image OGP et canonical. Pour la mesure, gardez les mêmes noms d’événements : vue produit, ajout panier, début Checkout, achat.

// 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: "Produit introuvable",
      robots: { index: false, follow: false },
    };
  }

  const title = `${product.name} | ClaudeCodeLab Store`;
  const description = `${product.description} Prix: JPY ${product.priceJPY.toLocaleString()}. Vérifiez le stock, la livraison et les retours avant l'achat.`;

  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,
  });
}

Trois cas d’usage

Un lancement D2C doit prioriser la réservation de stock, Checkout, Webhook et l’expédition. Le trafic social peut créer plusieurs achats simultanés.

Une boutique de produits numériques n’a pas d’expédition, mais elle a du fulfillment. N’exposez pas un lien de téléchargement uniquement sur la page de succès ; donnez l’accès après le Webhook.

Un portail B2B peut garder une validation humaine. Gardez Stripe Checkout pour la carte bancaire et ajoutez des statuts comme devis demandé, facture envoyée, paiement confirmé.

CasPrioritéPoint d’attention
Lancement D2CStock, Checkout, expéditionAchats simultanés
Produit numériqueAccès après WebhookNe pas dépendre de success_url
B2BAdmin, devis, statutsValidation humaine possible

Échecs à éviter

Le premier échec est de confirmer la commande sursuccess_url. Le client peut payer sans revenir sur le site. Le Webhook doit être la source de vérité.

Le deuxième est de faire confiance au total du panier dans le navigateur. Le JavaScript client se modifie. Recalculez prix, remises, livraison et stock côté serveur.

Le stock est aussi piégeux. Réserver trop tôt bloque du stock sur des sessions abandonnées ; réserver trop tard peut provoquer de la survente. Utilisez des réservations courtes, la libération à l’expiration et une correction manuelle.

En sécurité, ne publiez jamais la clé secrète Stripe, ne sautez pas la vérification de signature, ne laissez pas l’admin sans authentification et ne placez pas de données personnelles dans metadata.

Prompt de revue

Relis cette implémentation e-commerce.
Vérifie la manipulation de prix, la double réservation de stock, le fulfillment dupliqué par retry Webhook,
l'expédition de commandes non payées, la restitution du stock à l'annulation, les droits admin,
l'exposition des secrets Stripe et les données personnelles dans metadata.
Pour chaque problème, donne fichier, fonction, reproduction et correction.

Testez aussi une carte de test réussie, un échec d’authentification, l’expiration Checkout, le replay Webhook, le double clic sur l’expédition et l’affichage d’un remboursement.

Conclusion

Claude Code peut créer une base e-commerce solide si vous lui donnez les bonnes limites : catalogue, panier, inventaire, commandes, Stripe Checkout, Webhooks, administration, SEO, analytics, retours et annulations. La qualité production dépend surtout de trois points : confirmer par Webhook, recalculer prix et stock côté serveur, et rendre les événements répétés sûrs.

ClaudeCodeLab accompagne les équipes sur les prototypes e-commerce, l’intégration Stripe Checkout, les dashboards internes, le SEO et la formation Claude Code. Avec votre application actuelle, vos règles produits et vos contraintes opérationnelles, le plan devient concret.

Lorsque vous testez cet article, vérifiez questripe listen reçoit les événements, qu’une carte de test génèrecheckout.session.completed, que la commande devient payée même si la page de succès est fermée, qu’un manque de stock bloque Checkout et que les boutons admin ne s’activent que dans les bons statuts.

#Claude Code #e-commerce #Stripe #Next.js #TypeScript
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.