Use Cases (Atualizado: 01/06/2026)

Criar uma loja e-commerce com Claude Code: Next.js, Stripe Checkout e estoque

Guia prático para criar uma loja com Claude Code: produtos, carrinho, estoque, Stripe Checkout, Webhook, admin, SEO e devoluções.

Criar uma loja e-commerce com Claude Code: Next.js, Stripe Checkout e estoque

O que o Claude Code deve assumir em uma loja e-commerce

Uma loja e-commerce não fica pronta apenas com cards de produto e um botão de pagamento. Para vender com segurança, ela precisa conectar lista de produtos, página de detalhe, carrinho, reserva de estoque, criação de pedido, Stripe Checkout, confirmação por Webhook, painel administrativo, SEO, métricas, devoluções e cancelamentos.

O Claude Code funciona melhor quando recebe fronteiras de negócio claras. Em um protótipo pequeno de produtos físicos, Masa começou marcando o pedido como pago quando o cliente chegava à página de sucesso. Isso falhou ao considerar casos reais: o cliente pode fechar o navegador depois do pagamento, a Stripe pode reenviar Webhooks e uma sessão Checkout expirada precisa liberar estoque. A regra correta é: confirmação de pagamento vem do Webhook; preço e estoque são recalculados no servidor.

Os exemplos usam Next.js App Router e TypeScript. O armazenamento é em memória para facilitar testes locais; em produção, troque por PostgreSQL, Prisma ou seu banco de pedidos. Para pagamentos, acompanhe a documentação oficial da Stripe sobre Checkout Sessions API e fulfillment do Checkout. No Next.js, consulte Route Handlers e Metadata API.

Para aprofundar, veja também Stripe Checkout com Claude Code, SEO com Claude Code e dashboards com Claude Code.

Arquitetura antes do código

Separe quatro decisões: criar o pedido, reservar estoque, confirmar pagamento e operar o pedido depois do pagamento. Assim o Claude Code trabalha por partes e a revisão humana encontra riscos mais rápido.

flowchart LR
  A["Lista e detalhe do produto"] --> B["Carrinho"]
  B --> C["API de criação do pedido"]
  C --> D["Reserva de estoque"]
  D --> E["Stripe Checkout Session"]
  E --> F["Webhook"]
  F --> G["Pedido pago e fulfillment"]
  G --> H["Painel admin"]
  H --> I["Envio, devolução, cancelamento"]
  A --> J["SEO e dados estruturados"]
  B --> K["Eventos de analytics"]

Um bom prompt inicial deve incluir as regras operacionais.

Crie uma loja e-commerce pequena com Next.js App Router e TypeScript.
Separe lista de produtos, carrinho, reserva de estoque, criação de Stripe Checkout Session,
confirmação de pedido por Webhook e ações admin de envio/cancelamento.
Não confie em preço nem estoque enviados pelo cliente.
Verifique a assinatura do Webhook da Stripe e trate checkout.session.completed e async_payment_succeeded.
Não marque pedidos como pagos apenas porque success_url foi acessada.

Modelo de produto, estoque e pedido

Este módulo pode ser copiado para um teste local. Ele usa memória, então não é uma solução de produção. A ideia principal é que o servidor controla preço, estoque e status do pedido.

// 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: "Kit presente de hojicha",
    description: "Caixa de presente para primeira compra.",
    priceJPY: 3200,
    stock: 12,
    active: true,
    image: "/images/products/tea.jpg",
  },
  {
    id: "mug-001",
    slug: "ceramic-mug",
    name: "Caneca artesanal de cerâmica",
    description: "Pequena produção. Verifique avarias antes de repor devoluções.",
    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("A quantidade deve estar entre 1 e 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 não 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(`Produto não encontrado: ${line.productId}`);

    const availableStock = stock.get(product.id) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${product.name} não tem estoque 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} não tem estoque 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("Somente pedidos pagos podem ser enviados.");
  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("Somente pedidos pagos ou enviados podem ser marcados 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));
}

Na revisão, peça ao Claude Code para verificar validação de quantidade, falta de estoque, idempotência do fulfillment e restauração de estoque ao cancelar pedidos pendentes.

Lista de produtos e carrinho

O carrinho no cliente pode mostrar subtotal, mas não define o valor final. Envie ao servidor apenas IDs de produto e quantidades.

// 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 ?? "Não foi possível iniciar o Checkout.");
      }

      window.location.href = data.url;
    } catch (error) {
      alert(error instanceof Error ? error.message : "Checkout falhou.");
    } 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">Estoque: {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">
              Adicionar ao carrinho
            </button>
          </article>
        ))}
      </div>

      <aside className="h-fit rounded-lg border p-4">
        <h2 className="text-lg font-semibold">Carrinho</h2>
        {cart.length === 0 ? (
          <p className="mt-3 text-sm text-gray-500">Seu carrinho está vazio.</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 ? "Redirecionando..." : "Continuar para Stripe Checkout"}
        </button>
      </aside>
    </div>
  );
}

