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)