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.
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é.
| Cas | Priorité | Point d’attention |
|---|---|---|
| Lancement D2C | Stock, Checkout, expédition | Achats simultanés |
| Produit numérique | Accès après Webhook | Ne pas dépendre de success_url |
| B2B | Admin, devis, statuts | Validation 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.
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.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.