Skip to content
Build an E-Commerce Store with Next.js (Step by Step)

Build an E-Commerce Store with Next.js (Step by Step)

DodaTech Updated Jun 20, 2026 12 min read

Build a full e-commerce store with Next.js featuring product listings, shopping cart management, Stripe payment integration, order confirmation, and an admin dashboard for managing products and orders.

What You’ll Build

You’ll build a production-ready e-commerce store where customers browse products, add items to a cart, complete purchases through Stripe Checkout, and receive order confirmations. On the admin side, you can add/edit products, view orders, and manage inventory. This same e-commerce architecture powers the merchandise store for Doda Browser and Durga Antivirus Pro.

Why Build an E-Commerce Store?

E-commerce is the backbone of modern digital business. Building one teaches you product data modeling, cart state management, payment gateway integration, order lifecycle handling, and admin dashboard patterns — all skills that transfer directly to SaaS applications, subscription services, and digital marketplaces.

Prerequisites

Step 1: Project Setup

npx create-next-app@latest ecommerce-store --typescript --tailwind --eslint
cd ecommerce-store
npm install stripe @stripe/stripe-js @prisma/client prisma
npm install lucide-react clsx tailwind-merge
npx prisma init

Create a .env.local file:

STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
DATABASE_URL="file:./dev.db"

Step 2: Database Schema (Prisma + SQLite)

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Product {
  id          String   @id @default(cuid())
  name        String
  description String
  price       Int      // stored in cents
  image       String
  category    String
  inventory   Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  orders      OrderItem[]
}

model Order {
  id            String      @id @default(cuid())
  stripeId      String?
  email         String
  total         Int
  status        String      @default("pending")
  items         OrderItem[]
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

model OrderItem {
  id        String  @id @default(cuid())
  orderId   String
  order     Order   @relation(fields: [orderId], references: [id])
  productId String
  product   Product @relation(fields: [productId], references: [id])
  quantity  Int
  price     Int
}
npx prisma db push
npx prisma generate

Expected output:

Your database is now in sync with your Prisma schema. ✔
✔ Generated Prisma Client to ./node_modules/@prisma/client

Step 3: Database Seed Script

// prisma/seed.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  const products = [
    { name: "Wireless Headphones", description: "Premium noise-cancelling headphones with 30-hour battery life.", price: 29999, image: "/images/headphones.jpg", category: "Electronics", inventory: 25 },
    { name: "Mechanical Keyboard", description: "RGB mechanical keyboard with cherry MX switches.", price: 14999, image: "/images/keyboard.jpg", category: "Electronics", inventory: 15 },
    { name: "Canvas Backpack", description: "Durable canvas backpack with laptop compartment.", price: 5999, image: "/images/backpack.jpg", category: "Accessories", inventory: 40 },
    { name: "Wireless Mouse", description: "Ergonomic wireless mouse with 6 programmable buttons.", price: 4999, image: "/images/mouse.jpg", category: "Electronics", inventory: 30 },
    { name: "Desk Lamp", description: "LED desk lamp with adjustable brightness and color temperature.", price: 3999, image: "/images/lamp.jpg", category: "Home", inventory: 20 },
    { name: "Coffee Mug", description: "Ceramic mug with heat-changing color design.", price: 1499, image: "/images/mug.jpg", category: "Accessories", inventory: 100 },
  ];

  for (const product of products) {
    await prisma.product.create({ data: product });
  }
  console.log("Seeded", products.length, "products");
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
# Add to package.json:
# "prisma": { "seed": "tsx prisma/seed.ts" }
npm install tsx --save-dev
npx prisma db seed

Expected output:

Seeded 6 products

Step 4: Product Listing Page

// app/page.tsx
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { AddToCartButton } from "@/components/AddToCartButton";

export const dynamic = "force-dynamic";

