Next.js

E-commerce with Next.js

50 min Lesson 38 of 40

E-commerce with Next.js

Building an e-commerce application with Next.js combines the framework's performance benefits with modern shopping experiences. This lesson covers implementing product catalogs, shopping carts, checkout flows, and payment integration.

E-commerce Architecture Overview

A modern Next.js e-commerce application typically includes:

  • Product Catalog: Browse and search products with filtering and pagination
  • Product Details: Rich product pages with images, descriptions, variants
  • Shopping Cart: Add/remove items, update quantities, persist cart state
  • Checkout Flow: Multi-step checkout with address, shipping, and payment
  • Payment Processing: Secure payment integration with Stripe or similar
  • Order Management: Order history, tracking, and status updates
  • User Accounts: Authentication, profiles, saved addresses

Database Schema for E-commerce

Prisma schema example:
model Product {
  id          String   @id @default(cuid())
  name        String
  slug        String   @unique
  description String
  price       Float
  compareAtPrice Float?
  images      String[]
  category    Category @relation(fields: [categoryId], references: [id])
  categoryId  String
  variants    ProductVariant[]
  inventory   Int      @default(0)
  published   Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

model ProductVariant {
  id        String  @id @default(cuid())
  product   Product @relation(fields: [productId], references: [id])
  productId String
  name      String
  sku       String  @unique
  price     Float
  inventory Int     @default(0)
  options   Json    // { size: "M", color: "Blue" }
}

model Category {
  id          String    @id @default(cuid())
  name        String
  slug        String    @unique
  description String?
  products    Product[]
  parent      Category? @relation("CategoryToCategory", fields: [parentId], references: [id])
  parentId    String?
  children    Category[] @relation("CategoryToCategory")
}

model Cart {
  id        String     @id @default(cuid())
  userId    String?
  items     CartItem[]
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt
}

model CartItem {
  id        String  @id @default(cuid())
  cart      Cart    @relation(fields: [cartId], references: [id])
  cartId    String
  productId String
  variantId String?
  quantity  Int
  price     Float
}

model Order {
  id              String      @id @default(cuid())
  orderNumber     String      @unique
  user            User        @relation(fields: [userId], references: [id])
  userId          String
  items           OrderItem[]
  subtotal        Float
  tax             Float
  shipping        Float
  total           Float
  status          OrderStatus @default(PENDING)
  shippingAddress Json
  billingAddress  Json
  paymentIntentId String?
  createdAt       DateTime    @default(now())
  updatedAt       DateTime    @updatedAt
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

model OrderItem {
  id        String @id @default(cuid())
  order     Order  @relation(fields: [orderId], references: [id])
  orderId   String
  productId String
  variantId String?
  quantity  Int
  price     Float
  name      String
}

Product Catalog Implementation

app/products/page.tsx:
import { prisma } from '@/lib/prisma';
import ProductGrid from '@/components/ProductGrid';
import ProductFilters from '@/components/ProductFilters';
import { Suspense } from 'react';

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: {
    category?: string;
    sort?: string;
    minPrice?: string;
    maxPrice?: string;
    page?: string;
  };
}) {
  const page = parseInt(searchParams.page || '1');
  const limit = 12;
  const skip = (page - 1) * limit;

  const where = {
    published: true,
    ...(searchParams.category && {
      category: { slug: searchParams.category },
    }),
    ...(searchParams.minPrice && {
      price: { gte: parseFloat(searchParams.minPrice) },
    }),
    ...(searchParams.maxPrice && {
      price: { lte: parseFloat(searchParams.maxPrice) },
    }),
  };

  const orderBy = getOrderBy(searchParams.sort);

  const [products, total] = await Promise.all([
    prisma.product.findMany({
      where,
      orderBy,
      skip,
      take: limit,
      include: {
        category: true,
      },
    }),
    prisma.product.count({ where }),
  ]);

  const totalPages = Math.ceil(total / limit);

  return (
    <div className="products-page">
      <h1>Products</h1>

      <div className="products-layout">
        <aside className="filters">
          <Suspense fallback={<div>Loading filters...</div>}>
            <ProductFilters />
          </Suspense>
        </aside>

        <main>
          <ProductGrid products={products} />

          {totalPages > 1 && (
            <Pagination
              currentPage={page}
              totalPages={totalPages}
            />
          )}
        </main>
      </div>
    </div>
  );
}

function getOrderBy(sort?: string) {
  switch (sort) {
    case 'price-asc':
      return { price: 'asc' as const };
    case 'price-desc':
      return { price: 'desc' as const };
    case 'name':
      return { name: 'asc' as const };
    default:
      return { createdAt: 'desc' as const };
  }
}

