All posts
nextjstypescriptecommercepaymentsprismaseries

Building a Next.js Checkout From Scratch — The Store (Part 1 of 3)

April 9, 20269 min read

This is Part 1 of a 3-part series. We build a Next.js ecommerce checkout from scratch — no real payment gateway involved until Part 2.

Series: Part 1 — The Store · Part 2 — MockCard Integration · Part 3 — Webhooks and Edge Cases


Here is the uncomfortable truth about building a payment flow: most developers write the checkout UI, paste in a Stripe integration tutorial, and ship it. The tests mock the gateway. The webhook handler is untested. The edge cases — duplicate deliveries, 3DS abandonment, silent capture failures — are discovered by users, not engineers.

This series builds a checkout from first principles and tests it properly. No gateway account required. By the end of Part 3 you will have a payment flow that handles every failure mode before it happens in production.

Part 1 is the store itself. We build products, a cart, and an order system with the right status lifecycle. Part 2 wires in MockCard. Part 3 handles webhooks and the chaos.


The Data Model

Before a single line of UI, define the order lifecycle. This is the most important decision in the series — get it wrong and the whole thing falls apart.

// prisma/schema.prisma

model Order {
  id              String      @id @default(cuid())
  email           String
  name            String
  items           Json        // [{ productId, name, qty, unitPrice }]
  total           Int         // in paise (smallest unit)
  status          OrderStatus @default(PENDING_PAYMENT)
  paymentIntentId String?     @unique  // stored when payment is submitted
  createdAt       DateTime    @default(now())
  updatedAt       DateTime    @updatedAt
}

model ProcessedEvent {
  id          String   @id   // webhook event.id — used for idempotency in Part 3
  processedAt DateTime @default(now())
}

enum OrderStatus {
  PENDING_PAYMENT   // order created, nothing charged yet
  PENDING_WEBHOOK   // payment submitted, waiting for gateway confirmation
  FULFILLED         // webhook received, order shipped / access granted
  FAILED            // payment declined
  CANCELLED         // 3DS abandoned or explicit cancellation
}

Two things worth noting:

PENDING_WEBHOOK is not the same as PENDING_PAYMENT. When the user submits payment, the charge is initiated but not confirmed. The confirmation arrives via webhook. Orders that never leave PENDING_WEBHOOK are captured-but-unconfirmed — this is exactly the limbo state we test in Part 3.

ProcessedEvent is not optional. Real gateways send the same webhook more than once. Without a record of which events you have already processed, a retry becomes a second fulfilment. We build this from day one, not as an afterthought.

npx prisma migrate dev --name init

Products

For this series we use static products. In production these come from a database or CMS — the checkout logic is identical.

// lib/products.ts

export interface Product {
  id:    string;
  name:  string;
  price: number; // paise
  image: string;
  sku:   string;
}

export const PRODUCTS: Product[] = [
  { id: "prod_1", name: "MockCard T-Shirt",   price: 79900, image: "/products/tshirt.jpg",  sku: "MC-TS-001" },
  { id: "prod_2", name: "Developer Mug",      price: 49900, image: "/products/mug.jpg",     sku: "MC-MUG-01" },
  { id: "prod_3", name: "Testing Handbook",   price: 99900, image: "/products/book.jpg",    sku: "MC-BK-001" },
];

export function getProduct(id: string): Product | undefined {
  return PRODUCTS.find((p) => p.id === id);
}

Cart State

A React context that survives navigation. Nothing fancy — just what the checkout page needs to read.

// lib/cart-context.tsx
"use client";

import { createContext, useContext, useState, useCallback } from "react";
import type { Product } from "./products";

interface CartItem extends Product { qty: number }
interface CartContextValue {
  items:     CartItem[];
  add:       (product: Product) => void;
  remove:    (id: string) => void;
  clear:     () => void;
  total:     number;
  itemCount: number;
}

