Project: E-Commerce Store
Project: E-Commerce Store with Stripe Payments
This is the capstone project of the bootcamp — a fully functional e-commerce store with a product catalog, shopping cart, real Stripe checkout, and an order management system. By the end you'll have a production-ready store you can show in your portfolio.
What You'll Build
- Product catalog — grid of products with filtering by category and price
- Product detail pages — images, description, add to cart
- Shopping cart — persistent cart using localStorage and database sync
- Stripe Checkout — real payment processing (test mode)
- Order confirmation — success page with order details
- Order history — authenticated users can see past purchases
- Webhook handler — Stripe webhook to update order status after payment
Project Setup
npx create-next-app@latest ecommerce-store --typescript --tailwind --app
cd ecommerce-store
npm install prisma @prisma/client stripe @stripe/stripe-js
npm install bcryptjs jsonwebtoken zod
npm install -D @types/bcryptjs @types/jsonwebtoken
npx prisma init
Database Schema
// prisma/schema.prisma
model Product {
id String @id @default(cuid())
slug String @unique
name String
description String @db.Text
price Float
images String[]
category String
stock Int @default(0)
published Boolean @default(true)
orderItems OrderItem[]
@@map("products")
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
createdAt DateTime @default(now())
orders Order[]
@@map("users")
}
model Order {
id String @id @default(cuid())
status OrderStatus @default(PENDING)
total Float
stripeSessionId String? @unique
createdAt DateTime @default(now())
userId String?
user User? @relation(fields: [userId], references: [id])
items OrderItem[]
shipping ShippingAddress?
@@map("orders")
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELLED
}
model OrderItem {
id String @id @default(cuid())
quantity Int
price Float
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id])
@@map("order_items")
}
model ShippingAddress {
id String @id @default(cuid())
name String
line1 String
line2 String?
city String
state String
zip String
country String @default("US")
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("shipping_addresses")
}
Cart State Management
Keep the cart in localStorage for guest users and sync to the database for logged-in users:
// src/store/cart.ts
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CartItem {
id: string;
name: string;
price: number;
image: string;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (product: CartItem) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: number;
count: number;
}
// npm install zustand
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (product) => set(state => {
const existing = state.items.find(i => i.id === product.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id),
})),
updateQuantity: (id, quantity) => set(state => ({
items: quantity === 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, quantity } : i),
})),
clearCart: () => set({ items: [] }),
get total() {
return get().items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
get count() {
return get().items.reduce((sum, i) => sum + i.quantity, 0);
},
}),
{ name: "cart" }
)
);
Stripe Checkout
Create a Checkout Session
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const { items } = await request.json() as { items: Array<{ id: string; quantity: number }> };
if (!items?.length) {
return NextResponse.json({ error: "Cart is empty" }, { status: 400 });
}
// Fetch product details from database (never trust client-sent prices)
const productIds = items.map(i => i.id);
const products = await db.product.findMany({
where: { id: { in: productIds }, published: true },
});
if (products.length !== items.length) {
return NextResponse.json({ error: "Some products not available" }, { status: 400 });
}
// Create Stripe line items
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = items.map(item => {
const product = products.find(p => p.id === item.id)!;
return {
price_data: {
currency: "usd",
unit_amount: Math.round(product.price * 100), // cents
product_data: {
name: product.name,
images: product.images.slice(0, 1),
},
},
quantity: item.quantity,
};
});
// Create a pending order in the database
const order = await db.order.create({
data: {
total: products.reduce((sum, p) => {
const item = items.find(i => i.id === p.id)!;
return sum + p.price * item.quantity;
}, 0),
items: {
create: items.map(item => {
const product = products.find(p => p.id === item.id)!;
return {
productId: item.id,
quantity: item.quantity,
price: product.price,
};
}),
},
},
});
// Create Stripe checkout session
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: lineItems,
mode: "payment",
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart`,
metadata: {
orderId: order.id,
},
shipping_address_collection: {
allowed_countries: ["US", "CA", "GB"],
},
});
// Save Stripe session ID
await db.order.update({
where: { id: order.id },
data: { stripeSessionId: session.id },
});
return NextResponse.json({ url: session.url });
}
Stripe Webhook — Mark Orders as Paid
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
if (orderId) {
await db.order.update({
where: { stripeSessionId: session.id },
data: {
status: "PAID",
shipping: session.shipping_details ? {
create: {
name: session.shipping_details.name ?? "",
line1: session.shipping_details.address?.line1 ?? "",
line2: session.shipping_details.address?.line2 ?? undefined,
city: session.shipping_details.address?.city ?? "",
state: session.shipping_details.address?.state ?? "",
zip: session.shipping_details.address?.postal_code ?? "",
country: session.shipping_details.address?.country ?? "US",
}
} : undefined,
},
});
}
}
return NextResponse.json({ received: true });
}
The Cart Component
// src/components/Cart.tsx
"use client";
import { useCart } from "@/store/cart";
export function Cart() {
const { items, removeItem, updateQuantity, total } = useCart();
async function handleCheckout() {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items.map(i => ({ id: i.id, quantity: i.quantity })) }),
});
const { url } = await res.json();
window.location.href = url; // redirect to Stripe hosted checkout
}
if (items.length === 0) {
return <p className="text-gray-500 text-center py-12">Your cart is empty.</p>;
}
return (
<div>
{items.map(item => (
<div key={item.id} className="flex items-center gap-4 py-4 border-b">
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded-lg"/>
<div className="flex-1">
<p className="font-medium">{item.name}</p>
<p className="text-gray-500 text-sm">${item.price.toFixed(2)}</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => updateQuantity(item.id, item.quantity - 1)} className="w-8 h-8 border rounded flex items-center justify-center">-</button>
<span className="w-6 text-center">{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)} className="w-8 h-8 border rounded flex items-center justify-center">+</button>
</div>
<button onClick={() => removeItem(item.id)} className="text-red-500 hover:text-red-600 text-sm">Remove</button>
</div>
))}
<div className="mt-6 border-t pt-4 flex items-center justify-between">
<span className="text-xl font-bold">Total: ${total.toFixed(2)}</span>
<button
onClick={handleCheckout}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-6 py-3 rounded-xl transition-colors"
>
Checkout with Stripe
</button>
</div>
</div>
);
}
Testing with Stripe Test Cards
Card number: 4242 4242 4242 4242
Expiry: Any future date (e.g., 12/27)
CVC: Any 3 digits (e.g., 123)
ZIP: Any 5 digits
Declined cards:
4000 0000 0000 0002 → Declined
4000 0000 0000 9995 → Insufficient funds
Run the Stripe webhook listener locally:
npm install -g stripe
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Deploying
-
Set environment variables on Vercel:
DATABASE_URL— production databaseSTRIPE_SECRET_KEY— live key from Stripe dashboardSTRIPE_WEBHOOK_SECRET— from Stripe → Webhooks → Signing secretNEXT_PUBLIC_APP_URL— your production URLNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY— live publishable key
-
Add your production URL to Stripe webhook endpoints (Dashboard → Developers → Webhooks)
-
Deploy via
git push— Vercel builds and deploys automatically
You now have a production-ready e-commerce store. Congratulations on completing the Web Development Bootcamp.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises