9. Guided Project 2: An E-commerce Product Listing
This project will guide you through building a responsive e-commerce product listing application with Next.js. We’ll focus on displaying a catalog of products, implementing filtering and search capabilities, and creating individual product detail pages. This project will reinforce your understanding of data fetching, dynamic routes, and building interactive UI components in Next.js.
Project Objective: Create an e-commerce-style product listing application where:
- Users can view a grid of products.
- Users can filter products by category.
- Users can search for products by name.
- Each product has a dedicated detail page.
- The application is responsive and user-friendly.
Technology Stack:
- Next.js (App Router, Server Components)
- React
- Tailwind CSS (or your preferred styling method)
- (Simulated) Product Data Source
Step 1: Prepare Product Data Source
We’ll use a local data source for our products. For a real e-commerce application, this data would come from a database (e.g., using Prisma, as covered in Chapter 7) or an external e-commerce API.
1.1 Create Product Data File
Action: Create a new file src/lib/productsData.ts.
touch src/lib/productsData.ts
// src/lib/productsData.ts
export interface Product {
id: string;
name: string;
description: string;
price: number;
category: 'electronics' | 'clothing' | 'home' | 'books';
imageUrl: string;
rating: number;
stock: number;
slug: string; // Unique identifier for URLs
}
const products: Product[] = [
{
id: 'p001',
name: 'Wireless Bluetooth Headphones',
description: 'Immerse yourself in high-quality sound with these comfortable, noise-cancelling wireless headphones. Perfect for travel and daily commutes.',
price: 129.99,
category: 'electronics',
imageUrl: 'https://images.unsplash.com/photo-1505740420928-5e560c06f2e0?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
rating: 4.5,
stock: 50,
slug: 'wireless-bluetooth-headphones',
},
{
id: 'p002',
name: 'Smartwatch with Heart Rate Monitor',
description: 'Stay connected and track your fitness goals with this stylish smartwatch. Features include heart rate monitoring, step tracking, and notifications.',
price: 89.99,
category: 'electronics',
imageUrl: 'https://images.unsplash.com/photo-1546868871-7041f2a55e12?q=80&w=2920&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
rating: 4.2,
stock: 120,
slug: 'smartwatch-heart-rate-monitor',
},
{
id: 'p003',
name: 'Organic Cotton T-Shirt',
description: 'Soft and breathable organic cotton t-shirt for everyday comfort. Available in multiple colors.',
price: 24.99,
category: 'clothing',
imageUrl: 'https://images.unsplash.com/photo-1521572178477-f58c519d08e2?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
rating: 4.8,
stock: 200,
slug: 'organic-cotton-t-shirt',
},
{
id: 'p004',
name: 'Wool Blend Sweater',
description: 'Cozy and warm sweater made from a premium wool blend. Ideal for colder seasons.',
price: 69.99,
category: 'clothing',
imageUrl: 'https://images.unsplash.com/photo-1579750017424-df3c5c9a0665?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
rating: 4.6,
stock: 80,
slug: 'wool-blend-sweater',
},
{
id: 'p005',
name: 'Modern Abstract Wall Art',
description: 'Add a touch of contemporary elegance to your living space with this striking abstract wall art piece.',
price: 79.00,
category: 'home',
imageUrl: 'https://images.unsplash.com/photo-1588661759654-e7b8a53e5d3b?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
rating: 4.3,
stock: 30,
slug: 'modern-abstract-wall-art',
},
{
id: 'p006',
name: 'The Great Gatsby (Paperback)',
description: 'F. Scott Fitzgerald\'s classic novel, a vivid portrayal of the Jazz Age.',
price: 12.50,
category: 'books',
imageUrl: 'https://images.unsplash.com/photo-1543002565-d0c399b66282?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
rating: 4.7,
stock: 150,
slug: 'the-great-gatsby-paperback',
},
];
export type Category = Product['category'];
export async function getProducts(
category?: Category,
search?: string
): Promise<Product[]> {
await new Promise(resolve => setTimeout(resolve, 300)); // Simulate API delay
let filteredProducts = products;
if (category && category !== 'all') {
filteredProducts = filteredProducts.filter(p => p.category === category);
}
if (search) {
const lowerSearch = search.toLowerCase();
filteredProducts = filteredProducts.filter(
p => p.name.toLowerCase().includes(lowerSearch) || p.description.toLowerCase().includes(lowerSearch)
);
}
return filteredProducts;
}
export async function getProductBySlug(slug: string): Promise<Product | undefined> {
await new Promise(resolve => setTimeout(resolve, 200));
return products.find(p => p.slug === slug);
}
export async function getAllProductSlugs(): Promise<string[]> {
await new Promise(resolve => setTimeout(resolve, 100));
return products.map(p => p.slug);
}
export async function getAllCategories(): Promise<Category[]> {
await new Promise(resolve => setTimeout(resolve, 50));
const categories = Array.from(new Set(products.map(p => p.category)));
return categories.sort();
}
1.2 Configure next.config.mjs for Images
Ensure images.remotePatterns includes unsplash.com if you haven’t already. (As done in Project 1 / Chapter 6)
Action: Open next.config.mjs and ensure images.remotePatterns includes https://images.unsplash.com. You might need to restart your development server.
Step 2: Create Product Listing Page (/shop)
We’ll create a new route for our shop, where products will be displayed in a grid, and users can interact with filters and search.
2.1 Create Shop Page (src/app/shop/page.tsx)
This will be a Server Component that fetches products based on search parameters.
Action: Create a new folder src/app/shop and a file src/app/shop/page.tsx.
mkdir src/app/shop
touch src/app/shop/page.tsx
// src/app/shop/page.tsx
import { getProducts, getAllCategories, Category, Product } from '@/lib/productsData';
import Link from 'next/link';
import Image from 'next/image';
import { Suspense } from 'react';
import ProductFilters from './components/ProductFilters'; // We'll create this next
import ProductGrid from './components/ProductGrid'; // We'll create this soon
import ProductsLoading from './loading'; // Use a dedicated loading state
export const metadata = {
title: 'Shop | Our E-commerce Store',
description: 'Browse our wide range of products across electronics, clothing, home goods, and books.',
};
interface ShopPageProps {
searchParams: {
category?: Category;
search?: string;
};
}
export default async function ShopPage({ searchParams }: ShopPageProps) {
// Extract search parameters from the URL
const selectedCategory = searchParams.category;
const searchTerm = searchParams.search;
// Fetch all categories for the filter component
const categories = await getAllCategories();
const allCategoryOption: Category | 'all' = 'all'; // Default option
return (
<div className="container mx-auto p-6 max-w-6xl font-sans text-gray-800">
<h1 className="text-4xl font-extrabold text-gray-900 mb-8 text-center">Our Product Catalog</h1>
{/* Product Filters (Client Component) */}
<ProductFilters
categories={['all', ...categories]}
selectedCategory={selectedCategory || 'all'}
searchTerm={searchTerm || ''}
/>
{/* Product Grid (Server Component, wrapped in Suspense) */}
<Suspense fallback={<ProductsLoading />}>
{/* Pass the search parameters to the ProductGrid which will do the actual fetching */}
<ProductGrid
category={selectedCategory}
search={searchTerm}
/>
</Suspense>
<div className="mt-12 text-center">
<Link href="/" className="text-gray-600 hover:text-gray-800 font-medium text-lg">← Back to Home</Link>
</div>
</div>
);
}
2.2 Create Product Loading State (src/app/shop/loading.tsx)
Action: Create src/app/shop/loading.tsx.
touch src/app/shop/loading.tsx
// src/app/shop/loading.tsx
export default function ProductsLoading() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-8">
{[...Array(8)].map((_, i) => (
<div key={i} className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="w-full h-48 bg-gray-200"></div> {/* Image skeleton */}
<div className="p-4">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div> {/* Title skeleton */}
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div> {/* Price skeleton */}
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div> {/* Desc line 1 */}
<div className="h-4 bg-gray-200 rounded w-5/6"></div> {/* Desc line 2 */}
</div>
</div>
))}
</div>
);
}
Step 3: Create Product Filter and Grid Components
We need a client component for the filters (since they involve user interaction) and a server component for the product grid (since it fetches data).
3.1 Create Product Filters Client Component (src/app/shop/components/ProductFilters.tsx)
Action: Create src/app/shop/components/ProductFilters.tsx.
mkdir src/app/shop/components
touch src/app/shop/components/ProductFilters.tsx
// src/app/shop/components/ProductFilters.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Category } from '@/lib/productsData';
import { FaSearch, FaFilter, FaTimes } from 'react-icons/fa'; // Install react-icons if not done
interface ProductFiltersProps {
categories: (Category | 'all')[];
selectedCategory: Category | 'all';
searchTerm: string;
}
export default function ProductFilters({ categories, selectedCategory, searchTerm }: ProductFiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [currentCategory, setCurrentCategory] = useState<Category | 'all'>(selectedCategory);
const [currentSearchTerm, setCurrentSearchTerm] = useState<string>(searchTerm);
const searchInputRef = useRef<HTMLInputElement>(null);
// Sync internal state with URL search params when component mounts or search params change
useEffect(() => {
setCurrentCategory(searchParams.get('category') as Category || 'all');
setCurrentSearchTerm(searchParams.get('search') || '');
}, [searchParams]);
const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newCategory = e.target.value as Category | 'all';
setCurrentCategory(newCategory);
updateSearchParams(newCategory, currentSearchTerm);
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentSearchTerm(e.target.value);
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateSearchParams(currentCategory, currentSearchTerm);
};
const handleClearSearch = () => {
setCurrentSearchTerm('');
updateSearchParams(currentCategory, '');
if (searchInputRef.current) {
searchInputRef.current.focus();
}
};
const updateSearchParams = (category: Category | 'all', search: string) => {
const params = new URLSearchParams(searchParams.toString());
if (category && category !== 'all') {
params.set('category', category);
} else {
params.delete('category');
}
if (search) {
params.set('search', search);
} else {
params.delete('search');
}
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex flex-col md:flex-row gap-4 mb-8 p-4 bg-white rounded-lg shadow-md items-center">
{/* Category Filter */}
<div className="flex items-center gap-2 w-full md:w-auto">
<FaFilter className="text-gray-500" />
<select
value={currentCategory}
onChange={handleCategoryChange}
className="flex-grow border border-gray-300 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
</div>
{/* Search Input */}
<form onSubmit={handleSearchSubmit} className="flex-grow flex gap-2 w-full md:w-auto">
<div className="relative flex-grow">
<input
ref={searchInputRef}
type="text"
placeholder="Search products..."
value={currentSearchTerm}
onChange={handleSearchChange}
className="w-full border border-gray-300 rounded-md p-2 pl-10 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
{currentSearchTerm && (
<button
type="button"
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
aria-label="Clear search"
>
<FaTimes />
</button>
)}
</div>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Search
</button>
</form>
</div>
);
}
Action: Install react-icons if you haven’t already: npm install react-icons.
3.2 Create Product Grid Server Component (src/app/shop/components/ProductGrid.tsx)
This component will fetch the products based on the category and search props received from the parent Server Component (ShopPage).
Action: Create src/app/shop/components/ProductGrid.tsx.
touch src/app/shop/components/ProductGrid.tsx
// src/app/shop/components/ProductGrid.tsx
import { getProducts, Category, Product } from '@/lib/productsData';
import Link from 'next/link';
import Image from 'next/image';
import { FaStar } from 'react-icons/fa';
interface ProductGridProps {
category?: Category;
search?: string;
}
export default async function ProductGrid({ category, search }: ProductGridProps) {
// This Server Component fetches data based on props from the URL search params
const products = await getProducts(category, search);
if (products.length === 0) {
return (
<div className="text-center py-10">
<p className="text-xl text-gray-600">No products found matching your criteria.</p>
<p className="text-gray-500 mt-2">Try adjusting your filters or search term.</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-8">
{products.map((product) => (
<Link key={product.id} href={`/shop/${product.slug}`} className="block">
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300 h-full flex flex-col">
<div className="relative w-full h-48">
<Image
src={product.imageUrl}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
style={{ objectFit: 'cover' }}
className="hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="p-4 flex flex-col flex-grow">
<h2 className="text-xl font-semibold text-gray-900 mb-2 line-clamp-2">{product.name}</h2>
<p className="text-gray-600 text-sm mb-3 line-clamp-3 flex-grow">{product.description}</p>
<div className="flex items-center justify-between mt-auto pt-2 border-t border-gray-100">
<span className="text-2xl font-bold text-blue-600">${product.price.toFixed(2)}</span>
<div className="flex items-center text-yellow-500 text-sm">
<FaStar className="mr-1" />
<span>{product.rating.toFixed(1)}</span>
</div>
</div>
</div>
</div>
</Link>
))}
</div>
);
}
Test:
- Ensure
npm run devis running. - Navigate to
http://localhost:3000/shop. - You should see the product grid and filter/search bar.
- Try filtering by category (e.g., “Electronics”) and searching (e.g., “Smartwatch”). Observe how the URL changes and the product grid updates.
- Notice the loading skeleton (
ProductsLoading) briefly appearing when changing filters/search, indicating data fetching is happening.
Step 4: Create Individual Product Detail Page
Each product needs a dedicated page to display its full details.
4.1 Create Dynamic Product Route (src/app/shop/[productSlug]/page.tsx)
Action: Create a new folder src/app/shop/[productSlug] and a file src/app/shop/[productSlug]/page.tsx.
mkdir src/app/shop/[productSlug]
touch src/app/shop/[productSlug]/page.tsx
// src/app/shop/[productSlug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
import { getProductBySlug, getAllProductSlugs } from '@/lib/productsData';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { FaStar, FaShoppingCart, FaCheckCircle, FaTimesCircle } from 'react-icons/fa';
import AddToCartButton from './components/AddToCartButton'; // We'll create this next
interface ProductDetailPageProps {
params: { productSlug: string };
}
// --- Dynamic Metadata Generation ---
export async function generateMetadata(
{ params }: ProductDetailPageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const product = await getProductBySlug(params.productSlug);
if (!product) {
return {
title: 'Product Not Found',
description: 'The requested product could not be found.',
};
}
const previousImages = (await parent).openGraph?.images || [];
return {
title: `${product.name} | Our E-commerce Store`,
description: product.description,
keywords: [product.name.split(' ')[0], product.category, 'e-commerce', 'buy online'],
openGraph: {
title: `${product.name} | Our E-commerce Store`,
description: product.description,
url: `https://yourwebsite.com/shop/${product.slug}`, // Canonical URL
siteName: 'Our E-commerce Store',
images: product.imageUrl ? [{ url: product.imageUrl, width: 1200, height: 630, alt: product.name }] : previousImages,
locale: 'en_US',
type: 'product',
},
twitter: {
card: 'summary_large_image',
title: `${product.name} | Our E-commerce Store`,
description: product.description,
images: product.imageUrl ? [product.imageUrl] : previousImages,
},
};
}
// --- Dynamic Page Content ---
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const product = await getProductBySlug(params.productSlug);
if (!product) {
notFound();
}
return (
<main className="container mx-auto p-6 max-w-4xl bg-white shadow-lg rounded-lg mt-10 mb-10">
<Link href="/shop" className="text-blue-600 hover:text-blue-800 mb-6 inline-block font-medium">← Back to Shop</Link>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Product Image */}
<div className="relative w-full h-80 md:h-96 rounded-lg overflow-hidden shadow-md">
<Image
src={product.imageUrl}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'cover' }}
priority
/>
</div>
{/* Product Details */}
<div className="flex flex-col justify-between">
<div>
<span className="text-sm text-gray-500 uppercase font-semibold">{product.category}</span>
<h1 className="text-4xl font-extrabold text-gray-900 mt-2 mb-4">{product.name}</h1>
<div className="flex items-center text-yellow-500 text-lg mb-4">
<FaStar className="mr-1" />
<span>{product.rating.toFixed(1)} / 5</span>
<span className="text-gray-500 ml-2 text-base">({Math.floor(product.rating * 100)} reviews)</span>
</div>
<p className="text-gray-700 text-lg mb-6 leading-relaxed">{product.description}</p>
</div>
<div className="mt-auto pt-6 border-t border-gray-100">
<p className="text-5xl font-bold text-blue-700 mb-4">${product.price.toFixed(2)}</p>
<div className="flex items-center text-md mb-6">
{product.stock > 0 ? (
<span className="text-green-600 font-semibold flex items-center">
<FaCheckCircle className="mr-2" /> In Stock ({product.stock} available)
</span>
) : (
<span className="text-red-600 font-semibold flex items-center">
<FaTimesCircle className="mr-2" /> Out of Stock
</span>
)}
</div>
{product.stock > 0 && (
<AddToCartButton productId={product.id} productName={product.name} />
)}
</div>
</div>
</div>
</main>
);
}
// Optional: generateStaticParams for SSG and dynamic routes
export async function generateStaticParams() {
const productSlugs = await getAllProductSlugs();
return productSlugs.map((slug) => ({
productSlug: slug,
}));
}
4.2 Create Add to Cart Button (Client Component)
This button will be a client component since it involves user interaction (e.g., adding to a shopping cart or showing a confirmation message).
Action: Create src/app/shop/[productSlug]/components/AddToCartButton.tsx.
mkdir src/app/shop/[productSlug]/components
touch src/app/shop/[productSlug]/components/AddToCartButton.tsx
// src/app/shop/[productSlug]/components/AddToCartButton.tsx
'use client';
import { useState } from 'react';
import { FaShoppingCart } from 'react-icons/fa';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
interface AddToCartButtonProps {
productId: string;
productName: string;
}
export default function AddToCartButton({ productId, productName }: AddToCartButtonProps) {
const { data: session } = useSession();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [added, setAdded] = useState(false);
const handleAddToCart = async () => {
if (!session) {
alert('Please log in to add items to your cart.');
router.push(`/auth/login?callbackUrl=/shop/${productId}`); // Or /shop/${productSlug}
return;
}
setLoading(true);
setAdded(false);
try {
// Simulate adding to cart (e.g., an API call to a backend cart service)
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(`Added product ${productName} (ID: ${productId}) to cart for user ${session.user?.id}`);
setAdded(true);
setTimeout(() => setAdded(false), 3000); // Reset "Added!" message
} catch (error) {
console.error('Error adding to cart:', error);
alert('Failed to add product to cart.');
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleAddToCart}
disabled={loading}
className="flex items-center justify-center gap-2 bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-6 rounded-md text-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span>Adding...</span>
) : added ? (
<span>Added!</span>
) : (
<>
<FaShoppingCart />
<span>Add to Cart</span>
</>
)}
</button>
);
}
Test:
- Ensure
npm run devis running. - Navigate to
http://localhost:3000/shop. - Click on any product card to go to its detail page.
- You should see the full product details, including image, description, price, and stock status.
- Try clicking the “Add to Cart” button. If not logged in, it should prompt you to log in. If logged in, it should show “Adding…” then “Added!”.
Project Summary:
You’ve successfully built a foundational e-commerce product listing application with Next.js, incorporating:
- Dynamic product data fetching in Server Components.
- A client-side filtering and search interface that updates the URL.
- Dynamic routing for individual product detail pages.
- Optimized images with
next/image. - Interactive client components (Add to Cart button, filters).
- Rich metadata for SEO on product pages.
This project reinforces how Next.js blends server-side rendering with client-side interactivity to create performant and user-friendly applications. In a real e-commerce scenario, you would expand this with a shopping cart, checkout process, user accounts, and a real database for product management.