Product Detail Page

app/products/[slug]/page.tsx:
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import ProductImages from '@/components/ProductImages';
import AddToCartButton from '@/components/AddToCartButton';
import ProductVariants from '@/components/ProductVariants';

export async function generateStaticParams() {
  const products = await prisma.product.findMany({
    where: { published: true },
    select: { slug: true },
  });

  return products.map((product) => ({
    slug: product.slug,
  }));
}

export async function generateMetadata({ params }: Props) {
  const product = await prisma.product.findUnique({
    where: { slug: params.slug },
  });

  if (!product) return {};

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      images: product.images,
    },
  };
}

export default async function ProductPage({
  params,
}: {
  params: { slug: string };
}) {
  const product = await prisma.product.findUnique({
    where: { slug: params.slug },
    include: {
      category: true,
      variants: true,
    },
  });

  if (!product) {
    notFound();
  }

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.images,
    offers: {
      '@type': 'Offer',
      price: product.price,
      priceCurrency: 'USD',
      availability: product.inventory > 0
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />

      <div className="product-page">
        <div className="product-grid">
          <div className="product-images">
            <ProductImages images={product.images} />
          </div>

          <div className="product-info">
            <h1>{product.name}</h1>

            <div className="product-price">
              {product.compareAtPrice && (
                <span className="compare-price">
                  ${product.compareAtPrice}
                </span>
              )}
              <span className="price">${product.price}</span>
            </div>

            <p className="description">{product.description}</p>

            {product.variants.length > 0 && (
              <ProductVariants variants={product.variants} />
            )}

            <AddToCartButton
              productId={product.id}
              name={product.name}
              price={product.price}
              image={product.images[0]}
              inStock={product.inventory > 0}
            />

            <div className="product-meta">
              <p>Category: {product.category.name}</p>
              <p>
                Availability:{" "}
                {product.inventory > 0 ? 'In Stock' : 'Out of Stock'}
              </p>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}

Shopping Cart Implementation

lib/cart.ts (Zustand store):
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartItem {
  id: string;
  productId: string;
  variantId?: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  getTotal: () => number;
  getItemCount: () => number;
}

export const useCart = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],

      addItem: (newItem) =>
        set((state) => {
          const existingItem = state.items.find(
            (item) => item.id === newItem.id
          );

          if (existingItem) {
            return {
              items: state.items.map((item) =>
                item.id === newItem.id
                  ? { ...item, quantity: item.quantity + 1 }
                  : item
              ),
            };
          }

          return {
            items: [...state.items, { ...newItem, quantity: 1 }],
          };
        }),

      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((item) => item.id !== id),
        })),

      updateQuantity: (id, quantity) =>
        set((state) => ({
          items: state.items.map((item) =>
            item.id === id ? { ...item, quantity } : item
          ),
        })),

      clearCart: () => set({ items: [] }),

      getTotal: () => {
        return get().items.reduce(
          (total, item) => total + item.price * item.quantity,
          0
        );
      },

      getItemCount: () => {
        return get().items.reduce((count, item) => count + item.quantity, 0);
      },
    }),
    {
      name: 'shopping-cart',
    }
  )
);
components/AddToCartButton.tsx:
'use client';

import { useCart } from '@/lib/cart';
import { useState } from 'react';

interface Props {
  productId: string;
  name: string;
  price: number;
  image: string;
  inStock: boolean;
}

export default function AddToCartButton({
  productId,
  name,
  price,
  image,
  inStock,
}: Props) {
  const { addItem } = useCart();
  const [added, setAdded] = useState(false);

  const handleAddToCart = () => {
    addItem({
      id: productId,
      productId,
      name,
      price,
      image,
    });

    setAdded(true);
    setTimeout(() => setAdded(false), 2000);
  };

  return (
    <button
      onClick={handleAddToCart}
      disabled={!inStock}
      className="add-to-cart-button"
    >
      {added ? 'Added to Cart!' : inStock ? 'Add to Cart' : 'Out of Stock'}
    </button>
  );
}
app/cart/page.tsx:
'use client';

import { useCart } from '@/lib/cart';
import Link from 'next/link';
import Image from 'next/image';

