In this comprehensive tutorial, I'll walk you through building a complete e-commerce application with Next.js 15, featuring a persistent shopping cart, responsive design, and modern React patterns. We'll cover everything from state management to UI components and TypeScript integration.
π What We're Building
Our e-commerce app will include:
- Product catalog with grid layout
 - Shopping cart with slide-out sidebar
 - Persistent state using localStorage
 - Responsive design with Tailwind CSS
 - TypeScript for type safety
 - Custom notification system
 - Dynamic routing for product pages
 
π Table of Contents
- Project Setup
 - State Management with Zustand
 - Building the UI Components
 - Custom Hooks and Utilities
 - Responsive Design
 - Performance Optimizations
 - Conclusion
 
π οΈ Project Setup
Let's start by setting up a new Next.js 15 project with TypeScript:
  npx create-next-app@latest ecommerce-app --typescript 
  --tailwind --eslint --app
  cd ecommerce-app
  npm install zustand lucide-react
Our package.json includes these key dependencies:
{
    "dependencies": {
      "next": "15.3.5",
      "react": "^19.0.0",
      "react-dom": "^19.0.0",
      "zustand": "^5.0.6",
      "lucide-react": "^0.525.0"
    },
    "devDependencies": {
      "@types/node": "^20",
      "@types/react": "^19",
      "@types/react-dom": "^19",
      "tailwindcss": "^4",
      "typescript": "^5"
    }
  }
πͺ State Management with Zustand
Zustand provides a simple, lightweight state management solution. Let's create our cart store:
// src/stores/cart-store.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export interface Product {
  id: string
  name: string
  price: number
  image: string
  image_full: string
  category: string
  description: string
}
export interface CartItem {
  product: Product
  quantity: number
}
interface CartState {
  items: CartItem[]
  isOpen: boolean
}
interface CartActions {
  addItem: (product: Product) => void
  removeItem: (productId: string) => void
  updateQuantity: (productId: string, quantity: number) => void
  clearCart: () => void
  toggleCart: () => void
  getTotalItems: () => number
  getTotalPrice: () => number
}
type CartStore = CartState & CartActions
export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      isOpen: false,
      addItem: (product: Product) => {
        const { items } = get()
        const existingItem = items.find(item => item.product.id === product.id)
        if (existingItem) {
          set({
            items: items.map(item =>
              item.product.id === product.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            )
          })
        } else {
          set({
            items: [...items, { product, quantity: 1 }]
          })
        }
      },
      removeItem: (productId: string) => {
        set({
          items: get().items.filter(item => item.product.id !== productId)
        })
      },
      updateQuantity: (productId: string, quantity: number) => {
        if (quantity <= 0) {
          get().removeItem(productId)
          return
        }
        set({
          items: get().items.map(item =>
            item.product.id === productId
              ? { ...item, quantity }
              : item
          )
        })
      },
      clearCart: () => set({ items: [] }),
      toggleCart: () => set({ isOpen: !get().isOpen }),
      getTotalItems: () => {
        return get().items.reduce((total, item) => total + item.quantity, 0)
      },
      getTotalPrice: () => {
        return get().items.reduce((total, item) => 
          total + (item.product.price * item.quantity), 0
        )
      }
    }),
    {
      name: 'shopping-cart',
      storage: createJSONStorage(() => localStorage),
    }
  )
)
Key Features of Our Store:
- Persistent State: Data survives page refreshes
 - Immutable Updates: Clean state mutations
 - Computed Values: Automatic totals calculation
 - TypeScript Support: Full type safety
 
