Next.js
E-commerce with Next.js
E-commerce with Next.js
Building an e-commerce application with Next.js combines the framework's performance benefits with modern shopping experiences. This lesson covers implementing product catalogs, shopping carts, checkout flows, and payment integration.
E-commerce Architecture Overview
A modern Next.js e-commerce application typically includes:
- Product Catalog: Browse and search products with filtering and pagination
- Product Details: Rich product pages with images, descriptions, variants
- Shopping Cart: Add/remove items, update quantities, persist cart state
- Checkout Flow: Multi-step checkout with address, shipping, and payment
- Payment Processing: Secure payment integration with Stripe or similar
- Order Management: Order history, tracking, and status updates
- User Accounts: Authentication, profiles, saved addresses
Database Schema for E-commerce
Prisma schema example:
model Product {
id String @id @default(cuid())
name String
slug String @unique
description String
price Float
compareAtPrice Float?
images String[]
category Category @relation(fields: [categoryId], references: [id])
categoryId String
variants ProductVariant[]
inventory Int @default(0)
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProductVariant {
id String @id @default(cuid())
product Product @relation(fields: [productId], references: [id])
productId String
name String
sku String @unique
price Float
inventory Int @default(0)
options Json // { size: "M", color: "Blue" }
}
model Category {
id String @id @default(cuid())
name String
slug String @unique
description String?
products Product[]
parent Category? @relation("CategoryToCategory", fields: [parentId], references: [id])
parentId String?
children Category[] @relation("CategoryToCategory")
}
model Cart {
id String @id @default(cuid())
userId String?
items CartItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CartItem {
id String @id @default(cuid())
cart Cart @relation(fields: [cartId], references: [id])
cartId String
productId String
variantId String?
quantity Int
price Float
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
user User @relation(fields: [userId], references: [id])
userId String
items OrderItem[]
subtotal Float
tax Float
shipping Float
total Float
status OrderStatus @default(PENDING)
shippingAddress Json
billingAddress Json
paymentIntentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
model OrderItem {
id String @id @default(cuid())
order Order @relation(fields: [orderId], references: [id])
orderId String
productId String
variantId String?
quantity Int
price Float
name String
}
Product Catalog Implementation
app/products/page.tsx:
import { prisma } from '@/lib/prisma';
import ProductGrid from '@/components/ProductGrid';
import ProductFilters from '@/components/ProductFilters';
import { Suspense } from 'react';
export default async function ProductsPage({
searchParams,
}: {
searchParams: {
category?: string;
sort?: string;
minPrice?: string;
maxPrice?: string;
page?: string;
};
}) {
const page = parseInt(searchParams.page || '1');
const limit = 12;
const skip = (page - 1) * limit;
const where = {
published: true,
...(searchParams.category && {
category: { slug: searchParams.category },
}),
...(searchParams.minPrice && {
price: { gte: parseFloat(searchParams.minPrice) },
}),
...(searchParams.maxPrice && {
price: { lte: parseFloat(searchParams.maxPrice) },
}),
};
const orderBy = getOrderBy(searchParams.sort);
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
orderBy,
skip,
take: limit,
include: {
category: true,
},
}),
prisma.product.count({ where }),
]);
const totalPages = Math.ceil(total / limit);
return (
<div className="products-page">
<h1>Products</h1>
<div className="products-layout">
<aside className="filters">
<Suspense fallback={<div>Loading filters...</div>}>
<ProductFilters />
</Suspense>
</aside>
<main>
<ProductGrid products={products} />
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
/>
)}
</main>
</div>
</div>
);
}
function getOrderBy(sort?: string) {
switch (sort) {
case 'price-asc':
return { price: 'asc' as const };
case 'price-desc':
return { price: 'desc' as const };
case 'name':
return { name: 'asc' as const };
default:
return { createdAt: 'desc' as const };
}
}
Product Detail Page
app/products/[slug]/page.tsx:
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import ProductImages from '@/components/ProductImages';
import AddToCartButton from '@/components/AddToCartButton';
import ProductVariants from '@/components/ProductVariants';
export async function generateStaticParams() {
const products = await prisma.product.findMany({
where: { published: true },
select: { slug: true },
});
return products.map((product) => ({
slug: product.slug,
}));
}
export async function generateMetadata({ params }: Props) {
const product = await prisma.product.findUnique({
where: { slug: params.slug },
});
if (!product) return {};
return {
title: product.name,
description: product.description,
openGraph: {
images: product.images,
},
};
}
export default async function ProductPage({
params,
}: {
params: { slug: string };
}) {
const product = await prisma.product.findUnique({
where: { slug: params.slug },
include: {
category: true,
variants: true,
},
});
if (!product) {
notFound();
}
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: product.inventory > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="product-page">
<div className="product-grid">
<div className="product-images">
<ProductImages images={product.images} />
</div>
<div className="product-info">
<h1>{product.name}</h1>
<div className="product-price">
{product.compareAtPrice && (
<span className="compare-price">
${product.compareAtPrice}
</span>
)}
<span className="price">${product.price}</span>
</div>
<p className="description">{product.description}</p>
{product.variants.length > 0 && (
<ProductVariants variants={product.variants} />
)}
<AddToCartButton
productId={product.id}
name={product.name}
price={product.price}
image={product.images[0]}
inStock={product.inventory > 0}
/>
<div className="product-meta">
<p>Category: {product.category.name}</p>
<p>
Availability:{" "}
{product.inventory > 0 ? 'In Stock' : 'Out of Stock'}
</p>
</div>
</div>
</div>
</div>
</>
);
}
Shopping Cart Implementation
lib/cart.ts (Zustand store):
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CartItem {
id: string;
productId: string;
variantId?: string;
name: string;
price: number;
quantity: number;
image: string;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
getTotal: () => number;
getItemCount: () => number;
}
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (newItem) =>
set((state) => {
const existingItem = state.items.find(
(item) => item.id === newItem.id
);
if (existingItem) {
return {
items: state.items.map((item) =>
item.id === newItem.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return {
items: [...state.items, { ...newItem, quantity: 1 }],
};
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
})),
clearCart: () => set({ items: [] }),
getTotal: () => {
return get().items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
getItemCount: () => {
return get().items.reduce((count, item) => count + item.quantity, 0);
},
}),
{
name: 'shopping-cart',
}
)
);
components/AddToCartButton.tsx:
'use client';
import { useCart } from '@/lib/cart';
import { useState } from 'react';
interface Props {
productId: string;
name: string;
price: number;
image: string;
inStock: boolean;
}
export default function AddToCartButton({
productId,
name,
price,
image,
inStock,
}: Props) {
const { addItem } = useCart();
const [added, setAdded] = useState(false);
const handleAddToCart = () => {
addItem({
id: productId,
productId,
name,
price,
image,
});
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};
return (
<button
onClick={handleAddToCart}
disabled={!inStock}
className="add-to-cart-button"
>
{added ? 'Added to Cart!' : inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
);
}
app/cart/page.tsx:
'use client';
import { useCart } from '@/lib/cart';
import Link from 'next/link';
import Image from 'next/image';
export default function CartPage() {
const { items, removeItem, updateQuantity, getTotal } = useCart();
if (items.length === 0) {
return (
<div className="empty-cart">
<h1>Your cart is empty</h1>
<Link href="/products">Continue Shopping</Link>
</div>
);
}
return (
<div className="cart-page">
<h1>Shopping Cart</h1>
<div className="cart-items">
{items.map((item) => (
<div key={item.id} className="cart-item">
<Image
src={item.image}
alt={item.name}
width={100}
height={100}
/>
<div className="item-details">
<h3>{item.name}</h3>
<p>${item.price}</p>
</div>
<div className="item-quantity">
<button
onClick={() =>
updateQuantity(item.id, Math.max(1, item.quantity - 1))
}
>
-
</button>
<span>{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
>
+
</button>
</div>
<div className="item-total">
${(item.price * item.quantity).toFixed(2)}
</div>
<button
onClick={() => removeItem(item.id)}
className="remove-button"
>
Remove
</button>
</div>
))}
</div>
<div className="cart-summary">
<h2>Order Summary</h2>
<div className="summary-row">
<span>Subtotal:</span>
<span>${getTotal().toFixed(2)}</span>
</div>
<div className="summary-row">
<span>Shipping:</span>
<span>Calculated at checkout</span>
</div>
<div className="summary-total">
<span>Total:</span>
<span>${getTotal().toFixed(2)}</span>
</div>
<Link href="/checkout" className="checkout-button">
Proceed to Checkout
</Link>
</div>
</div>
);
}
Stripe Payment Integration
Install Stripe:
npm install stripe @stripe/stripe-js
lib/stripe.ts:
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-01-01',
});
app/api/checkout/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getServerSession } from 'next-auth';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { items } = await request.json();
// Create Stripe checkout session
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'payment',
customer_email: session.user.email,
line_items: items.map((item: any) => ({
price_data: {
currency: 'usd',
product_data: {
name: item.name,
images: [item.image],
},
unit_amount: Math.round(item.price * 100),
},
quantity: item.quantity,
})),
success_url: \`${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}\`,
cancel_url: \`${process.env.NEXT_PUBLIC_URL}/cart\`,
metadata: {
userId: session.user.id,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}
app/checkout/page.tsx:
'use client';
import { useCart } from '@/lib/cart';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function CheckoutPage() {
const { items, getTotal } = useCart();
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleCheckout = async () => {
setLoading(true);
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
const { url } = await response.json();
if (url) {
router.push(url);
}
} catch (error) {
console.error('Checkout error:', error);
} finally {
setLoading(false);
}
};
return (
<div className="checkout-page">
<h1>Checkout</h1>
<div className="checkout-summary">
<h2>Order Summary</h2>
{items.map((item) => (
<div key={item.id} className="checkout-item">
<span>{item.name} x {item.quantity}</span>
<span>${(item.price * item.quantity).toFixed(2)}</span>
</div>
))}
<div className="checkout-total">
<span>Total:</span>
<span>${getTotal().toFixed(2)}</span>
</div>
<button
onClick={handleCheckout}
disabled={loading}
className="checkout-button"
>
{loading ? 'Processing...' : 'Pay with Stripe'}
</button>
</div>
</div>
);
}
Webhook for Order Processing
app/api/webhooks/stripe/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
// Create order in database
await prisma.order.create({
data: {
orderNumber: \`ORD-${Date.now()}\`,
userId: session.metadata?.userId!,
total: session.amount_total! / 100,
paymentIntentId: session.payment_intent as string,
status: 'PROCESSING',
// ... other order data
},
});
}
return NextResponse.json({ received: true });
}
Exercise:
- Set up a product database schema with Prisma
- Create a product catalog with filtering and sorting
- Implement a shopping cart with Zustand
- Build a product detail page with variants
- Integrate Stripe for payment processing
- Set up webhooks for order fulfillment
- Add order history and tracking features
E-commerce Best Practices:
- Use proper database indexes for product queries
- Implement inventory management and stock checks
- Add product search with fuzzy matching
- Optimize images for different screen sizes
- Implement proper error handling for payments
- Add loading states for better UX
- Use webhooks for reliable order processing
- Implement proper security for payment flows