API do Stripe Checkout

Crie o pedido antes de redirecionar para a Stripe. Reserve estoque, calcule valores no servidor e coloque o ID interno do pedido emmetadata. Não coloque dados pessoais sensíveis em 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(`Produto não 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 : "Checkout falhou." },
      { status: 400 },
    );
  }
}
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000

O erro mais perigoso é criarunit_amount a partir do subtotal do navegador. O servidor deve recalcular. Também planeje liberar estoque quando uma Checkout Session expirar.

Confirmar pedido por Webhook

A página de sucesso não é confirmação confiável. O cliente pode pagar e não voltar ao site. Use Webhooks como fonte da verdade e torne o handler seguro para 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

Revise assinatura,payment_status, pagamentos assíncronos, restauração de estoque e idempotência.

Admin, devoluções e cancelamentos

O painel admin é parte do fulfillment. Ele deve mostrar pedidos pagos, marcar envio, cancelar pedidos pendentes e diferenciar reembolsos. Em produção, adicione autenticação, papéis e logs.

// 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">Status</th>
              <th className="py-2">Valor</th>
              <th className="py-2">Email</th>
              <th className="py-2">Ações</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>
  );
}

Defina regras de devolução antes da automação. Cancelamento antes do envio pode liberar estoque; devolução após envio pode exigir inspeção antes de revender, vender com desconto ou descartar.

SEO e métricas

Cada página de produto pode gerar tráfego orgânico. Inclua nome, uso, material, envio, devolução, imagem OGP e canonical. Em analytics, use nomes consistentes para visualização, carrinho, início de Checkout e 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: "Produto não encontrado",
      robots: { index: false, follow: false },
    };
  }

  const title = `${product.name} | ClaudeCodeLab Store`;
  const description = `${product.description} Preço: JPY ${product.priceJPY.toLocaleString()}. Confira estoque, envio e devolução 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,
  });
}

Três casos reais

Um lançamento D2C precisa primeiro de reserva de estoque, Checkout, Webhook e administração de envio. Tráfego de redes sociais pode gerar compras simultâneas.

Produto digital não precisa de entrega física, mas precisa liberar acesso. Não mostre o link de download apenas na página de sucesso; libere depois do Webhook.

Pedidos B2B podem precisar de orçamento e aprovação humana. Mantenha Stripe Checkout para cartão e adicione status como orçamento solicitado, fatura enviada e pagamento confirmado.

CasoPrioridadeAtenção
Lançamento D2CEstoque, Checkout, envioCompras simultâneas
Produto digitalAcesso após WebhookNão depender de success_url
B2BAdmin, orçamento, statusPode exigir revisão humana

Falhas comuns

A falha mais comum é marcar pedido como pago ao carregarsuccess_url. O cliente pode pagar e nunca voltar ao site. Webhook deve ser a fonte da verdade.

A segunda é confiar no total do carrinho no navegador. JavaScript do cliente pode ser alterado. Preço, desconto, frete e estoque precisam ser recalculados no servidor.

Estoque também é delicado. Reservar cedo demais bloqueia itens em sessões abandonadas; reservar tarde demais pode vender além do estoque. Use reservas curtas, liberação por expiração e ajuste manual.

Em segurança, evite expor a chave secreta da Stripe, ignorar assinatura do Webhook, publicar admin sem autenticação ou colocar dados pessoais em metadata.

Prompt de revisão

Revise esta implementação e-commerce.
Verifique manipulação de preço, reserva duplicada de estoque, fulfillment duplicado em retry de Webhook,
envio de pedidos não pagos, restauração de estoque no cancelamento, permissões do admin,
exposição de Stripe secret e webhook secret, e dados pessoais em metadata.
Para cada problema, informe arquivo, função, passos de reprodução e correção concreta.

A verificação humana deve cobrir pagamento com cartão de teste, falha de autenticação, expiração do Checkout, replay de Webhook, clique duplo no envio e exibição de reembolso.

Conclusão

Claude Code pode criar uma base e-commerce sólida quando você define bem produtos, carrinho, estoque, pedidos, Stripe Checkout, Webhooks, admin, SEO, analytics, devoluções e cancelamentos. A qualidade de produção depende de confirmar pagamento por Webhook, recalcular preço e estoque no servidor e tornar eventos repetidos seguros.

ClaudeCodeLab ajuda equipes com protótipos de loja, integração Stripe Checkout, painéis administrativos, SEO e treinamentos de Claude Code. Com a aplicação atual, regras de produto e restrições operacionais, o plano de implementação fica concreto.

Ao testar este artigo, confirme questripe listen recebe eventos, que um cartão de teste geracheckout.session.completed, que o pedido fica pago mesmo sem a página de sucesso, que falta de estoque bloqueia Checkout e que botões admin só ficam ativos no status correto.

#Claude Code #e-commerce #Stripe #Next.js #TypeScript
Grátis

PDF grátis: cheatsheet do Claude Code

Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.

Cuidamos dos seus dados e não enviamos spam.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.