Crear una tienda e-commerce con Claude Code: Next.js, Stripe Checkout e inventario
Guía práctica para crear una tienda con Claude Code: productos, carrito, inventario, Stripe Checkout, Webhook, admin, SEO y operaciones.
Qué debe asumir Claude Code en una tienda e-commerce
Una tienda e-commerce no está lista solo porque tenga tarjetas de producto y un botón de pago. Para vender de forma fiable necesita listado de productos, detalle, carrito, reserva de inventario, creación de pedidos, Stripe Checkout, confirmación por Webhook, panel de administración, SEO, medición, devoluciones y cancelaciones.
Claude Code aporta más valor cuando le das límites de negocio claros. En un prototipo pequeño de productos físicos, Masa empezó marcando el pedido como pagado cuando el usuario llegaba a la página de éxito. Eso falló al revisar casos reales: el cliente puede cerrar el navegador tras pagar, Stripe puede reenviar el Webhook y un Checkout expirado debe liberar inventario. La instrucción correcta es: la confirmación de pago viene del Webhook, y el precio y el stock se recalculan en el servidor.
Este artículo usa Next.js App Router y TypeScript. El ejemplo guarda datos en memoria para que puedas copiarlo y probarlo en local; en producción debes reemplazarlo por una base de datos persistente. Para pagos, consulta la documentación oficial de Stripe sobre Checkout Sessions API y fulfillment con Checkout. Para Next.js, revisa Route Handlers y Metadata API.
También conviene leer Stripe Checkout con Claude Code, SEO con Claude Code y desarrollo de dashboards con Claude Code.
Arquitectura antes de escribir código
Separa cuatro decisiones: crear el pedido, reservar inventario, confirmar el pago y operar el pedido después del pago. Así Claude Code puede trabajar por piezas y la revisión humana encuentra problemas con más facilidad.
flowchart LR
A["Listado y detalle de producto"] --> B["Carrito"]
B --> C["API de creación de pedido"]
C --> D["Reserva de inventario"]
D --> E["Stripe Checkout Session"]
E --> F["Webhook"]
F --> G["Pedido pagado y cola de fulfillment"]
G --> H["Panel de administración"]
H --> I["Envíos, devoluciones, cancelaciones"]
A --> J["SEO y datos estructurados"]
B --> K["Eventos de analítica"]
Un buen primer prompt incluye las reglas de negocio:
Crea una tienda e-commerce pequeña con Next.js App Router y TypeScript.
Separa listado de productos, carrito, reserva de inventario, creación de Stripe Checkout Session,
confirmación de pedido por Webhook y operaciones de envío/cancelación en el admin.
No confíes en precios ni stock enviados por el cliente.
Verifica la firma del Webhook de Stripe y maneja checkout.session.completed y async_payment_succeeded.
No marques pedidos como pagados solo porque se visitó success_url.
Modelo de producto, inventario y pedido
Este módulo es pequeño y se puede pegar en un proyecto local. Usa memoria, así que no es una solución de producción. Lo importante es el límite: el servidor controla precio, stock, estado del pedido y fulfillment.
// 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: "Set de té hojicha para regalo",
description: "Caja de regalo pensada para una primera compra.",
priceJPY: 3200,
stock: 12,
active: true,
image: "/images/products/tea.jpg",
},
{
id: "mug-001",
slug: "ceramic-mug",
name: "Taza cerámica artesanal",
description: "Taza de producción limitada. Revisa daños antes de reponer devoluciones.",
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 cantidad debe estar entre 1 y 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("Pedido no encontrado.");
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(`Producto no encontrado: ${line.productId}`);
const availableStock = stock.get(product.id) ?? 0;
if (availableStock < line.quantity) {
throw new Error(`${product.name} no tiene stock suficiente.`);
}
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} no tiene stock suficiente.`);
}
}
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("Solo se pueden enviar pedidos pagados.");
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("Solo pedidos pagados o enviados pueden marcarse como reembolsados.");
}
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));
}
Pide a Claude Code que revise validación de cantidades, falta de stock, idempotencia del fulfillment y recuperación de inventario al cancelar pedidos pendientes.
Listado de productos y carrito
El navegador puede mostrar un subtotal, pero no debe decidir el precio final. Envía al servidor solo IDs de producto y cantidades.
// 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 ?? "No se pudo iniciar Checkout.");
}
window.location.href = data.url;
} catch (error) {
alert(error instanceof Error ? error.message : "Falló Checkout.");
} 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">
Añadir al carrito
</button>
</article>
))}
</div>
<aside className="h-fit rounded-lg border p-4">
<h2 className="text-lg font-semibold">Carrito</h2>
{cart.length === 0 ? (
<p className="mt-3 text-sm text-gray-500">Tu carrito está vacío.</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: 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 ? "Redirigiendo..." : "Continuar a Stripe Checkout"}
</button>
</aside>
</div>
);
}
API para Stripe Checkout
Crea el pedido antes de redirigir a Stripe. Reserva inventario, genera los importes desde datos del servidor y guarda tu ID interno de pedido enmetadata. No guardes datos personales sensibles en 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(`Producto no encontrado: ${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 : "Falló Checkout." },
{ status: 400 },
);
}
}
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
El error crítico es construirunit_amount desde el subtotal del navegador. Recalcula siempre en el servidor. Además, si una sesión de Checkout expira, necesitas liberar el inventario reservado.
Confirmación de pedidos por Webhook
La página de éxito no es fulfillment. El cliente puede pagar y cerrar el navegador antes de volver al sitio. Usa Webhooks como fuente de verdad y haz que el handler soporte eventos repetidos.
// 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
Revisa verificación de firma,payment_status, pagos diferidos, restauración de inventario e idempotencia. Idempotencia significa que repetir el mismo evento no rompe el resultado.
Administración, devoluciones y cancelaciones
El panel de administración forma parte del flujo de fulfillment. Debe permitir ver pedidos pagados, marcarlos como enviados, cancelar pedidos pendientes y distinguir reembolsos. Antes de publicarlo, añade autenticación, roles y registro de acciones.
// 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">Pedidos</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">Pedido</th>
<th className="py-2">Estado</th>
<th className="py-2">Importe</th>
<th className="py-2">Email</th>
<th className="py-2">Acciones</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">
Marcar enviado
</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">
Cancelar
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}
Define las reglas de devolución antes de automatizarlas. Una cancelación antes del envío puede liberar inventario; una devolución después del envío puede requerir inspección antes de reponer, vender con descuento o descartar.
SEO y medición
Cada página de producto puede atraer búsquedas. Incluye nombre, uso, material, envío, política de devolución, imagen OGP y canonical. En analítica, usa nombres consistentes para vista de producto, añadir al carrito, comenzar Checkout y compra.
// 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: "Producto no encontrado",
robots: { index: false, follow: false },
};
}
const title = `${product.name} | ClaudeCodeLab Store`;
const description = `${product.description} Precio: JPY ${product.priceJPY.toLocaleString()}. Revisa stock, envío y devolución antes de comprar.`;
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,
});
}
Tres casos de uso reales
Un lanzamiento D2C necesita reserva de inventario, Checkout, Webhook y gestión de envíos antes que una búsqueda sofisticada. El tráfico de redes puede generar compras simultáneas.
Una tienda de productos digitales no necesita envío, pero sí fulfillment. No muestres el enlace de descarga solo en la página de éxito; concede acceso después del Webhook.
Un portal B2B puede necesitar cotizaciones y aprobación humana. Mantén Stripe Checkout para tarjetas, pero añade estados como cotización solicitada, factura enviada y pago confirmado.
| Caso | Prioridad | Riesgo |
|---|---|---|
| Lanzamiento D2C | Inventario, Checkout, envíos | Compras simultáneas |
| Producto digital | Acceso después del Webhook | No depender de success_url |
| Pedido B2B | Admin, cotización, estados | Puede requerir revisión humana |
Fallos que debes evitar
El fallo más común es marcar el pedido como pagado cuando cargasuccess_url. El cliente puede pagar y no volver al sitio. El Webhook debe ser la fuente de verdad.
El segundo fallo es confiar en el total del carrito del navegador. El JavaScript del cliente se puede modificar. Recalcula precio, descuento, envío e inventario en el servidor.
El inventario también es delicado. Si reservas al iniciar Checkout, las sesiones abandonadas bloquean stock. Si esperas hasta el pago, puedes vender de más. Usa reservas cortas, liberación por expiración y ajuste manual en admin.
En seguridad, evita exponer la clave secreta de Stripe, omitir la verificación del Webhook, publicar un admin sin autenticación o guardar datos personales en metadata.
Prompt de revisión para Claude Code
Revisa esta implementación e-commerce.
Enfócate en manipulación de precios, doble reserva de inventario, fulfillment duplicado por reintentos de Webhook,
envío de pedidos no pagados, restauración de inventario al cancelar, permisos del admin,
exposición de Stripe secret y webhook secret, y datos personales en metadata.
Para cada problema, entrega archivo, función, pasos para reproducir y corrección concreta.
La verificación humana debe cubrir pago con tarjeta de prueba, fallo de autenticación, expiración de Checkout, repetición de Webhook, doble clic en el botón de envío y visualización de reembolsos.
Resumen
Claude Code puede construir una base e-commerce seria si defines los límites: productos, carrito, inventario, estado de pedido, Stripe Checkout, Webhooks, administración, SEO, analítica, devoluciones y cancelaciones. La calidad de producción depende de confirmar pagos por Webhook, recalcular precio y stock en el servidor y soportar eventos repetidos.
ClaudeCodeLab ayuda a equipos a prototipar tiendas, integrar Stripe Checkout, crear paneles internos, mejorar SEO y formar desarrolladores en el uso seguro de Claude Code. Con la aplicación actual, reglas de producto y restricciones operativas, el plan de implementación se vuelve mucho más concreto.
Al probar el contenido de este artículo, confirma questripe listen recibe eventos, que una tarjeta de prueba generacheckout.session.completed, que el pedido queda pagado aunque cierres la página de éxito, que la falta de stock bloquea Checkout y que los botones de administración solo se activan para los estados correctos.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.