Build an E-Commerce Store with Claude Code: Next.js, Stripe Checkout, Inventory
Build a real e-commerce flow with Claude Code: products, cart, stock, Stripe Checkout, webhooks, admin, SEO, analytics, and operations.
What Claude Code Should Own in an E-Commerce Build
An e-commerce store is not finished when it has product cards and a payment button. A useful store connects product listing, product detail, cart, inventory reservation, order creation, Stripe Checkout, webhook-based fulfillment, admin operations, SEO, analytics, returns, and cancellation handling.
Claude Code is most valuable when you ask it to preserve those business boundaries instead of simply generating isolated UI. In one small physical-goods prototype, Masa initially treated the success page as the source of truth for order confirmation. That broke down as soon as we considered webhook retries, customers closing the browser after payment, and inventory that needed to be released when Checkout expired. The better instruction was simple: payment confirmation must come from webhooks, and price and stock must be recalculated on the server.
This guide uses Next.js App Router and TypeScript. The sample store uses an in-memory data module so you can paste and test it locally. Before production, replace it with a real database such as PostgreSQL, but keep the same order lifecycle. For the payment side, check Stripe’s official Checkout Sessions API and Checkout fulfillment guide. For the Next.js side, use the current Route Handlers and Metadata API references.
For deeper related work, pair this article with Stripe Checkout implementation with Claude Code, SEO optimization with Claude Code, and dashboard development with Claude Code.
Architecture Before Code
Separate the flow into four decisions: create an order, reserve stock, confirm payment, and operate the order after payment. That makes the task easier for Claude Code and easier for humans to review.
flowchart LR
A["Product listing and detail"] --> B["Cart"]
B --> C["Order creation API"]
C --> D["Stock reservation"]
D --> E["Stripe Checkout Session"]
E --> F["Webhook"]
F --> G["Paid order and fulfillment queue"]
G --> H["Admin screen"]
H --> I["Shipping, returns, cancellations"]
A --> J["SEO and structured data"]
B --> K["Analytics events"]
A strong first prompt is specific about the business rules:
Build a small e-commerce store with Next.js App Router and TypeScript.
Separate product listing, cart, stock reservation, Stripe Checkout Session creation,
webhook-based order confirmation, and admin shipping/cancellation operations.
Do not trust client-side price or stock values.
Verify Stripe webhook signatures and handle checkout.session.completed plus async_payment_succeeded.
Do not mark orders as paid only because the success URL was visited.
Product, Inventory, and Order Model
This module is intentionally small and copy-pasteable. It works for local testing, but a production deployment must use persistent storage. The important part is the boundary: the server owns price, stock, order status, and fulfillment state.
// 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: "Roasted green tea gift set",
description: "A gift-boxed set for first-time buyers.",
priceJPY: 3200,
stock: 12,
active: true,
image: "/images/products/tea.jpg",
},
{
id: "mug-001",
slug: "ceramic-mug",
name: "Handmade ceramic mug",
description: "Small-batch ceramic mug. Check for damage before restocking returns.",
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("Quantity must be between 1 and 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("Order not found.");
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(`Product not found: ${line.productId}`);
const availableStock = stock.get(product.id) ?? 0;
if (availableStock < line.quantity) {
throw new Error(`${product.name} does not have enough 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} does not have enough 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("Only paid orders can be shipped.");
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("Only paid or shipped orders can be marked as refunded.");
}
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));
}
When Claude Code reviews this module, ask it to focus on quantity validation, stock shortage handling, idempotent fulfillment, and whether cancellations restore only unpaid reserved stock.
Product Listing and Cart
The browser can display a helpful subtotal, but it must not decide the final price. Send only product IDs and quantities to the server.
// 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 ?? "Could not start Checkout.");
}
window.location.href = data.url;
} catch (error) {
alert(error instanceof Error ? error.message : "Checkout failed.");
} 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"
>
Add to cart
</button>
</article>
))}
</div>
<aside className="h-fit rounded-lg border p-4">
<h2 className="text-lg font-semibold">Cart</h2>
{cart.length === 0 ? (
<p className="mt-3 text-sm text-gray-500">Your cart is empty.</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 ? "Redirecting..." : "Continue to Stripe Checkout"}
</button>
</aside>
</div>
);
}
Stripe Checkout API
Create the order before redirecting to Stripe. Reserve stock, create the Checkout Session from server-side product data, and store your internal order ID in metadata. Do not put addresses, card details, or other sensitive personal data in 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(`Product not found: ${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 failed." },
{ status: 400 },
);
}
}
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
The main trap is building unit_amount from a client subtotal. Always recalculate from server-side data. Also plan how to release reserved stock when a Checkout Session expires.
Webhook Fulfillment
The success URL is not fulfillment. Customers can pay and then close the browser before your page loads. Use Stripe webhooks as the source of truth, and make the handler safe to receive the same event more than once.
// 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
Review signature verification, payment_status, delayed payment success and failure, stock restoration, and idempotency. Idempotency means the same operation can run multiple times without corrupting the result; every serious commerce flow needs it.
Admin, Returns, and Cancellations
The admin screen is part of fulfillment, not an optional extra. Operators need to see paid orders, mark them shipped, cancel unpaid orders, and distinguish refunded orders.
// 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">Orders</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">Order ID</th>
<th className="py-2">Status</th>
<th className="py-2">Amount</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"
>
Mark shipped
</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"
>
Cancel
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}
Define return rules before automating them. A pre-shipment cancellation can release inventory. A return after shipment may need inspection before the item can be restocked. If refunds start manually in the Stripe Dashboard, still reflect the result in your order status.
SEO and Analytics
Each product page can earn search traffic. Include product name, use case, material, delivery conditions, return policy, Open Graph image, and canonical URL. For analytics, send product view, add to cart, begin checkout, and purchase with consistent event names.
// 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: "Product not found",
robots: { index: false, follow: false },
};
}
const title = `${product.name} | ClaudeCodeLab Store`;
const description = `${product.description} Price: JPY ${product.priceJPY.toLocaleString()}. Check stock, delivery, and return conditions before purchase.`;
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,
});
}
Three Real Use Cases
A D2C launch needs stock reservation, Checkout, webhook fulfillment, and shipping operations before advanced search. Social traffic can create simultaneous purchases, so server-side stock control matters more than a polished client-side stock badge.
A digital product store does not need shipping, but it still needs fulfillment. Do not reveal a download URL only on the success page. Mark the order paid from the webhook, then grant access.
A B2B ordering portal may keep human approval in the loop. In that case, keep Stripe Checkout for card payments but add statuses such as quote requested, invoice sent, and payment confirmed.
| Use case | First priority | Watch out for |
|---|---|---|
| D2C launch | Stock reservation, Checkout, shipping admin | Simultaneous campaign purchases |
| Digital product | Webhook-based access grant | Do not distribute from success URL only |
| B2B ordering | Admin, quote, status workflow | Human approval may remain necessary |
Failure Cases to Avoid
The most common mistake is marking an order paid when the success URL loads. The customer may pay and never return to the site. Webhooks must be the source of truth.
The second mistake is trusting cart totals from the browser. JavaScript can be changed. Your API must recalculate price, discount, shipping, and stock from server-side data.
Inventory is another trap. If you reserve stock at Checkout start, abandoned sessions can block stock. If you wait until payment succeeds, simultaneous buyers can oversell. Use short reservations, release expired sessions, and give operators a manual adjustment path.
Security mistakes are costly: exposing the Stripe secret key, skipping webhook signature verification, publishing an unauthenticated admin screen, or putting personal data into metadata. Put these boundaries directly in the Claude Code prompt.
Review Prompt for Claude Code
Review this e-commerce implementation.
Focus on price tampering, duplicate stock reservation, duplicate fulfillment on webhook retries,
shipping unpaid orders, inventory restoration on cancellation, admin authorization,
Stripe secret and webhook secret exposure, and personal data in metadata.
For every issue, provide file name, function name, reproduction steps, and a concrete fix.
Human verification should cover successful test-card payment, failed authentication, Checkout expiration, webhook replay, double-clicking the shipping button, and refund status display.
Wrap-Up
Claude Code can build a serious e-commerce foundation when you give it the right boundaries: product listing, cart, inventory, order state, Stripe Checkout, webhooks, admin operations, SEO, analytics, returns, and cancellations. The dividing line for production quality is clear: confirm payment by webhook, recalculate price and stock on the server, and make repeated events safe.
ClaudeCodeLab helps teams prototype e-commerce flows, integrate Stripe Checkout, build admin dashboards, improve SEO, and train developers to use Claude Code safely. Bring the current app, product rules, and operational constraints, and the implementation plan becomes much more concrete.
When you try the code from this article, confirm that stripe listen receives events, a test-card payment sends checkout.session.completed, the order becomes paid even if the success page is closed, stock shortage blocks Checkout Session creation, and admin shipping/cancellation buttons are enabled only for the intended statuses.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.