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)