export default async function Home() {
  const products = await prisma.product.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Shop</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
        {products.map((product) => (
          <div key={product.id} className="border rounded-lg p-4 hover:shadow-lg transition">
            <div className="bg-gray-100 h-48 rounded-md mb-4 flex items-center justify-center text-gray-400">
              {product.name.charAt(0)}
            </div>
            <h2 className="text-lg font-semibold">{product.name}</h2>
            <p className="text-gray-600 text-sm mt-1 line-clamp-2">{product.description}</p>
            <p className="text-xl font-bold mt-2">${(product.price / 100).toFixed(2)}</p>
            <p className="text-sm text-gray-500 mt-1">{product.category}</p>
            <div className="mt-4 flex gap-2">
              <Link
                href={`/products/${product.id}`}
                className="text-blue-600 hover:underline text-sm"
              >
                View Details
              </Link>
              <AddToCartButton product={product} />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Step 5: Cart Component

// lib/cart.ts
"use server";
import { cookies } from "next/headers";
import { prisma } from "./prisma";

interface CartItem {
  productId: string;
  quantity: number;
}

function getCart(): CartItem[] {
  const cookieStore = cookies();
  const cart = cookieStore.get("cart")?.value;
  if (!cart) return [];
  try {
    return JSON.parse(cart);
  } catch {
    return [];
  }
}

function setCart(items: CartItem[]) {
  const cookieStore = cookies();
  cookieStore.set("cart", JSON.stringify(items), {
    path: "/",
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 7,
  });
}

export async function addToCart(productId: string, quantity: number = 1) {
  const cart = getCart();
  const existing = cart.find((item) => item.productId === productId);
  if (existing) {
    existing.quantity += quantity;
  } else {
    cart.push({ productId, quantity });
  }
  setCart(cart);
  return cart;
}

export async function getCartWithDetails() {
  const cart = getCart();
  if (cart.length === 0) return [];
  const productIds = cart.map((item) => item.productId);
  const products = await prisma.product.findMany({
    where: { id: { in: productIds } },
  });
  return cart.map((item) => {
    const product = products.find((p) => p.id === item.productId);
    return {
      ...item,
      product,
      subtotal: product ? product.price * item.quantity : 0,
    };
  });
}

export async function updateQuantity(productId: string, quantity: number) {
  const cart = getCart();
  if (quantity <= 0) {
    return removeFromCart(productId);
  }
  const item = cart.find((i) => i.productId === productId);
  if (item) item.quantity = quantity;
  setCart(cart);
  return cart;
}

export async function removeFromCart(productId: string) {
  const cart = getCart().filter((item) => item.productId !== productId);
  setCart(cart);
  return cart;
}

export async function getCartCount() {
  return getCart().reduce((sum, item) => sum + item.quantity, 0);
}
// components/AddToCartButton.tsx
"use client";
import { useTransition } from "react";
import { addToCart } from "@/lib/cart";

interface Product {
  id: string;
  name: string;
  price: number;
}

export function AddToCartButton({ product }: { product: Product }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(() => addToCart(product.id))}
      disabled={isPending}
      className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700 disabled:opacity-50"
    >
      {isPending ? "Adding..." : "Add to Cart"}
    </button>
  );
}

Step 6: Checkout with Stripe

// app/api/create-checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const { items, email } = await req.json();
  const cartItems = items as { productId: string; quantity: number }[];

  const productIds = cartItems.map((i) => i.productId);
  const products = await prisma.product.findMany({
    where: { id: { in: productIds } },
  });

  const lineItems = cartItems.map((item) => {
    const product = products.find((p) => p.id === item.productId)!;
    return {
      price_data: {
        currency: "usd",
        product_data: { name: product.name },
        unit_amount: product.price,
      },
      quantity: item.quantity,
    };
  });

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    customer_email: email,
    line_items: lineItems,
    success_url: `${req.headers.get("origin")}/order/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.headers.get("origin")}/cart`,
    metadata: {
      items: JSON.stringify(cartItems),
    },
  });

  return NextResponse.json({ url: session.url });
}
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    const items = JSON.parse(session.metadata!.items);
    const total = session.amount_total!;

    const order = await prisma.order.create({
      data: {
        stripeId: session.id,
        email: session.customer_email || "unknown",
        total,
        status: "paid",
        items: {
          create: items.map((item: { productId: string; quantity: number }) => ({
            productId: item.productId,
            quantity: item.quantity,
            price: 0,
          })),
        },
      },
    });

    // Decrement inventory
    for (const item of items) {
      await prisma.product.update({
        where: { id: item.productId },
        data: { inventory: { decrement: item.quantity } },
      });
    }
  }

  return NextResponse.json({ received: true });
}

Step 7: Order Confirmation Page

// app/order/success/page.tsx
import { prisma } from "@/lib/prisma";