export default function CartPage() {
  const { items, removeItem, updateQuantity, getTotal } = useCart();

  if (items.length === 0) {
    return (
      <div className="empty-cart">
        <h1>Your cart is empty</h1>
        <Link href="/products">Continue Shopping</Link>
      </div>
    );
  }

  return (
    <div className="cart-page">
      <h1>Shopping Cart</h1>

      <div className="cart-items">
        {items.map((item) => (
          <div key={item.id} className="cart-item">
            <Image
              src={item.image}
              alt={item.name}
              width={100}
              height={100}
            />

            <div className="item-details">
              <h3>{item.name}</h3>
              <p>${item.price}</p>
            </div>

            <div className="item-quantity">
              <button
                onClick={() =>
                  updateQuantity(item.id, Math.max(1, item.quantity - 1))
                }
              >
                -
              </button>
              <span>{item.quantity}</span>
              <button
                onClick={() => updateQuantity(item.id, item.quantity + 1)}
              >
                +
              </button>
            </div>

            <div className="item-total">
              ${(item.price * item.quantity).toFixed(2)}
            </div>

            <button
              onClick={() => removeItem(item.id)}
              className="remove-button"
            >
              Remove
            </button>
          </div>
        ))}
      </div>

      <div className="cart-summary">
        <h2>Order Summary</h2>
        <div className="summary-row">
          <span>Subtotal:</span>
          <span>${getTotal().toFixed(2)}</span>
        </div>
        <div className="summary-row">
          <span>Shipping:</span>
          <span>Calculated at checkout</span>
        </div>
        <div className="summary-total">
          <span>Total:</span>
          <span>${getTotal().toFixed(2)}</span>
        </div>

        <Link href="/checkout" className="checkout-button">
          Proceed to Checkout
        </Link>
      </div>
    </div>
  );
}

Stripe Payment Integration

Install Stripe:
npm install stripe @stripe/stripe-js
lib/stripe.ts:
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-01-01',
});
app/api/checkout/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getServerSession } from 'next-auth';

export async function POST(request: NextRequest) {
  try {
    const session = await getServerSession();

    if (!session?.user) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    const { items } = await request.json();

    // Create Stripe checkout session
    const checkoutSession = await stripe.checkout.sessions.create({
      mode: 'payment',
      customer_email: session.user.email,
      line_items: items.map((item: any) => ({
        price_data: {
          currency: 'usd',
          product_data: {
            name: item.name,
            images: [item.image],
          },
          unit_amount: Math.round(item.price * 100),
        },
        quantity: item.quantity,
      })),
      success_url: \`${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}\`,
      cancel_url: \`${process.env.NEXT_PUBLIC_URL}/cart\`,
      metadata: {
        userId: session.user.id,
      },
    });

    return NextResponse.json({ url: checkoutSession.url });
  } catch (error) {
    console.error('Checkout error:', error);
    return NextResponse.json(
      { error: 'Failed to create checkout session' },
      { status: 500 }
    );
  }
}
app/checkout/page.tsx:
'use client';

import { useCart } from '@/lib/cart';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function CheckoutPage() {
  const { items, getTotal } = useCart();
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleCheckout = async () => {
    setLoading(true);

    try {
      const response = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      });

      const { url } = await response.json();

      if (url) {
        router.push(url);
      }
    } catch (error) {
      console.error('Checkout error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="checkout-page">
      <h1>Checkout</h1>

      <div className="checkout-summary">
        <h2>Order Summary</h2>
        {items.map((item) => (
          <div key={item.id} className="checkout-item">
            <span>{item.name} x {item.quantity}</span>
            <span>${(item.price * item.quantity).toFixed(2)}</span>
          </div>
        ))}

        <div className="checkout-total">
          <span>Total:</span>
          <span>${getTotal().toFixed(2)}</span>
        </div>

        <button
          onClick={handleCheckout}
          disabled={loading}
          className="checkout-button"
        >
          {loading ? 'Processing...' : 'Pay with Stripe'}
        </button>
      </div>
    </div>
  );
}

Webhook for Order Processing

app/api/webhooks/stripe/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';

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,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (error) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;

    // Create order in database
    await prisma.order.create({
      data: {
        orderNumber: \`ORD-${Date.now()}\`,
        userId: session.metadata?.userId!,
        total: session.amount_total! / 100,
        paymentIntentId: session.payment_intent as string,
        status: 'PROCESSING',
        // ... other order data
      },
    });
  }

  return NextResponse.json({ received: true });
}
Exercise:
  1. Set up a product database schema with Prisma
  2. Create a product catalog with filtering and sorting
  3. Implement a shopping cart with Zustand
  4. Build a product detail page with variants
  5. Integrate Stripe for payment processing
  6. Set up webhooks for order fulfillment
  7. Add order history and tracking features
E-commerce Best Practices:
  • Use proper database indexes for product queries
  • Implement inventory management and stock checks
  • Add product search with fuzzy matching
  • Optimize images for different screen sizes
  • Implement proper error handling for payments
  • Add loading states for better UX
  • Use webhooks for reliable order processing
  • Implement proper security for payment flows