Membangun toko e-commerce dengan Claude Code: Next.js, Stripe Checkout, dan stok
Panduan membuat toko dengan Claude Code: produk, keranjang, stok, Stripe Checkout, Webhook, admin, SEO, analytics, retur, dan pembatalan.
Apa yang sebaiknya dikerjakan Claude Code
Toko e-commerce tidak selesai hanya dengan kartu produk dan tombol bayar. Toko yang siap dipakai harus menghubungkan daftar produk, detail produk, keranjang, reservasi stok, pembuatan pesanan, Stripe Checkout, konfirmasi lewat Webhook, halaman admin, SEO, analytics, retur, dan pembatalan.
Claude Code paling berguna ketika diberi batas bisnis yang jelas. Dalam prototipe kecil untuk produk fisik, Masa awalnya menganggap halaman sukses sebagai tanda pesanan sudah dibayar. Itu rapuh: pelanggan bisa menutup browser setelah membayar, Stripe bisa mengirim ulang Webhook, dan Checkout Session yang kedaluwarsa harus mengembalikan stok. Aturan yang lebih aman adalah: pembayaran dikonfirmasi oleh Webhook, sedangkan harga dan stok dihitung ulang di server.
Contoh di artikel ini memakai Next.js App Router dan TypeScript. Penyimpanan memakai memori supaya mudah dicoba lokal; untuk produksi, ganti dengan PostgreSQL, Prisma, atau database pesanan yang sudah ada. Untuk pembayaran, cek dokumentasi resmi Stripe tentang Checkout Sessions API dan Checkout fulfillment. Untuk Next.js, lihat Route Handlers dan Metadata API.
Artikel terkait yang membantu: Stripe Checkout dengan Claude Code, SEO dengan Claude Code, dan dashboard admin dengan Claude Code.
Arsitektur sebelum kode
Pisahkan empat keputusan: membuat pesanan, mereservasi stok, mengonfirmasi pembayaran, dan mengoperasikan pesanan setelah pembayaran. Dengan begitu tugas Claude Code lebih jelas dan review manusia lebih terarah.
flowchart LR
A["Daftar dan detail produk"] --> B["Keranjang"]
B --> C["API pembuatan pesanan"]
C --> D["Reservasi stok"]
D --> E["Stripe Checkout Session"]
E --> F["Webhook"]
F --> G["Pesanan paid dan fulfillment"]
G --> H["Halaman admin"]
H --> I["Pengiriman, retur, pembatalan"]
A --> J["SEO dan structured data"]
B --> K["Analytics events"]
Prompt awal yang baik harus memuat aturan operasional.
Buat toko e-commerce kecil dengan Next.js App Router dan TypeScript.
Pisahkan daftar produk, keranjang, reservasi stok, pembuatan Stripe Checkout Session,
konfirmasi pesanan lewat Webhook, dan aksi admin untuk pengiriman/pembatalan.
Jangan percaya harga atau stok dari client.
Verifikasi signature Webhook Stripe dan tangani checkout.session.completed serta async_payment_succeeded.
Jangan tandai pesanan paid hanya karena success_url dikunjungi.
Model produk, stok, dan pesanan
Modul berikut bisa ditempel untuk uji lokal. Karena memakai memori, ini bukan implementasi produksi. Intinya: server adalah sumber kebenaran untuk harga, stok, dan status pesanan.
// 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: "Paket hadiah hojicha",
description: "Kotak hadiah untuk pembelian pertama.",
priceJPY: 3200,
stock: 12,
active: true,
image: "/images/products/tea.jpg",
},
{
id: "mug-001",
slug: "ceramic-mug",
name: "Mug keramik handmade",
description: "Produksi kecil. Cek kerusakan sebelum retur masuk stok lagi.",
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("Jumlah harus antara 1 dan 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("Pesanan tidak ditemukan.");
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(`Produk tidak ditemukan: ${line.productId}`);
const availableStock = stock.get(product.id) ?? 0;
if (availableStock < line.quantity) {
throw new Error(`${product.name} tidak memiliki stok cukup.`);
}
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} tidak memiliki stok cukup.`);
}
}
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("Hanya pesanan paid yang bisa dikirim.");
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("Hanya pesanan paid atau shipped yang bisa ditandai 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));
}
Minta Claude Code mereview validasi jumlah, stok tidak cukup, fulfillment yang idempotent, dan pengembalian stok saat pesanan pending dibatalkan.
Daftar produk dan keranjang
Keranjang di client boleh menampilkan subtotal, tetapi tidak boleh menentukan harga final. Kirim hanya ID produk dan jumlah ke 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 ?? "Checkout tidak bisa dimulai.");
}
window.location.href = data.url;
} catch (error) {
alert(error instanceof Error ? error.message : "Checkout gagal.");
} 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">Stok: {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">
Tambah ke keranjang
</button>
</article>
))}
</div>
<aside className="h-fit rounded-lg border p-4">
<h2 className="text-lg font-semibold">Keranjang</h2>
{cart.length === 0 ? (
<p className="mt-3 text-sm text-gray-500">Keranjang kosong.</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 ? "Mengalihkan..." : "Lanjut ke Stripe Checkout"}
</button>
</aside>
</div>
);
}
API Stripe Checkout
Buat pesanan sebelum redirect ke Stripe. Reserve stok, hitung nominal dari data server, dan simpan ID pesanan internal dimetadata. Jangan taruh data pribadi sensitif di 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(`Produk tidak ditemukan: ${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 gagal." },
{ status: 400 },
);
}
}
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
Kesalahan besar adalah membuatunit_amountdari subtotal browser. Nominal harus dihitung ulang di server. Selain itu, siapkan cara melepas stok jika Checkout Session kedaluwarsa.
Konfirmasi pesanan lewat Webhook
Halaman sukses bukan bukti fulfillment. Pelanggan bisa membayar lalu tidak kembali ke situs. Webhook harus menjadi sumber kebenaran, dan handler harus aman ketika event dikirim berulang.
// 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, pembayaran async, pengembalian stok, dan idempotency. Idempotency berarti event yang sama tidak membuat hasil rusak jika diproses dua kali.
Admin, retur, dan pembatalan
Halaman admin adalah bagian dari operasi, bukan tambahan kosmetik. Operator perlu melihat pesanan paid, menandai pengiriman, membatalkan pesanan pending, dan membedakan pesanan refunded. Untuk produksi, tambahkan autentikasi, role, dan audit log.
// 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">Pesanan</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">Pesanan</th>
<th className="py-2">Status</th>
<th className="py-2">Jumlah</th>
<th className="py-2">Email</th>
<th className="py-2">Aksi</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">
Tandai dikirim
</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">
Batalkan
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}
Aturan retur harus jelas sebelum otomatisasi. Pembatalan sebelum pengiriman bisa mengembalikan stok; retur setelah pengiriman butuh pemeriksaan kondisi barang sebelum restock, diskon, atau dibuang.
SEO dan analytics
Setiap halaman produk bisa mendatangkan traffic pencarian. Masukkan nama produk, kegunaan, material, pengiriman, kebijakan retur, OGP image, dan canonical. Untuk analytics, gunakan nama event konsisten: view_item, add_to_cart, begin_checkout, dan purchase.
// 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: "Produk tidak ditemukan",
robots: { index: false, follow: false },
};
}
const title = `${product.name} | ClaudeCodeLab Store`;
const description = `${product.description} Harga: JPY ${product.priceJPY.toLocaleString()}. Cek stok, pengiriman, dan retur sebelum membeli.`;
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,
});
}
Tiga use case nyata
Peluncuran D2C perlu memprioritaskan reservasi stok, Checkout, Webhook, dan admin pengiriman sebelum fitur pencarian yang kompleks. Traffic dari media sosial bisa memicu pembelian bersamaan.
Produk digital tidak perlu pengiriman fisik, tetapi tetap butuh fulfillment. Jangan menampilkan link download hanya di halaman sukses; beri akses setelah Webhook mengonfirmasi pembayaran.
Portal B2B sering membutuhkan quotation dan approval. Stripe Checkout bisa tetap dipakai untuk kartu, sementara admin menambahkan status seperti quote requested, invoice sent, dan payment confirmed.
| Use case | Prioritas | Risiko |
|---|---|---|
| Launch D2C | Stok, Checkout, pengiriman | Pembelian bersamaan |
| Produk digital | Akses setelah Webhook | Jangan bergantung pada success_url |
| B2B | Admin, quote, status | Approval manual bisa tetap ada |
Kesalahan yang harus dihindari
Kesalahan terbesar adalah menandai pesanan paid ketikasuccess_urldibuka. Pelanggan bisa membayar dan tidak kembali ke situs. Webhook harus menjadi sumber kebenaran.
Kesalahan kedua adalah percaya pada total keranjang dari browser. JavaScript client bisa diubah. Server harus menghitung ulang harga, diskon, ongkir, dan stok.
Stok juga rawan. Jika reserve dilakukan saat Checkout mulai, sesi yang ditinggalkan bisa mengunci stok. Jika reserve menunggu pembayaran sukses, oversell bisa terjadi. Gunakan reservasi pendek, release saat expired, dan penyesuaian manual di admin.
Dari sisi keamanan, jangan expose Stripe secret, jangan lewati verifikasi signature Webhook, jangan buka admin tanpa autentikasi, dan jangan simpan data pribadi di metadata.
Prompt review untuk Claude Code
Review implementasi e-commerce ini.
Fokus pada manipulasi harga, reservasi stok ganda, fulfillment ganda karena retry Webhook,
pengiriman pesanan yang belum dibayar, pengembalian stok saat pembatalan, permission admin,
exposure Stripe secret dan webhook secret, serta data pribadi di metadata.
Untuk setiap masalah, berikan file, function, langkah reproduksi, dan perbaikan konkret.
Verifikasi manual harus mencakup pembayaran kartu test sukses, authentication failure, Checkout expiration, Webhook replay, double click tombol pengiriman, dan tampilan status refund.
Ringkasan
Claude Code bisa membangun fondasi e-commerce yang kuat jika batasnya jelas: produk, keranjang, stok, order state, Stripe Checkout, Webhook, admin, SEO, analytics, retur, dan pembatalan. Kualitas produksi ditentukan oleh tiga hal: konfirmasi pembayaran lewat Webhook, harga dan stok dihitung ulang di server, serta event berulang aman diproses.
ClaudeCodeLab membantu tim membuat prototipe e-commerce, integrasi Stripe Checkout, dashboard admin, perbaikan SEO, dan training Claude Code. Jika aplikasi saat ini, aturan produk, dan batas operasional sudah jelas, rencana implementasi bisa dibuat lebih konkret.
Saat mencoba kode di artikel ini, pastikanstripe listenmenerima event, kartu test menghasilkancheckout.session.completed, pesanan tetap menjadi paid walau halaman sukses ditutup, stok kurang memblokir Checkout Session, dan tombol admin hanya aktif pada status yang benar.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.