export default async function OrderSuccessPage({
  searchParams,
}: {
  searchParams: { session_id: string };
}) {
  const order = await prisma.order.findFirst({
    where: { stripeId: searchParams.session_id },
    include: { items: { include: { product: true } } },
  });

  if (!order) {
    return (
      <div className="max-w-2xl mx-auto px-4 py-16 text-center">
        <h1 className="text-3xl font-bold mb-4">Order Confirmed!</h1>
        <p className="text-gray-600">Your order has been placed. You'll receive a confirmation email shortly.</p>
      </div>
    );
  }

  return (
    <div className="max-w-2xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-2">Thank You for Your Order!</h1>
      <p className="text-gray-600 mb-8">Order #{order.id.slice(0, 8)}</p>

      <div className="border rounded-lg divide-y">
        {order.items.map((item) => (
          <div key={item.id} className="flex justify-between p-4">
            <div>
              <p className="font-medium">{item.product.name}</p>
              <p className="text-sm text-gray-500">Qty: {item.quantity}</p>
            </div>
            <p className="font-medium">${(item.product.price * item.quantity / 100).toFixed(2)}</p>
          </div>
        ))}
        <div className="flex justify-between p-4 font-bold text-lg">
          <span>Total</span>
          <span>${(order.total / 100).toFixed(2)}</span>
        </div>
      </div>

      <p className="text-sm text-gray-500 mt-8">
        A confirmation email has been sent to {order.email}.
      </p>
    </div>
  );
}

Step 8: Admin Dashboard

// app/admin/page.tsx
import { prisma } from "@/lib/prisma";

export const dynamic = "force-dynamic";