const CartContext = createContext<CartContextValue | null>(null);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);

  const add = useCallback((product: Product) => {
    setItems((prev) => {
      const existing = prev.find((i) => i.id === product.id);
      if (existing) return prev.map((i) => i.id === product.id ? { ...i, qty: i.qty + 1 } : i);
      return [...prev, { ...product, qty: 1 }];
    });
  }, []);

  const remove = useCallback((id: string) => {
    setItems((prev) => prev.filter((i) => i.id !== id));
  }, []);

  const clear = useCallback(() => setItems([]), []);

  const total     = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  const itemCount = items.reduce((sum, i) => sum + i.qty, 0);

  return (
    <CartContext.Provider value={{ items, add, remove, clear, total, itemCount }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error("useCart must be used inside CartProvider");
  return ctx;
}

The Checkout Page

Two sections: order summary on the left, customer + card details on the right. The card fields collect data but in Part 2 these are sent to MockCard, not a real gateway — so no PCI scope issues during development.

// app/checkout/page.tsx
"use client";

import { useState } from "react";
import { useCart } from "@/lib/cart-context";
import { useRouter } from "next/navigation";

export default function CheckoutPage() {
  const { items, total, clear } = useCart();
  const router = useRouter();

  const [form, setForm] = useState({
    name: "", email: "", address: "",
    // These are sent to MockCard in Part 2 — not a real gateway
    cardNumber: "", expiry: "", cvv: "",
  });
  const [loading, setLoading] = useState(false);
  const [error, setError]     = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ form, items, total }),
    });

    const data = await res.json();
    setLoading(false);

    if (data.success) {
      clear();
      router.push(`/orders/${data.orderId}?status=pending`);
    } else if (data.requiresAction) {
      // 3DS redirect — handled in Part 2
      window.location.href = data.redirectUrl;
    } else {
      setError(data.error?.message ?? "Payment failed. Please try again.");
    }
  };

  if (items.length === 0) {
    return <div className="p-8 text-center">Your cart is empty.</div>;
  }

  return (
    <main className="max-w-4xl mx-auto p-6 grid grid-cols-1 md:grid-cols-2 gap-8">
      {/* Order summary */}
      <div className="space-y-4">
        <h2 className="font-semibold text-lg">Order Summary</h2>
        {items.map((item) => (
          <div key={item.id} className="flex justify-between text-sm">
            <span>{item.name} × {item.qty}</span>
            <span>₹{((item.price * item.qty) / 100).toFixed(2)}</span>
          </div>
        ))}
        <div className="border-t pt-3 font-semibold flex justify-between">
          <span>Total</span>
          <span>₹{(total / 100).toFixed(2)}</span>
        </div>
      </div>

      {/* Payment form */}
      <form onSubmit={handleSubmit} className="space-y-4">
        <h2 className="font-semibold text-lg">Payment Details</h2>
        <input required placeholder="Full name"     value={form.name}       onChange={(e) => setForm({ ...form, name: e.target.value })}       className="w-full border rounded p-2 text-sm" />
        <input required placeholder="Email"         value={form.email}      onChange={(e) => setForm({ ...form, email: e.target.value })}      className="w-full border rounded p-2 text-sm" type="email" />
        <input required placeholder="Address"       value={form.address}    onChange={(e) => setForm({ ...form, address: e.target.value })}    className="w-full border rounded p-2 text-sm" />
        <input required placeholder="Card number"   value={form.cardNumber} onChange={(e) => setForm({ ...form, cardNumber: e.target.value })} className="w-full border rounded p-2 text-sm font-mono" maxLength={16} />
        <div className="grid grid-cols-2 gap-3">
          <input required placeholder="MM/YY" value={form.expiry} onChange={(e) => setForm({ ...form, expiry: e.target.value })} className="border rounded p-2 text-sm" />
          <input required placeholder="CVV"   value={form.cvv}    onChange={(e) => setForm({ ...form, cvv: e.target.value })}    className="border rounded p-2 text-sm" maxLength={4} />
        </div>

        {error && (
          <div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
            {error}
          </div>
        )}

        <button
          type="submit"
          disabled={loading}
          className="w-full bg-black text-white py-3 rounded font-medium hover:bg-gray-800 disabled:opacity-50"
        >
          {loading ? "Processing…" : `Pay ₹${(total / 100).toFixed(2)}`}
        </button>
      </form>
    </main>
  );
}