π¨ Building the UI Components
Product Card Component
// src/components/ProductCard.tsx
import Image from 'next/image'
import Link from 'next/link'
import { ShoppingCart } from 'lucide-react'
import { Product, useCartStore } from '@/stores/cart-store'
import { useNotification } from '@/hooks/useNotification'
interface ProductCardProps {
  product: Product
}
export const ProductCard = ({ product }: ProductCardProps) => {
  const addItem = useCartStore((state) => state.addItem)
  const notification = useNotification()
  const handleAddToCart = () => {
    addItem(product)
    notification.success(`${product.name} added to cart!`, {
      duration: 3000
    })
  }
  return (
    <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
      <Link href={`/product/${product.id}`}>
        <div className="relative h-48 cursor-pointer">
          <Image
            src={product.image}
            alt={product.name}
            fill
            className="object-cover hover:scale-105 transition-transform duration-300"
          />
        </div>
      </Link>
      <div className="p-4">
        <div className="flex justify-between items-start mb-2">
          <Link href={`/product/${product.id}`}>
            <h3 className="text-lg font-semibold text-gray-900 line-clamp-1 hover:text-blue-600 transition-colors cursor-pointer">
              {product.name}
            </h3>
          </Link>
          <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
            {product.category}
          </span>
        </div>
        <p className="text-gray-600 text-sm mb-3 line-clamp-2">
          {product.description}
        </p>
        <div className="flex justify-between items-center">
          <span className="text-2xl font-bold text-gray-900">
            ${product.price}
          </span>
          <button
            onClick={handleAddToCart}
            className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors duration-200"
          >
            <ShoppingCart size={18} />
            Add to Cart
          </button>
        </div>
      </div>
    </div>
  )
}
Shopping Cart Component
// src/components/Cart.tsx
import { X, ShoppingBag } from 'lucide-react'
import { useCartStore } from '@/stores/cart-store'
import { CartItem } from './CartItem'
export const Cart = () => {
  const { items, isOpen, toggleCart, clearCart, getTotalPrice } = useCartStore()
  if (!isOpen) return null
  return (
    <>
      {/* Overlay */}
      <div
        className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40"
        onClick={toggleCart}
      />
      {/* Cart Sidebar */}
      <div className="fixed right-0 top-0 h-full w-96 bg-white shadow-2xl z-50 transform transition-transform duration-300">
        <div className="flex flex-col h-full">
          {/* Header */}
          <div className="flex items-center justify-between p-6 border-b border-gray-200">
            <h2 className="text-xl font-semibold text-gray-900 flex items-center gap-3">
              <ShoppingBag size={24} className="text-blue-600" />
              Shopping Cart ({items.length})
            </h2>
            <button
              onClick={toggleCart}
              className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700"
            >
              <X size={24} />
            </button>
          </div>
          {/* Cart Items */}
          <div className="flex-1 overflow-y-auto">
            {items.length === 0 ? (
              <div className="flex flex-col items-center justify-center h-full text-gray-500">
                <ShoppingBag size={64} className="mb-6 text-gray-300" />
                <p className="text-lg font-medium text-gray-700">Your cart is empty</p>
                <p className="text-sm text-gray-500 mt-2">Add some products to get started!</p>
              </div>
            ) : (
              <div className="p-2">
                {items.map((item) => (
                  <CartItem key={item.product.id} item={item} />
                ))}
              </div>
            )}
          </div>
          {/* Footer */}
          {items.length > 0 && (
            <div className="p-6 border-t border-gray-200 bg-gray-50">
              <div className="flex justify-between items-center mb-6">
                <span className="text-2xl font-bold text-gray-900">
                  Total: ${getTotalPrice().toFixed(2)}
                </span>
                <button
                  onClick={clearCart}
                  className="text-sm text-red-600 hover:text-red-700 transition-colors font-medium"
                >
                  Clear Cart
                </button>
              </div>
              <button className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-4 rounded-xl font-semibold transition-all duration-200 transform hover:scale-105 shadow-lg">
                Checkout
              </button>
            </div>
          )}
        </div>
      </div>
    </>
  )
}
Cart Item Component
// src/components/CartItem.tsx
import Image from 'next/image'
import { Minus, Plus, Trash2 } from 'lucide-react'
import { CartItem as CartItemType, useCartStore } from '@/stores/cart-store'
interface CartItemProps {
  item: CartItemType
}
export const CartItem = ({ item }: CartItemProps) => {
  const { updateQuantity, removeItem } = useCartStore()
  const handleIncrement = () => {
    updateQuantity(item.product.id, item.quantity + 1)
  }
  const handleDecrement = () => {
    updateQuantity(item.product.id, item.quantity - 1)
  }
  const handleRemove = () => {
    removeItem(item.product.id)
  }
  return (
    <div className="flex items-start gap-4 p-4 mx-2 mb-3 bg-white rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors shadow-sm">
      <div className="flex flex-col items-center gap-2">
        <div className="relative w-16 h-16 flex-shrink-0">
          <Image
            src={item.product.image}
            alt={item.product.name}
            fill
            className="object-cover rounded-lg"
          />
        </div>
        <h4 className="text-xs font-medium text-gray-900 text-center w-16 leading-tight">
          {item.product.name}
        </h4>
      </div>
      <div className="flex-1 min-w-0 flex flex-col gap-2">
        <div className="flex items-center gap-2 bg-gray-100 rounded-lg p-1 w-fit">
          <button
            onClick={handleDecrement}
            className="p-1 rounded-md hover:bg-gray-200 transition-colors"
            disabled={item.quantity <= 1}
          >
            <Minus size={16} className={item.quantity <= 1 ? 'text-gray-400' : 'text-gray-600'} />
          </button>
          <span className="w-8 text-center text-sm font-medium text-gray-900">
            {item.quantity}
          </span>
          <button
            onClick={handleIncrement}
            className="p-1 rounded-md hover:bg-gray-200 transition-colors"
          >
            <Plus size={16} className="text-gray-600" />
          </button>
        </div>
      </div>
      <div className="flex items-center gap-3">
        <span className="text-sm font-semibold text-gray-900 min-w-[60px] text-right">
          ${(item.product.price * item.quantity).toFixed(2)}
        </span>
        <button
          onClick={handleRemove}
          className="p-1.5 rounded-md hover:bg-red-100 transition-colors"
        >
          <Trash2 size={16} className="text-red-600 hover:text-red-700" />
        </button>
      </div>
    </div>
  )
}
π§ Custom Hooks and Utilities
Custom Notification Hook
// src/hooks/useNotification.ts
'use client'
import { useEffect, useRef } from 'react'
interface NotificationOptions {
  type?: 'success' | 'error' | 'warning' | 'info'
  duration?: number
  dismissible?: boolean
}
export const useNotification = () => {
  const containerRef = useRef<HTMLDivElement | null>(null)
  useEffect(() => {
    // Create notification container if it doesn't exist
    if (!containerRef.current) {
      const container = document.createElement('div')
      container.id = 'notification-container'
      container.style.cssText = `
        position: fixed;
        top: 20px;
        right: 20px;
        z-index: 9999;
        pointer-events: none;
      `
      document.body.appendChild(container)
      containerRef.current = container
    }
  }, [])
  const showNotification = (message: string, options: NotificationOptions = {}) => {
    const {
      type = 'success',
      duration = 3000,
      dismissible = true
    } = options
    const notification = document.createElement('div')
    notification.style.cssText = `
      background-color: ${getBackgroundColor(type)};
      color: white;
      padding: 12px 16px;
      margin-bottom: 8px;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      font-family: system-ui, sans-serif;
      font-size: 14px;
      font-weight: 500;
      pointer-events: auto;
      transform: translateX(100%);
      transition: transform 0.3s ease-in-out;
      max-width: 300px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    `
    // Add message and close button
    const messageSpan = document.createElement('span')
    messageSpan.textContent = message
    notification.appendChild(messageSpan)
    if (dismissible) {
      const closeButton = document.createElement('button')
      closeButton.innerHTML = 'Γ'
      closeButton.style.cssText = `
        background: none;
        border: none;
        color: white;
        font-size: 18px;
        cursor: pointer;
        padding: 0;
        margin-left: 8px;
        opacity: 0.8;
      `
      closeButton.onclick = () => removeNotification(notification)
      notification.appendChild(closeButton)
    }
    if (containerRef.current) {
      containerRef.current.appendChild(notification)
    }
    // Animate in
    setTimeout(() => {
      notification.style.transform = 'translateX(0)'
    }, 10)
    // Auto remove
    if (duration > 0) {
      setTimeout(() => {
        removeNotification(notification)
      }, duration)
    }
  }
  const removeNotification = (notification: HTMLElement) => {
    notification.style.transform = 'translateX(100%)'
    setTimeout(() => {
      if (notification.parentNode) {
        notification.parentNode.removeChild(notification)
      }
    }, 300)
  }
  const getBackgroundColor = (type: string) => {
    switch (type) {
      case 'success': return '#10b981'
      case 'error': return '#ef4444'
      case 'warning': return '#f59e0b'
      case 'info': return '#3b82f6'
      default: return '#10b981'
    }
  }
  return {
    success: (message: string, options?: Omit<NotificationOptions, 'type'>) =>
      showNotification(message, { ...options, type: 'success' }),
    error: (message: string, options?: Omit<NotificationOptions, 'type'>) =>
      showNotification(message, { ...options, type: 'error' }),
    warning: (message: string, options?: Omit<NotificationOptions, 'type'>) =>
      showNotification(message, { ...options, type: 'warning' }),
    info: (message: string, options?: Omit<NotificationOptions, 'type'>) =>
      showNotification(message, { ...options, type: 'info' }),
  }
}
π± Responsive Design {#responsive-design}
Our application uses Tailwind CSS for responsive design:
// src/components/ProductGrid.tsx
import { products } from '@/data/products'
import { ProductCard } from './ProductCard'
export const ProductGrid = () => {
  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <div className="mb-8">
        <h2 className="text-3xl font-bold text-gray-900 mb-2">
          Featured Products
        </h2>
        <p className="text-gray-600">
          Discover our latest collection of premium products
        </p>
      </div>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  )
}
Responsive Breakpoints:
- Mobile: 1 column
 - Small tablets: 2 columns
 - Large tablets: 3 columns
 - Desktop: 4 columns
 
