Build an E-Commerce Store with Next.js (Step by Step)
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
- Next.js basics — pages, API routes
- React fundamentals
- Node.js 18+ and npm installed
- A Stripe account (free, for test mode)
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 initCreate 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 generateExpected output:
Your database is now in sync with your Prisma schema. ✔
✔ Generated Prisma Client to ./node_modules/@prisma/clientStep 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 seedExpected output:
Seeded 6 productsStep 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
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