The Checkout API Route

This route creates the order record in PENDING_PAYMENT status. In Part 2 it calls MockCard. Right now it just validates the input and persists the order — the payment logic comes next.

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getProduct } from "@/lib/products";

export async function POST(req: NextRequest) {
  const { form, items, total } = await req.json();

  // Validate every item exists and total is correct server-side
  let serverTotal = 0;
  const orderItems = [];

  for (const item of items) {
    const product = getProduct(item.id);
    if (!product) {
      return NextResponse.json({ error: { message: "Invalid product" } }, { status: 400 });
    }
    serverTotal += product.price * item.qty;
    orderItems.push({ productId: product.id, name: product.name, qty: item.qty, unitPrice: product.price });
  }

  // Never trust the client total
  if (serverTotal !== total) {
    return NextResponse.json({ error: { message: "Price mismatch" } }, { status: 400 });
  }

  // Create order in PENDING_PAYMENT — nothing is charged yet
  const order = await prisma.order.create({
    data: {
      email:  form.email,
      name:   form.name,
      items:  orderItems,
      total:  serverTotal,
      status: "PENDING_PAYMENT",
    },
  });

  // Part 2: call MockCard here and update order to PENDING_WEBHOOK on success
  // For now, return the order ID so we can test the UI flow

  return NextResponse.json({ success: true, orderId: order.id });
}

Order Status Page

// app/orders/[id]/page.tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";

const STATUS_LABELS = {
  PENDING_PAYMENT: { label: "Awaiting payment",    color: "text-amber-600" },
  PENDING_WEBHOOK: { label: "Payment processing",  color: "text-blue-600"  },
  FULFILLED:       { label: "Order confirmed",      color: "text-green-600" },
  FAILED:          { label: "Payment failed",       color: "text-red-600"   },
  CANCELLED:       { label: "Order cancelled",      color: "text-zinc-500"  },
};

export default async function OrderPage({ params }: { params: { id: string } }) {
  const order = await prisma.order.findUnique({ where: { id: params.id } });
  if (!order) notFound();

  const { label, color } = STATUS_LABELS[order.status];
  const items = order.items as { name: string; qty: number; unitPrice: number }[];

  return (
    <main className="max-w-xl mx-auto p-8 space-y-6">
      <div>
        <p className="text-sm text-gray-500">Order #{order.id.slice(0, 8)}</p>
        <p className={`text-xl font-semibold mt-1 ${color}`}>{label}</p>
      </div>
      <div className="border rounded p-4 space-y-2">
        {items.map((item, i) => (
          <div key={i} className="flex justify-between text-sm">
            <span>{item.name} × {item.qty}</span>
            <span>₹{((item.unitPrice * item.qty) / 100).toFixed(2)}</span>
          </div>
        ))}
        <div className="border-t pt-2 font-semibold flex justify-between">
          <span>Total</span>
          <span>₹{(order.total / 100).toFixed(2)}</span>
        </div>
      </div>
      {order.status === "PENDING_WEBHOOK" && (
        <p className="text-sm text-gray-500">
          Your payment was received. We are confirming with the payment processor.
          This usually takes a few seconds.
        </p>
      )}
    </main>
  );
}

Where We Are

You now have:

  • A Prisma schema with the right order lifecycle — PENDING_PAYMENT → PENDING_WEBHOOK → FULFILLED
  • A cart with client state that survives navigation
  • A checkout form that collects card details
  • A server-side route that validates order totals and creates the order record
  • An order status page that renders every state clearly

Nothing is charged yet. The checkout form collects card details but nothing calls a gateway. This is intentional — in Part 2 we wire in MockCard, which means you can test every payment outcome (approvals, all five decline types, 3DS challenge) without a real gateway account or a real card.

The separation matters: the store logic is complete and testable on its own before any payment code exists.

Continue to Part 2 →