π Performance Optimizations {#performance}
1. Next.js Image Optimization
<Image
  src={product.image}
  alt={product.name}
  fill
  className="object-cover hover:scale-105 transition-transform duration-300"
  priority={index < 4} // Prioritize first 4 images
/>
2. Zustand Store Optimization
- Selective subscriptions to prevent unnecessary re-renders
 - Computed values for efficient calculations
 - Persistent state with localStorage
 
3. Component Optimization
- Proper use of React.memo for expensive components
 - Efficient key props for lists
 - Lazy loading for non-critical components
 
π― Key Features Implemented
β Shopping Cart Functionality
- Add/remove items
 - Update quantities
 - Clear entire cart
 - Persistent state across sessions
 
β Product Management
- Dynamic product catalog
 - Category filtering
 - Responsive product grid
 - Product detail pages
 
β User Experience
- Slide-out cart sidebar
 - Real-time notifications
 - Smooth animations
 - Mobile-responsive design
 
β TypeScript Integration
- Full type safety
 - Interface definitions
 - Proper prop typing
 - Error prevention
 
π Additional Features to Consider
For a production application, consider adding:
- 
Authentication System
- User login/registration
 - Protected routes
 - User profiles
 
 - 
Payment Integration
- Stripe/PayPal integration
 - Checkout process
 - Order management
 
 - 
Backend Integration
- API routes
 - Database integration
 - Inventory management
 
 - 
Search & Filtering
- Product search
 - Category filters
 - Price ranges
 
 - 
SEO Optimization
- Meta tags
 - Structured data
 - Sitemap generation
 
 
π Conclusion
We've built a modern, feature-rich e-commerce application using Next.js 15, Zustand, and TypeScript. The application includes:
- Persistent shopping cart with localStorage
 - Responsive design that works on all devices
 - Type-safe codebase with TypeScript
 - Modern UI with Tailwind CSS
 - Performance optimizations with Next.js
 - Custom notification system for user feedback
 
The architecture is scalable and maintainable, making it easy to add new features like user authentication, payment processing, and backend integration.
π Useful Resources
π Next Steps
- Add user authentication
 - Implement payment processing
 - Create an admin dashboard
 - Add search functionality
 - Implement reviews and ratings
 
              
    
Top comments (0)