export default async function AdminPage() {
  const products = await prisma.product.findMany({ orderBy: { createdAt: "desc" } });
  const orders = await prisma.order.findMany({
    include: { items: { include: { product: true } } },
    orderBy: { createdAt: "desc" },
    take: 20,
  });

  const totalRevenue = orders.reduce((sum, o) => sum + o.total, 0);
  const totalOrders = await prisma.order.count();
  const lowStock = products.filter((p) => p.inventory < 10).length;

  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Admin Dashboard</h1>

      <div className="grid grid-cols-3 gap-4 mb-8">
        <div className="bg-white border rounded-lg p-6">
          <p className="text-sm text-gray-500">Total Revenue</p>
          <p className="text-2xl font-bold">${(totalRevenue / 100).toFixed(2)}</p>
        </div>
        <div className="bg-white border rounded-lg p-6">
          <p className="text-sm text-gray-500">Orders</p>
          <p className="text-2xl font-bold">{totalOrders}</p>
        </div>
        <div className="bg-white border rounded-lg p-6">
          <p className="text-sm text-gray-500">Low Stock Items</p>
          <p className="text-2xl font-bold text-red-600">{lowStock}</p>
        </div>
      </div>

      <h2 className="text-xl font-semibold mb-4">Recent Orders</h2>
      <div className="border rounded-lg overflow-hidden mb-8">
        <table className="w-full text-sm">
          <thead className="bg-gray-50">
            <tr>
              <th className="text-left p-3">Order</th>
              <th className="text-left p-3">Customer</th>
              <th className="text-left p-3">Items</th>
              <th className="text-left p-3">Total</th>
              <th className="text-left p-3">Status</th>
            </tr>
          </thead>
          <tbody className="divide-y">
            {orders.map((order) => (
              <tr key={order.id}>
                <td className="p-3 font-mono text-xs">#{order.id.slice(0, 8)}</td>
                <td className="p-3">{order.email}</td>
                <td className="p-3">{order.items.length}</td>
                <td className="p-3">${(order.total / 100).toFixed(2)}</td>
                <td className="p-3">
                  <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs">
                    {order.status}
                  </span>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <h2 className="text-xl font-semibold mb-4">Products ({products.length})</h2>
      <div className="border rounded-lg overflow-hidden">
        <table className="w-full text-sm">
          <thead className="bg-gray-50">
            <tr>
              <th className="text-left p-3">Name</th>
              <th className="text-left p-3">Price</th>
              <th className="text-left p-3">Inventory</th>
              <th className="text-left p-3">Category</th>
            </tr>
          </thead>
          <tbody className="divide-y">
            {products.map((product) => (
              <tr key={product.id} className={product.inventory < 10 ? "bg-red-50" : ""}>
                <td className="p-3">{product.name}</td>
                <td className="p-3">${(product.price / 100).toFixed(2)}</td>
                <td className="p-3">{product.inventory}</td>
                <td className="p-3">{product.category}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Architecture


sequenceDiagram
    participant User as Customer (Browser)
    participant Next as Next.js App
    participant DB as SQLite (Prisma)
    participant Stripe as Stripe API

    User->>Next: Browse products
    Next->>DB: SELECT products
    DB-->>Next: Product list
    Next-->>User: Render product grid

    User->>Next: Click "Add to Cart"
    Next->>Next: Set cart cookie

    User->>Next: Proceed to checkout
    Next->>Stripe: Create Checkout Session
    Stripe-->>Next: Session URL
    Next-->>User: Redirect to Stripe

    User->>Stripe: Enter payment details
    Stripe->>Next: Webhook: checkout.session.completed
    Next->>DB: Create order, decrement inventory
    Stripe-->>User: Redirect to success page
    Next-->>User: Show order confirmation

Common Errors

1. Stripe “Invalid API Key” error Your STRIPE_SECRET_KEY must start with sk_test_ for test mode. Live keys start with sk_live_. Ensure .env.local has no extra quotes or spaces. Restart the dev server after changing env vars — Next.js doesn’t hot-reload environment variables.

2. Webhook signature verification fails Stripe webhooks require a signing secret (STRIPE_WEBHOOK_SECRET starting with whsec_), not just the API key. Set up the Stripe CLI and run stripe listen --forward-to localhost:3000/api/webhook to get the signing secret for local development.

3. Prisma “Table does not exist” If you modify schema.prisma after running db push, the database might be out of sync. Run npx prisma db push --force-reset to recreate all tables (warning: deletes existing data). For production, use Prisma Migrations instead.

4. Cart data lost on page refresh The cart is stored in an HTTP-only cookie with a 7-day expiry. If cookies are blocked in the browser, the cart won’t persist. Always set httpOnly: true and sameSite: "lax" for cart cookies to prevent XSS attacks.

Practice Questions

1. Why are prices stored in cents (integer) rather than dollars (float)? Floating-point arithmetic causes precision errors — 0.1 + 0.2 = 0.30000000000000004. Storing prices as integers in cents avoids rounding errors in calculations, taxes, and discounts. Display formatting divides by 100 only for the UI.

2. How does the webhook ensure orders aren’t duplicated? Stripe webhooks are idempotent. The checkout.session.completed event may be delivered more than once. Our code should check if an order with the same stripeId already exists before creating a new one. In production, add a findFirst check before create.

3. Why use cookies instead of localStorage for the cart? Cart cookies can be read by server components and API routes (via the cookies() function), enabling server-side rendering of cart state. localStorage is only available in the browser. With Next.js App Router, server components need access to cart data for SEO and initial page load.

4. Challenge: Add product search and filtering Add a search bar that filters products by name/category. Use URL search params so search results are shareable and indexable. Add category filter buttons. Implement both client-side filtering (instant) and server-side filtering (via API route for pagination).

5. Challenge: Implement coupon/discount codes Add a Coupon model to Prisma with code, discountPercent, and expiresAt fields. Create an API route to validate coupon codes. Pass the discount to Stripe Checkout using dynamic_tax_rates or by adjusting line item amounts before creating the session.

FAQ

How do I switch to a real database?
Replace the SQLite datasource in schema.prisma with PostgreSQL or MySQL. Run npx prisma migrate dev to create migrations. Update DATABASE_URL in .env.local with your cloud database connection string. Prisma abstracts the database — no code changes needed beyond the schema.
How do I add product images?
Store images in a cloud service like AWS S3 or Cloudinary. Add an imageUrl field to the Product model. Create an upload API route that handles multipart form data and returns the URL. For the admin panel, add a file input that uploads to your cloud storage.
How do I handle taxes and shipping?
Stripe Checkout supports tax_rates and shipping_rates. Create tax rates in the Stripe dashboard and pass their IDs in the session creation call. For complex tax logic, use a service like TaxJar or Stripe Tax, which calculates rates based on the customer’s address.

Next Steps

  • Add user authentication with Auth0 for customer accounts
  • Switch to PostgreSQL for production deployment
  • Learn Docker to containerize the full stack
  • Build the Real-Time Dashboard project for admin analytics

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro