March 27, 202614 min read

Build an E-Commerce Store with Next.js: Products, Cart, and Checkout

Build a full e-commerce store with Next.js App Router -- product pages, shopping cart, Stripe checkout, SEO optimization, and deployment.

nextjs ecommerce react stripe tutorial
Ad 336x280

E-commerce is one of those projects that sounds simple until you start building it. Show products, add to cart, check out. Three steps. But each step has real complexity: static generation for SEO, persistent cart state, secure payment processing, inventory management, order confirmation flows.

We're going to build a functional e-commerce store with Next.js App Router, using static site generation for product pages, React Context with localStorage for the shopping cart, and Stripe for checkout. Not a toy demo -- something you could actually deploy and sell through.

Project Setup

npx create-next-app@latest my-store --typescript --tailwind --app --src-dir
cd my-store
npm install stripe @stripe/stripe-js

Project structure we're building toward:

src/
  app/
    layout.tsx
    page.tsx
    products/
      page.tsx
      [slug]/
        page.tsx
    cart/
      page.tsx
    checkout/
      success/
        page.tsx
    api/
      checkout/
        route.ts
  components/
    product-card.tsx
    cart-provider.tsx
    cart-icon.tsx
    add-to-cart-button.tsx
  lib/
    products.ts
    stripe.ts

Product Data

For a real store, you'd pull from a CMS or database. We'll use a local data file that mirrors what you'd get from an API:

// src/lib/products.ts
export interface Product {
  id: string;
  slug: string;
  name: string;
  description: string;
  price: number; // in cents
  image: string;
  category: string;
  featured: boolean;
}

const products: Product[] = [
{
id: "prod_001",
slug: "minimal-desk-lamp",
name: "Minimal Desk Lamp",
description:
"A clean, adjustable LED desk lamp with three brightness levels and a weighted base that won't tip over. USB-C powered.",
price: 4999,
image: "/images/products/desk-lamp.jpg",
category: "lighting",
featured: true,
},
{
id: "prod_002",
slug: "mechanical-keyboard",
name: "Tactile Mechanical Keyboard",
description:
"65% layout with brown switches, PBT keycaps, and USB-C connection. Quiet enough for an office, satisfying enough for home.",
price: 8999,
image: "/images/products/keyboard.jpg",
category: "peripherals",
featured: true,
},
{
id: "prod_003",
slug: "wireless-mouse",
name: "Ergonomic Wireless Mouse",
description:
"Vertical ergonomic design reduces wrist strain. 4000 DPI sensor, Bluetooth and USB receiver, 90-day battery life.",
price: 3499,
image: "/images/products/mouse.jpg",
category: "peripherals",
featured: false,
},
{
id: "prod_004",
slug: "monitor-stand",
name: "Walnut Monitor Stand",
description:
"Solid walnut wood monitor riser with cable management shelf. Fits monitors up to 32 inches. Raises screen to eye level.",
price: 7999,
image: "/images/products/monitor-stand.jpg",
category: "furniture",
featured: true,
},
{
id: "prod_005",
slug: "usb-c-hub",
name: "7-in-1 USB-C Hub",
description:
"HDMI 4K@60Hz, 2x USB-A 3.0, USB-C PD 100W, SD card reader, ethernet. Aluminum body with braided cable.",
price: 4499,
image: "/images/products/usb-hub.jpg",
category: "peripherals",
featured: false,
},
{
id: "prod_006",
slug: "cable-organizer",
name: "Magnetic Cable Organizer",
description:
"Set of 5 magnetic cable clips that stick to your desk. Keeps charging cables, headphones, and USB cables tidy.",
price: 1499,
image: "/images/products/cable-organizer.jpg",
category: "accessories",
featured: false,
},
];

export function getAllProducts(): Product[] {
return products;
}

export function getProductBySlug(slug: string): Product | undefined {
return products.find((p) => p.slug === slug);
}

export function getFeaturedProducts(): Product[] {
return products.filter((p) => p.featured);
}

export function getProductsByCategory(category: string): Product[] {
return products.filter((p) => p.category === category);
}

export function formatPrice(priceInCents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(priceInCents / 100);
}

Prices are in cents to avoid floating-point issues. Always store currency as integers. $49.99 is stored as 4999. Format it for display with Intl.NumberFormat.

Product Listing Page with SSG

// src/app/products/page.tsx
import { getAllProducts, formatPrice } from "@/lib/products";
import ProductCard from "@/components/product-card";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "All Products | My Store",
description: "Browse our collection of minimal, functional desk accessories.",
};

export default function ProductsPage() {
const products = getAllProducts();

return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">All Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}

// src/components/product-card.tsx
import Link from "next/link";
import Image from "next/image";
import { Product, formatPrice } from "@/lib/products";

export default function ProductCard({ product }: { product: Product }) {
return (
<Link
href={/products/${product.slug}}
className="group block border rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-square bg-gray-100 relative">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover group-hover:scale-105 transition-transform"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
</div>
<div className="p-4">
<h2 className="font-semibold text-lg">{product.name}</h2>
<p className="text-gray-600 text-sm mt-1 line-clamp-2">
{product.description}
</p>
<p className="text-lg font-bold mt-2">{formatPrice(product.price)}</p>
</div>
</Link>
);
}

This is a Server Component. No JavaScript ships to the client for the product listing -- it's pure HTML. The page is statically generated at build time, which means instant load times and great SEO.

Dynamic Product Pages

// src/app/products/[slug]/page.tsx
import { getProductBySlug, getAllProducts, formatPrice } from "@/lib/products";
import AddToCartButton from "@/components/add-to-cart-button";
import Image from "next/image";
import { notFound } from "next/navigation";
import { Metadata } from "next";

interface Props {
params: Promise<{ slug: string }>;
}

export async function generateStaticParams() {
const products = getAllProducts();
return products.map((product) => ({
slug: product.slug,
}));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) return { title: "Product Not Found" };

return {
title: ${product.name} | My Store,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.image],
},
};
}

export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = getProductBySlug(slug);

if (!product) {
notFound();
}

return (
<div className="max-w-6xl mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="aspect-square bg-gray-100 relative rounded-lg overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>

<div>
<p className="text-sm text-gray-500 uppercase tracking-wide">
{product.category}
</p>
<h1 className="text-3xl font-bold mt-2">{product.name}</h1>
<p className="text-2xl font-semibold mt-4">
{formatPrice(product.price)}
</p>
<p className="text-gray-700 mt-6 leading-relaxed">
{product.description}
</p>

<div className="mt-8">
<AddToCartButton product={product} />
</div>
</div>
</div>
</div>
);
}

generateStaticParams tells Next.js to pre-render a page for every product at build time. The result: static HTML files for every product page. Fast, cacheable, and SEO-friendly. generateMetadata creates unique title, description, and Open Graph tags for each product. This is critical for SEO and social media sharing.

Shopping Cart with Context and localStorage

The cart needs to work across pages, survive page refreshes, and not require a backend. React Context plus localStorage handles this:

// src/components/cart-provider.tsx
"use client";

import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { Product } from "@/lib/products";

export interface CartItem {
product: Product;
quantity: number;
}

interface CartContextType {
items: CartItem[];
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
totalItems: number;
totalPrice: number;
}

const CartContext = createContext<CartContextType | undefined>(undefined);

export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const [loaded, setLoaded] = useState(false);

// Load cart from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem("cart");
if (saved) {
try {
setItems(JSON.parse(saved));
} catch {
localStorage.removeItem("cart");
}
}
setLoaded(true);
}, []);

// Save cart to localStorage on change
useEffect(() => {
if (loaded) {
localStorage.setItem("cart", JSON.stringify(items));
}
}, [items, loaded]);

const addItem = useCallback((product: Product) => {
setItems((prev) => {
const existing = prev.find((item) => item.product.id === product.id);
if (existing) {
return prev.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { product, quantity: 1 }];
});
}, []);

const removeItem = useCallback((productId: string) => {
setItems((prev) => prev.filter((item) => item.product.id !== productId));
}, []);

const updateQuantity = useCallback((productId: string, quantity: number) => {
if (quantity <= 0) {
removeItem(productId);
return;
}
setItems((prev) =>
prev.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
)
);
}, [removeItem]);

const clearCart = useCallback(() => {
setItems([]);
}, []);

const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);

return (
<CartContext.Provider
value={{
items,
addItem,
removeItem,
updateQuantity,
clearCart,
totalItems,
totalPrice,
}}
>
{children}
</CartContext.Provider>
);
}

export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart must be used within a CartProvider");
}
return context;
}

Wrap your layout with the provider:

// src/app/layout.tsx
import { CartProvider } from "@/components/cart-provider";

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<CartProvider>
<nav className="border-b px-4 py-3 flex justify-between items-center max-w-7xl mx-auto">
<a href="/" className="font-bold text-xl">My Store</a>
<div className="flex gap-6 items-center">
<a href="/products">Products</a>
<CartIcon />
</div>
</nav>
{children}
</CartProvider>
</body>
</html>
);
}

The Add to Cart button:

// src/components/add-to-cart-button.tsx
"use client";

import { useState } from "react";
import { useCart } from "@/components/cart-provider";
import { Product } from "@/lib/products";

export default function AddToCartButton({ product }: { product: Product }) {
const { addItem } = useCart();
const [added, setAdded] = useState(false);

const handleClick = () => {
addItem(product);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};

return (
<button
onClick={handleClick}
className={px-6 py-3 rounded-lg font-semibold transition-colors ${
added
? "bg-green-600 text-white"
: "bg-black text-white hover:bg-gray-800"
}
}
>
{added ? "Added to Cart" : "Add to Cart"}
</button>
);
}

Cart Page

// src/app/cart/page.tsx
"use client";

import { useCart } from "@/components/cart-provider";
import { formatPrice } from "@/lib/products";
import Image from "next/image";
import Link from "next/link";

export default function CartPage() {
const { items, removeItem, updateQuantity, totalPrice } = useCart();

if (items.length === 0) {
return (
<div className="max-w-4xl mx-auto px-4 py-12 text-center">
<h1 className="text-2xl font-bold">Your cart is empty</h1>
<p className="text-gray-600 mt-2">Add some products to get started.</p>
<Link
href="/products"
className="inline-block mt-6 px-6 py-3 bg-black text-white rounded-lg"
>
Browse Products
</Link>
</div>
);
}

return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-2xl font-bold mb-8">Shopping Cart</h1>

<div className="space-y-6">
{items.map((item) => (
<div
key={item.product.id}
className="flex gap-4 items-center border-b pb-6"
>
<div className="w-24 h-24 bg-gray-100 relative rounded overflow-hidden flex-shrink-0">
<Image
src={item.product.image}
alt={item.product.name}
fill
className="object-cover"
sizes="96px"
/>
</div>

<div className="flex-grow">
<h2 className="font-semibold">{item.product.name}</h2>
<p className="text-gray-600">
{formatPrice(item.product.price)}
</p>
</div>

<div className="flex items-center gap-2">
<button
onClick={() =>
updateQuantity(item.product.id, item.quantity - 1)
}
className="w-8 h-8 border rounded flex items-center justify-center"
>
-
</button>
<span className="w-8 text-center">{item.quantity}</span>
<button
onClick={() =>
updateQuantity(item.product.id, item.quantity + 1)
}
className="w-8 h-8 border rounded flex items-center justify-center"
>
+
</button>
</div>

<p className="font-semibold w-24 text-right">
{formatPrice(item.product.price * item.quantity)}
</p>

<button
onClick={() => removeItem(item.product.id)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</div>
))}
</div>

<div className="mt-8 border-t pt-6 flex justify-between items-center">
<p className="text-xl font-bold">Total: {formatPrice(totalPrice)}</p>
<button
onClick={handleCheckout}
className="px-8 py-3 bg-black text-white rounded-lg font-semibold hover:bg-gray-800"
>
Proceed to Checkout
</button>
</div>
</div>
);
}

async function handleCheckout() {
// We'll implement this next
}

Stripe Checkout Integration

Stripe Checkout handles the entire payment form -- card input, validation, 3D Secure, receipts. You redirect to Stripe, they handle the payment, and redirect back.

Set up Stripe:

// src/lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
});

Create an API route that generates a Stripe Checkout session:

// src/app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

interface CartItem {
productId: string;
name: string;
price: number;
quantity: number;
image: string;
}

export async function POST(request: Request) {
try {
const { items } = (await request.json()) as { items: CartItem[] };

if (!items || items.length === 0) {
return NextResponse.json(
{ error: "No items in cart" },
{ status: 400 }
);
}

const lineItems = items.map((item) => ({
price_data: {
currency: "usd",
product_data: {
name: item.name,
images: [
${process.env.NEXT_PUBLIC_BASE_URL}${item.image},
],
},
unit_amount: item.price, // already in cents
},
quantity: item.quantity,
}));

const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: lineItems,
success_url: ${process.env.NEXT_PUBLIC_BASE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID},
cancel_url: ${process.env.NEXT_PUBLIC_BASE_URL}/cart,
shipping_address_collection: {
allowed_countries: ["US", "CA", "GB"],
},
});

return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Stripe checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}

Now wire up the checkout button in the cart page:

// Update the handleCheckout function in cart/page.tsx
async function handleCheckout() {
  const { items } = useCart(); // This won't work inside a regular function

// Instead, make handleCheckout part of the component:
}

Actually, let's restructure the cart page properly since handleCheckout needs access to the cart state:

// Inside the CartPage component, replace the handleCheckout function:
const handleCheckout = async () => {
  const response = await fetch("/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      items: items.map((item) => ({
        productId: item.product.id,
        name: item.product.name,
        price: item.product.price,
        quantity: item.quantity,
        image: item.product.image,
      })),
    }),
  });

const { url } = await response.json();
if (url) {
window.location.href = url;
}
};

Order Confirmation Page

After successful payment, Stripe redirects to your success URL:

// src/app/checkout/success/page.tsx
import Link from "next/link";

export default function CheckoutSuccess({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
return (
<div className="max-w-2xl mx-auto px-4 py-16 text-center">
<div className="text-5xl mb-4">&#10003;</div>
<h1 className="text-3xl font-bold">Order Confirmed</h1>
<p className="text-gray-600 mt-4">
Thank you for your purchase. You'll receive a confirmation email
shortly with your order details and tracking information.
</p>
<Link
href="/products"
className="inline-block mt-8 px-6 py-3 bg-black text-white rounded-lg"
>
Continue Shopping
</Link>
</div>
);
}

In a production app, you'd use the session_id to fetch order details from Stripe and display them. You'd also set up a Stripe webhook to handle post-payment events (updating inventory, sending emails, etc.) rather than relying on the redirect.

SEO Optimization

Next.js gives you tools for SEO. Use them:

Metadata for every page:
// src/app/layout.tsx
import { Metadata } from "next";

export const metadata: Metadata = {
title: {
default: "My Store | Minimal Desk Accessories",
template: "%s | My Store",
},
description: "Curated collection of minimal, functional desk accessories.",
openGraph: {
type: "website",
locale: "en_US",
siteName: "My Store",
},
};

Structured data for products (JSON-LD):
// In the product page component
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify({
      "@context": "https://schema.org",
      "@type": "Product",
      name: product.name,
      description: product.description,
      image: product.image,
      offers: {
        "@type": "Offer",
        price: (product.price / 100).toFixed(2),
        priceCurrency: "USD",
        availability: "https://schema.org/InStock",
      },
    }),
  }}
/>
Sitemap:
// src/app/sitemap.ts
import { getAllProducts } from "@/lib/products";
import { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
const products = getAllProducts();

const productUrls = products.map((product) => ({
url: https://mystore.com/products/${product.slug},
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.8,
}));

return [
{
url: "https://mystore.com",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
{
url: "https://mystore.com/products",
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.9,
},
...productUrls,
];
}

Environment Variables

Create a .env.local file:

STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_BASE_URL=http://localhost:3000

For production, set these in your hosting provider's dashboard (Vercel, Cloudflare, etc.).

Common Mistakes

Storing prices as floats. 0.1 + 0.2 !== 0.3 in JavaScript. Always use integers (cents) for money. Format for display only at the last moment. Trusting client-side cart data for prices. The cart stores product IDs and quantities. The server should look up current prices when creating the Stripe session. Never trust a price sent from the browser. Not handling Stripe webhooks. The redirect to your success page is not reliable -- the user could close the browser. Use Stripe webhooks to handle checkout.session.completed events for critical post-payment logic like updating inventory and sending confirmation emails. Skipping generateStaticParams. Without it, product pages are rendered on demand instead of at build time. For an e-commerce site, build-time rendering means faster page loads and better SEO. Not optimizing images. Use Next.js Image component with proper sizes and priority attributes. Serve product images in WebP format. Lazy load below-the-fold images.

What's Next

This is a foundation. A production e-commerce store would also need:

  • Stripe webhooks for reliable post-payment processing
  • Inventory management to prevent overselling
  • User accounts for order history
  • Search and filtering for the product catalog
  • Reviews and ratings for social proof
  • Email notifications for order updates
  • Admin dashboard for managing products
Each of these is its own project. The architecture we've built here -- static product pages, client-side cart, server-side checkout -- scales well as you add features.

For more Next.js and React tutorials, check out CodeUp.

Ad 728x90