DEV Community

Cover image for Building a Modern E-commerce App with Next.js 15, Zustand, and TypeScript
A0mineTV
A0mineTV

Posted on

Building a Modern E-commerce App with Next.js 15, Zustand, and TypeScript

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

  1. Project Setup
  2. State Management with Zustand
  3. Building the UI Components
  4. Custom Hooks and Utilities
  5. Responsive Design
  6. Performance Optimizations
  7. 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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
Enter fullscreen mode Exit fullscreen mode

πŸͺ 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),
    }
  )
)
Enter fullscreen mode Exit fullscreen mode

Key Features of Our Store:

  1. Persistent State: Data survives page refreshes
  2. Immutable Updates: Clean state mutations
  3. Computed Values: Automatic totals calculation
  4. 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

πŸ”§ 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' }),
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ“± 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
/>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Authentication System

    • User login/registration
    • Protected routes
    • User profiles
  2. Payment Integration

    • Stripe/PayPal integration
    • Checkout process
    • Order management
  3. Backend Integration

    • API routes
    • Database integration
    • Inventory management
  4. Search & Filtering

    • Product search
    • Category filters
    • Price ranges
  5. 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

  1. Add user authentication
  2. Implement payment processing
  3. Create an admin dashboard
  4. Add search functionality
  5. Implement reviews and ratings

Top comments (0)