Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
75 minLesson 40 of 40
Capstone Projects

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

  1. Set environment variables on Vercel:

    • DATABASE_URL — production database
    • STRIPE_SECRET_KEY — live key from Stripe dashboard
    • STRIPE_WEBHOOK_SECRET — from Stripe → Webhooks → Signing secret
    • NEXT_PUBLIC_APP_URL — your production URL
    • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY — live publishable key
  2. Add your production URL to Stripe webhook endpoints (Dashboard → Developers → Webhooks)

  3. 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

Get Notes Free →
!