E-Commerce Storefront with Next.js
Build a multi-page storefront with a typed shopping cart in Next.js.
Introduction
30 Second Summary
Every online store you browse has the same core pieces: a product catalog, a way to navigate between pages, and a cart that remembers what you picked. Building these from scratch teaches you how professional frontend applications actually work under the hood.
In this project, you will build a multi-page e-commerce storefront with a working shopping cart, responsive product grid, and shared navigation layout.
What You'll Build
A responsive e-commerce storefront using Next.js for routing, TypeScript for type safety, Tailwind CSS for styling, and shadcn/ui for pre-built accessible components.
By the end of this project, you'll have:
- A Next.js project with file-based routing powering Home, Products, and Cart pages that load without full page refreshes.
- A typed shopping cart using useReducer and useContext with TypeScript discriminated unions to manage add, remove, and update actions.
- A responsive product grid built with shadcn/ui Card components and a shared layout with Navbar and Footer visible on every page.
- Secret Mission: Build a Contact page with a validated form using shadcn/ui form components.
Are there any prerequisites?
You need basic familiarity with React components and props. No Next.js experience is required.
Scaffold the Project
Every storefront starts with a blank canvas. Before you can design pages and build features, you need a project that bundles your framework, styling system, and component library together.
In this step, you will scaffold a complete Next.js project using the shadcn/ui CLI. This single command gives you TypeScript, Tailwind CSS v4, and a component system ready to go.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript, Tailwind CSS v4, and shadcn/ui pre-configured.
- Open the project in VS Code and verify the dev server runs.
- Add the shadcn/ui components you will use throughout the project.
Scaffold your Next.js project with shadcn/ui
The shadcn/ui CLI can create an entire Next.js project in one command. It installs Next.js, configures TypeScript, sets up Tailwind CSS v4, and adds a components.json file that tracks your component configuration.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the project by running this command:
npx shadcn@latest init -t next --name ecommerce-store
What does this command do?
- npx shadcn@latest runs the latest version of the shadcn CLI without installing it globally.
- init tells the CLI to initialize a new project from scratch.
- -t next selects the Next.js template so you get file-based routing and React Server Components.
- --name ecommerce-store sets the project folder name.
What prompts will I see?
The CLI may ask you to pick a base color (such as Neutral, Zinc, or Slate). Choose whichever you prefer. It will then create the Next.js project and install dependencies automatically.
This can take a minute or two depending on your internet speed.
- Wait for the CLI to finish. You should see a success message indicating project initialization is complete.
Seeing an error or the CLI hangs?
- Make sure you are connected to the internet. The CLI downloads the project template from GitHub.
- If it hangs for more than 5 minutes, press Ctrl+C to cancel and run the command again.
- If you see a permissions error on macOS, try prefixing the command with sudo.
help me troubleshoot why the shadcn CLI init command is failing
Open the project and run the dev server
Now that the project exists on your Desktop, you need to open it in VS Code and confirm that the dev server starts successfully.
- Move into the project folder and start the development server by running these commands:
cd ecommerce-store
npm run dev
What does npm run dev do?
This starts the Next.js development server. It compiles your TypeScript and Tailwind CSS on the fly, then serves your app at localhost:3000.
Any changes you save will hot-reload in the browser automatically.
- Open your browser and navigate to http://localhost:3000.
- You should see the default shadcn/ui Next.js template page.
- Open a new terminal window. Keep the dev server running in the current one.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the ecommerce-store folder, then click Open.
Don't see the template page at localhost:3000?
- Check the terminal for error messages. If the port is already in use, stop the other process or try a different port.
- Make sure you ran cd ecommerce-store before npm run dev. Running it from the wrong folder causes a missing package.json error.
help me debug why npm run dev is not starting the Next.js development server
Add the shadcn/ui components
Unlike traditional component libraries that live in node_modules, shadcn/ui places actual component source code in your project. You own the files and can customize them however you want.
- In your new terminal window (not the one running the dev server), navigate to the project folder:
cd ~/Desktop/ecommerce-store
- Add all the shadcn/ui components you will use in this project by running this command:
npx shadcn@latest add button card badge sheet input separator
What does this command do?
The add command downloads the source code for each component and places it in your components/ui/ folder. Each component is a real .tsx file you can open and edit.
You are adding six components: Button, Card, Badge, Sheet (a slide-out panel), Input, and Separator. These are the building blocks for your storefront navigation, product cards, and cart page.
- Confirm the components were installed. In the VS Code sidebar, expand the components/ui/ folder.
- You should see files like button.tsx, card.tsx, badge.tsx, sheet.tsx, input.tsx, and separator.tsx listed there.
Components folder is missing or empty?
- Make sure you ran the add command from inside the ecommerce-store folder (where components.json lives).
- If you ran it from the Desktop, the CLI cannot find your project configuration. Navigate to the project root and try again.
help me figure out why npx shadcn add is not placing components in my project
Why shadcn/ui instead of a traditional component library?
Most UI libraries like Material UI or Chakra ship compiled code hidden in node_modules. When you need to tweak something, you hit a wall.
shadcn/ui copies the component source code directly into your project. You can open button.tsx, read it, edit it, and learn exactly how it works. This teaches you component architecture instead of hiding it behind an abstraction.
Your project is scaffolded and ready to build on. Next up, you will design your component architecture and build the shared layout that wraps every page.
Design Your Component Architecture and Layout
Your scaffold is running and your UI components are installed. Now it is time to think about how all these pieces fit together as a storefront.
Every e-commerce site has UI that repeats on every page (navigation, footer) and UI that is unique to a single page (a hero banner, a product grid, a cart list). Separating these into distinct components makes your app easier to build, test, and maintain.
In this step, get ready to:
- Plan your component architecture and review your site-wide CSS variables.
- Build the Navbar with responsive mobile navigation using shadcn/ui.
- Build the Footer and wire up a shared layout that wraps every page.
Plan your component architecture
Before writing code, it helps to categorize the components your storefront needs. This prevents you from mixing shared layout pieces with page-specific content.
- Shared across all pages: Navbar and Footer. These appear on every route and live in the root layout.
- Page-specific: Hero section (Home), ProductGrid (Products page), CartList (Cart page).
- Reusable building blocks: ProductCard, which you will use on both the Home and Products pages.
What is component architecture?
Component architecture is the plan for how you split your UI into reusable pieces. Shared components go in a top-level components/ folder. Page-specific components stay near the page file that uses them. This keeps your project organized as it grows.
The shadcn/ui CLI already set up your app/globals.css with CSS variables for colors, border radius, and spacing.
- Open app/globals.css in the VS Code Explorer sidebar.
- Scroll through the :root block. You will see variables like --background, --foreground, --primary, and --muted-foreground. These control the site-wide color scheme that all shadcn/ui components consume.
- Leave the defaults for now. You can tweak these values later to rebrand the store.
Customize your theme later
The --primary variable controls button and link accent colors. The --radius variable controls border rounding on cards and inputs. Changing just these two values can transform the look of the entire store.
Build the Navbar component
The Navbar appears at the top of every page. It provides navigation links and a cart icon that shows how many items the user has added. To display the cart count, the Navbar needs to read from a shared cart state.
You will create the cart state management first so the Navbar can reference it immediately.
- Create the folders and files for your types and context by running these commands in your terminal:
mkdir types context
Why create these folders?
The types/ folder holds TypeScript interfaces that describe the shape of your data. The context/ folder holds React context providers that share state across components. Keeping these separate from components/ and app/ makes the project easier to navigate.
- Create types/product.ts and paste the following code:
export interface Product {
id: string;
name: string;
price: number;
image: string;
category: string;
description: string;
}
export interface CartItem {
id: string;
name: string;
price: number;
image: string;
quantity: number;
}
What does this code do?
These interfaces define the shape of your data. Product describes items in your catalog. CartItem describes a product that has been added to the cart, with an additional quantity field.
- Save types/product.ts.
- Create context/CartContext.tsx and paste the following code:
"use client";
import { createContext, useContext, useReducer, ReactNode } from "react";
import { CartItem } from "@/types/product";
interface CartState {
items: CartItem[];
}
type CartAction =
| { type: "ADD_ITEM"; payload: CartItem }
| { type: "REMOVE_ITEM"; payload: { id: string } }
| { type: "UPDATE_QUANTITY"; payload: { id: string; quantity: number } }
| { type: "CLEAR_CART" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find((item) => item.id === action.payload.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { ...action.payload, quantity: 1 }] };
}
case "REMOVE_ITEM":
return {
items: state.items.filter((item) => item.id !== action.payload.id),
};
case "UPDATE_QUANTITY":
return {
items: state.items
.map((item) =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
)
.filter((item) => item.quantity > 0),
};
case "CLEAR_CART":
return { items: [] };
default:
return state;
}
}
const CartStateContext = createContext<CartState | undefined>(undefined);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | undefined>(
undefined
);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export function useCartState() {
const context = useContext(CartStateContext);
if (context === undefined) {
throw new Error("useCartState must be used within a CartProvider");
}
return context;
}
export function useCartDispatch() {
const context = useContext(CartDispatchContext);
if (context === undefined) {
throw new Error("useCartDispatch must be used within a CartProvider");
}
return context;
}
What does this code do?
This file sets up your shopping cart state using useReducer and useContext. Here is what each piece does:
- CartAction is a discriminated union type. Each action has a type string that tells TypeScript exactly which payload shape to expect.
- cartReducer handles four actions: add an item, remove an item, update its quantity, and clear the entire cart.
- Two separate contexts (CartStateContext and CartDispatchContext) prevent unnecessary re-renders. Components that only dispatch actions will not re-render when state changes.
- useCartState() and useCartDispatch() are custom hooks that make consuming the context safe and convenient.
Why split state and dispatch into two contexts?
This is a performance pattern. The dispatch function never changes identity between renders. If a button only needs dispatch (to add an item), it subscribes only to CartDispatchContext and skips re-rendering when the cart's item list updates.
- Save context/CartContext.tsx.
Now that the cart context exists, you can build the Navbar that reads from it.
- Create components/Navbar.tsx (in the same components/ folder where your ui/ subfolder lives) and paste the following code:
"use client";
import Link from "next/link";
import { ShoppingCart, Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { useCartState } from "@/context/CartContext";
export function Navbar() {
const { items } = useCartState();
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/products", label: "Products" },
{ href: "/cart", label: "Cart" },
];
return (
<header className="border-b">
<nav className="container mx-auto flex items-center justify-between px-4 py-4">
<Link href="/" className="text-xl font-bold">
Store
</Link>
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm font-medium hover:underline"
>
{link.label}
</Link>
))}
<Link href="/cart" className="relative">
<ShoppingCart className="h-5 w-5" />
{itemCount > 0 && (
<Badge className="absolute -top-2 -right-2 h-5 w-5 flex items-center justify-center rounded-full p-0 text-xs">
{itemCount}
</Badge>
)}
</Link>
</div>
<Sheet>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right">
<div className="flex flex-col gap-4 mt-8">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-lg font-medium"
>
{link.label}
</Link>
))}
</div>
</SheetContent>
</Sheet>
</nav>
</header>
);
}
What does this code do?
The Navbar renders differently on desktop and mobile:
- On desktop (md: breakpoint and above), navigation links and a shopping cart icon appear in a horizontal row. The cart icon shows a Badge with the item count when the cart is not empty.
- On mobile, the links hide behind a hamburger menu icon. Pressing it opens a Sheet (a slide-out panel from shadcn/ui) with the same links in a vertical stack.
- Next.js Link enables client-side navigation, so page transitions happen without a full browser reload.
- Save components/Navbar.tsx.
Seeing red squiggles in VS Code?
If TypeScript shows import errors for @/context/CartContext or @/types/product, make sure you saved both files and that they are at the correct paths (context/CartContext.tsx and types/product.ts at the project root). You may also need to restart the TypeScript server: open the command palette with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and run TypeScript: Restart TS Server.
help me fix TypeScript import errors in my Navbar component
Build the Footer and update the layout
The Footer is a simpler component with copyright text and placeholder links. After creating it, you will update the root layout to wrap every page with the Navbar at the top and the Footer at the bottom.
- Create components/Footer.tsx and paste the following code:
export function Footer() {
return (
<footer className="border-t mt-auto">
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-muted-foreground">
2026 Store. All rights reserved.
</p>
<div className="flex gap-4">
<a href="#" className="text-sm text-muted-foreground hover:underline">
Privacy Policy
</a>
<a href="#" className="text-sm text-muted-foreground hover:underline">
Terms of Service
</a>
</div>
</div>
</div>
</footer>
);
}
What does this code do?
The Footer uses mt-auto to push itself to the bottom of the viewport. On mobile, the copyright and links stack vertically. On desktop (md:flex-row), they sit side by side. The text-muted-foreground class uses one of the CSS variables you saw earlier in globals.css.
- Save components/Footer.tsx.
Now connect everything in the root layout so the Navbar and Footer wrap every page automatically.
- Open app/layout.tsx in the Explorer sidebar.
- Replace the entire file contents with the following code:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { CartProvider } from "@/context/CartContext";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Store - Quality Products for Everyone",
description: "Browse our curated collection of premium products.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} flex flex-col min-h-screen`}>
<CartProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</CartProvider>
</body>
</html>
);
}
What does this code do?
This is the root layout for your entire Next.js app. Every page renders inside it. Here is what each piece accomplishes:
- CartProvider wraps everything so any component on any page can read or update the cart.
- Navbar sits above main and Footer sits below it. The flex flex-col min-h-screen on body ensures the footer sticks to the bottom even when page content is short.
- main has flex-1 so it expands to fill all remaining vertical space.
- The Inter font loads from Google Fonts and applies to the entire body.
- Save app/layout.tsx.
- Return to your browser at localhost:3000. You should see the Navbar at the top of the page with links for Home, Products, and Cart. The Footer should appear at the bottom with copyright text.
What is file-based routing?
In Next.js, every folder inside app/ that contains a page.tsx file becomes a route. The root app/page.tsx maps to /. A file at app/products/page.tsx would map to /products. You will create these pages in the next steps.
Don't see the Navbar or Footer?
Make sure the dev server is still running in your terminal. If it stopped, run npm run dev again from inside the ecommerce-store folder. Also check that your import paths match exactly (@/components/Navbar, @/components/Footer, @/context/CartContext).
help me debug why my Navbar and Footer are not rendering
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: types/product.ts, context/CartContext.tsx, components/Navbar.tsx, components/Footer.tsx, and app/layout.tsx.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or modified in this step.
export interface Product {
id: string;
name: string;
price: number;
image: string;
category: string;
description: string;
}
export interface CartItem {
id: string;
name: string;
price: number;
image: string;
quantity: number;
}
"use client";
import { createContext, useContext, useReducer, ReactNode } from "react";
import { CartItem } from "@/types/product";
interface CartState {
items: CartItem[];
}
type CartAction =
| { type: "ADD_ITEM"; payload: CartItem }
| { type: "REMOVE_ITEM"; payload: { id: string } }
| { type: "UPDATE_QUANTITY"; payload: { id: string; quantity: number } }
| { type: "CLEAR_CART" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find((item) => item.id === action.payload.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { ...action.payload, quantity: 1 }] };
}
case "REMOVE_ITEM":
return {
items: state.items.filter((item) => item.id !== action.payload.id),
};
case "UPDATE_QUANTITY":
return {
items: state.items
.map((item) =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
)
.filter((item) => item.quantity > 0),
};
case "CLEAR_CART":
return { items: [] };
default:
return state;
}
}
const CartStateContext = createContext<CartState | undefined>(undefined);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | undefined>(
undefined
);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export function useCartState() {
const context = useContext(CartStateContext);
if (context === undefined) {
throw new Error("useCartState must be used within a CartProvider");
}
return context;
}
export function useCartDispatch() {
const context = useContext(CartDispatchContext);
if (context === undefined) {
throw new Error("useCartDispatch must be used within a CartProvider");
}
return context;
}
"use client";
import Link from "next/link";
import { ShoppingCart, Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { useCartState } from "@/context/CartContext";
export function Navbar() {
const { items } = useCartState();
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/products", label: "Products" },
{ href: "/cart", label: "Cart" },
];
return (
<header className="border-b">
<nav className="container mx-auto flex items-center justify-between px-4 py-4">
<Link href="/" className="text-xl font-bold">
Store
</Link>
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm font-medium hover:underline"
>
{link.label}
</Link>
))}
<Link href="/cart" className="relative">
<ShoppingCart className="h-5 w-5" />
{itemCount > 0 && (
<Badge className="absolute -top-2 -right-2 h-5 w-5 flex items-center justify-center rounded-full p-0 text-xs">
{itemCount}
</Badge>
)}
</Link>
</div>
<Sheet>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right">
<div className="flex flex-col gap-4 mt-8">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-lg font-medium"
>
{link.label}
</Link>
))}
</div>
</SheetContent>
</Sheet>
</nav>
</header>
);
}
export function Footer() {
return (
<footer className="border-t mt-auto">
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-muted-foreground">
2026 Store. All rights reserved.
</p>
<div className="flex gap-4">
<a href="#" className="text-sm text-muted-foreground hover:underline">
Privacy Policy
</a>
<a href="#" className="text-sm text-muted-foreground hover:underline">
Terms of Service
</a>
</div>
</div>
</div>
</footer>
);
}
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { CartProvider } from "@/context/CartContext";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Store - Quality Products for Everyone",
description: "Browse our curated collection of premium products.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} flex flex-col min-h-screen`}>
<CartProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</CartProvider>
</body>
</html>
);
}
Your shared layout is in place. Every page in your store now gets a consistent Navbar and Footer for free. Next up, you will build the Home page with a hero section and a grid of featured products.
Build the Home Page
Your layout is locked in. Every page now renders with the Navbar at the top and Footer at the bottom. The next step is filling in actual content for visitors to see.
The home page is the first thing shoppers land on. It needs to answer one question instantly: what does this store sell, and how do I browse? In this step, you will build a home page with a hero banner, a featured products grid, and a call-to-action at the bottom.
In this step, get ready to:
- Create TypeScript types and mock product data.
- Build a reusable ProductCard component with shadcn/ui.
- Design the home page with a hero section, featured products grid, and bottom call-to-action.
Create your product types and mock data
Before you can display products on the page, you need to define what a product looks like in TypeScript. You also need sample data to render while you build the UI.
- Create a new folder called types inside the root of your ecommerce-store project.
- Inside that folder, create a file called types/product.ts and paste the following code:
export interface Product {
id: string;
name: string;
price: number;
image: string;
category: string;
description: string;
}
export interface CartItem {
id: string;
name: string;
price: number;
image: string;
quantity: number;
}
What does this code do?
The Product interface describes every field a product has: an id, name, price, image path, category label, and a description.
The CartItem interface is a slimmed-down version that includes a quantity field. This is what gets stored in the shopping cart when someone adds a product.
- Save types/product.ts.
- Now create a folder called data in the root of your project, then create a file called data/products.ts with the following mock data:
import { Product } from "@/types/product";
export const products: Product[] = [
{
id: "1",
name: "Wireless Headphones",
price: 79.99,
image: "/images/headphones.jpg",
category: "Electronics",
description: "Premium noise-cancelling wireless headphones with 30-hour battery life.",
},
{
id: "2",
name: "Leather Backpack",
price: 129.99,
image: "/images/backpack.jpg",
category: "Accessories",
description: "Handcrafted full-grain leather backpack with laptop compartment.",
},
{
id: "3",
name: "Running Shoes",
price: 94.99,
image: "/images/shoes.jpg",
category: "Footwear",
description: "Lightweight cushioned running shoes for all terrains.",
},
{
id: "4",
name: "Ceramic Mug Set",
price: 34.99,
image: "/images/mugs.jpg",
category: "Home",
description: "Set of 4 handmade ceramic mugs in earthy tones.",
},
{
id: "5",
name: "Mechanical Keyboard",
price: 149.99,
image: "/images/keyboard.jpg",
category: "Electronics",
description: "Compact mechanical keyboard with RGB backlighting and hot-swappable switches.",
},
{
id: "6",
name: "Yoga Mat",
price: 49.99,
image: "/images/yoga-mat.jpg",
category: "Fitness",
description: "Eco-friendly non-slip yoga mat with alignment markings.",
},
{
id: "7",
name: "Desk Lamp",
price: 59.99,
image: "/images/lamp.jpg",
category: "Home",
description: "Adjustable LED desk lamp with touch dimmer and USB charging port.",
},
{
id: "8",
name: "Canvas Tote Bag",
price: 24.99,
image: "/images/tote.jpg",
category: "Accessories",
description: "Durable organic cotton canvas tote with interior pocket.",
},
];
What does this code do?
This file exports an array of 8 mock products. Each product satisfies the Product interface you just defined. The image paths point to a /images/ folder inside public.
You do not need actual image files yet. The cards will show a muted grey background (bg-muted) as a fallback when images are missing. You can add real images later.
- Save data/products.ts.
- Confirm both new files exist by checking the VS Code Explorer sidebar. You should see types/product.ts and data/products.ts listed.
Seeing red underlines in your imports?
If data/products.ts shows a red underline on the import from @/types/product, make sure you created the types folder in the project root (not inside app or src). The @/ alias maps to your project root.
help me fix import path errors in my Next.js project
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is types/product.ts:
export interface Product {
id: string;
name: string;
price: number;
image: string;
category: string;
description: string;
}
export interface CartItem {
id: string;
name: string;
price: number;
image: string;
quantity: number;
}
Here is data/products.ts:
import { Product } from "@/types/product";
export const products: Product[] = [
{
id: "1",
name: "Wireless Headphones",
price: 79.99,
image: "/images/headphones.jpg",
category: "Electronics",
description: "Premium noise-cancelling wireless headphones with 30-hour battery life.",
},
{
id: "2",
name: "Leather Backpack",
price: 129.99,
image: "/images/backpack.jpg",
category: "Accessories",
description: "Handcrafted full-grain leather backpack with laptop compartment.",
},
{
id: "3",
name: "Running Shoes",
price: 94.99,
image: "/images/shoes.jpg",
category: "Footwear",
description: "Lightweight cushioned running shoes for all terrains.",
},
{
id: "4",
name: "Ceramic Mug Set",
price: 34.99,
image: "/images/mugs.jpg",
category: "Home",
description: "Set of 4 handmade ceramic mugs in earthy tones.",
},
{
id: "5",
name: "Mechanical Keyboard",
price: 149.99,
image: "/images/keyboard.jpg",
category: "Electronics",
description: "Compact mechanical keyboard with RGB backlighting and hot-swappable switches.",
},
{
id: "6",
name: "Yoga Mat",
price: 49.99,
image: "/images/yoga-mat.jpg",
category: "Fitness",
description: "Eco-friendly non-slip yoga mat with alignment markings.",
},
{
id: "7",
name: "Desk Lamp",
price: 59.99,
image: "/images/lamp.jpg",
category: "Home",
description: "Adjustable LED desk lamp with touch dimmer and USB charging port.",
},
{
id: "8",
name: "Canvas Tote Bag",
price: 24.99,
image: "/images/tote.jpg",
category: "Accessories",
description: "Durable organic cotton canvas tote with interior pocket.",
},
];
Build the ProductCard component
Each product appears as a card across multiple pages (home, products). Building a reusable ProductCard component means you write the card UI once and render it anywhere.
- Create a new file called components/ProductCard.tsx (in the same components folder that already contains Navbar.tsx and Footer.tsx) and paste the following code:
"use client";
import Image from "next/image";
import { Product } from "@/types/product";
import { useCartDispatch } from "@/context/CartContext";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface ProductCardProps {
product: Product;
}
export function ProductCard({ product }: ProductCardProps) {
const dispatch = useCartDispatch();
const handleAddToCart = () => {
dispatch({
type: "ADD_ITEM",
payload: {
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: 1,
},
});
};
return (
<Card className="flex flex-col">
<CardHeader className="p-0">
<div className="relative h-48 w-full bg-muted">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover rounded-t-lg"
/>
</div>
</CardHeader>
<CardContent className="flex-1 p-4">
<div className="flex items-center justify-between mb-2">
<CardTitle className="text-lg">{product.name}</CardTitle>
<Badge variant="secondary">{product.category}</Badge>
</div>
<p className="text-sm text-muted-foreground">{product.description}</p>
</CardContent>
<CardFooter className="flex items-center justify-between p-4 pt-0">
<span className="text-lg font-bold">${product.price.toFixed(2)}</span>
<Button onClick={handleAddToCart}>Add to Cart</Button>
</CardFooter>
</Card>
);
}
What does this code do?
The ProductCard receives a Product as a prop and renders it using shadcn/ui Card primitives. The image uses Next.js Image with fill so it fills the card header area. The bg-muted background shows if the image file is missing.
The handleAddToCart function dispatches an ADD_ITEM action to the cart context. This wires the card's button directly into your app's state management.
- Save components/ProductCard.tsx.
- You will not see a visible change in the browser yet because no page is using this component. The next substep puts it to work.
Red underlines on the Image import?
If TypeScript complains about next/image, confirm you are inside the ecommerce-store folder (where package.json lives). The next package provides the Image component. Also check that your dev server is still running.
help me resolve the next/image TypeScript error
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
"use client";
import Image from "next/image";
import { Product } from "@/types/product";
import { useCartDispatch } from "@/context/CartContext";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface ProductCardProps {
product: Product;
}
export function ProductCard({ product }: ProductCardProps) {
const dispatch = useCartDispatch();
const handleAddToCart = () => {
dispatch({
type: "ADD_ITEM",
payload: {
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: 1,
},
});
};
return (
<Card className="flex flex-col">
<CardHeader className="p-0">
<div className="relative h-48 w-full bg-muted">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover rounded-t-lg"
/>
</div>
</CardHeader>
<CardContent className="flex-1 p-4">
<div className="flex items-center justify-between mb-2">
<CardTitle className="text-lg">{product.name}</CardTitle>
<Badge variant="secondary">{product.category}</Badge>
</div>
<p className="text-sm text-muted-foreground">{product.description}</p>
</CardContent>
<CardFooter className="flex items-center justify-between p-4 pt-0">
<span className="text-lg font-bold">${product.price.toFixed(2)}</span>
<Button onClick={handleAddToCart}>Add to Cart</Button>
</CardFooter>
</Card>
);
}
Build the home page with hero, products, and call-to-action
Good visual hierarchy guides a visitor's eye from top to bottom. The hero grabs attention with a bold headline. The product grid gives them something to browse. The bottom call-to-action nudges them toward the full catalog.
- Open app/page.tsx in VS Code. This file already exists from the scaffold. Replace its entire contents with the following:
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ProductCard } from "@/components/ProductCard";
import { products } from "@/data/products";
export default function HomePage() {
// Show the first 4 products as featured items
const featuredProducts = products.slice(0, 4);
return (
<div>
{/* Hero section - full width banner with headline and CTA */}
<section className="container mx-auto px-4 py-16 text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-4">
Quality Products for Everyone
</h1>
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
Discover our curated collection of premium products designed to
elevate your everyday life.
</p>
<Button asChild size="lg">
<Link href="/products">Browse Products</Link>
</Button>
</section>
{/* Featured Products - responsive grid of product cards */}
<section className="container mx-auto px-4 py-16">
<h2 className="text-2xl font-bold mb-8">Featured Products</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</section>
{/* Bottom CTA - encourages visitors to view full catalog */}
<section className="container mx-auto px-4 py-16 text-center">
<h2 className="text-2xl font-bold mb-4">Ready to explore?</h2>
<p className="text-muted-foreground mb-6">
We have more products waiting for you.
</p>
<Button asChild variant="outline" size="lg">
<Link href="/products">View All Products</Link>
</Button>
</section>
</div>
);
}
What does this code do?
The page has three distinct sections. The hero uses responsive font sizing (text-4xl on mobile, md:text-6xl on desktop) so it scales nicely. The Button asChild pattern lets the shadcn/ui Button render as a Next.js Link for client-side navigation.
The Featured Products grid uses Tailwind CSS responsive grid classes: 1 column on mobile, 2 on small screens, 4 on large screens. It slices the first 4 products from your data array using products.slice(0, 4).
The bottom CTA uses variant="outline" to visually differentiate it from the hero button. This creates a clear visual hierarchy between the primary and secondary actions.
- Save app/page.tsx.
- Switch to your browser and navigate to http://localhost:3000. You should see a large "Quality Products for Everyone" headline, a "Browse Products" button, 4 product cards in a grid (with grey placeholder areas where images would go), and a "Ready to explore?" section at the bottom.
- Resize your browser window narrower to confirm the product grid collapses from 4 columns to 2 to 1 as the window shrinks.
Adding real product images
To see actual images, create a public/images/ folder and add image files matching the filenames in your mock data (headphones.jpg, backpack.jpg, etc.). Any free stock photo will work. The cards render fine without them.
Page not updating in the browser?
Make sure your dev server is still running in the terminal. If it crashed, restart it by running npm run dev from inside the ecommerce-store folder. Also confirm you saved all files (types/product.ts, data/products.ts, components/ProductCard.tsx, and app/page.tsx).
help me debug why my Next.js home page is not rendering
✔️ Awesome, I've got everything!
Great. Confirm you have saved app/page.tsx and see the home page in your browser.
ⓧ I'd like to double check the full code
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ProductCard } from "@/components/ProductCard";
import { products } from "@/data/products";
export default function HomePage() {
const featuredProducts = products.slice(0, 4);
return (
<div>
<section className="container mx-auto px-4 py-16 text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-4">
Quality Products for Everyone
</h1>
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
Discover our curated collection of premium products designed to
elevate your everyday life.
</p>
<Button asChild size="lg">
<Link href="/products">Browse Products</Link>
</Button>
</section>
<section className="container mx-auto px-4 py-16">
<h2 className="text-2xl font-bold mb-8">Featured Products</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</section>
<section className="container mx-auto px-4 py-16 text-center">
<h2 className="text-2xl font-bold mb-4">Ready to explore?</h2>
<p className="text-muted-foreground mb-6">
We have more products waiting for you.
</p>
<Button asChild variant="outline" size="lg">
<Link href="/products">View All Products</Link>
</Button>
</section>
</div>
);
}
Your home page is live. Next up, you will build the full Products page where shoppers can see every item in your catalog and add them to a real shopping cart.
Build the Products Page
Your home page has a hero, featured products, and a call to action. Now you need a dedicated page where shoppers can browse the full catalog.
A product grid is only useful if shoppers can actually add items to their cart. Right now, the "Add to Cart" buttons in your ProductCard component try to call a useCartDispatch hook that does not exist yet. In this step, you will build the cart state system that makes everything click.
In this step, get ready to:
- Create the Products page with a responsive product grid.
- Build a typed cart state system using useReducer and useContext.
- Connect the Add to Cart button to dispatch actions to the cart.
Create the Products page
In Next.js, every folder inside app/ with a page.tsx file becomes a route. Creating app/products/page.tsx automatically gives you the /products URL.
- In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder products.
- Right-click the new products folder and select New File.
- Name the file page.tsx.
- Paste the following code into app/products/page.tsx:
import { ProductCard } from "@/components/ProductCard";
import { products } from "@/data/products";
export default function ProductsPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">All Products</h1>
<p className="text-muted-foreground mb-8">
Browse our full collection of {products.length} products.
</p>
<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>
);
}
What does this code do?
This page imports the full products array from your mock data and renders each one using the ProductCard component you built earlier.
The grid uses Tailwind CSS responsive classes: 1 column on mobile, 2 on tablets (sm:), 3 on laptops (lg:), and 4 on wide screens (xl:). This means the layout adapts automatically based on the visitor's screen size.
- Save app/products/page.tsx.
Your dev server will show an error right now because ProductCard tries to import useCartDispatch from a file that does not exist yet. You will fix this in the next substep.
Create the cart context with useReducer
Your app needs a central place to store cart data that any component can read or update. React's useReducer hook handles complex state transitions, and useContext shares that state across the component tree without passing props manually.
- In the VS Code Explorer sidebar, right-click the root ecommerce-store folder and select New Folder.
- Name the folder context.
- Right-click context and select New File. Name it CartContext.tsx.
- Start by adding the imports and type definitions at the top of context/CartContext.tsx:
"use client";
import { createContext, useContext, useReducer, ReactNode } from "react";
import { CartItem } from "@/types/product";
interface CartState {
items: CartItem[];
}
type CartAction =
| { type: "ADD_ITEM"; payload: CartItem }
| { type: "REMOVE_ITEM"; payload: { id: string } }
| { type: "UPDATE_QUANTITY"; payload: { id: string; quantity: number } }
| { type: "CLEAR_CART" };
What is a discriminated union?
The CartAction type uses a TypeScript pattern called a discriminated union. Each action has a literal type field (like "ADD_ITEM" or "CLEAR_CART") that tells TypeScript exactly which payload shape to expect.
This means if you dispatch an action with type: "ADD_ITEM", TypeScript knows the payload must be a CartItem. If you make a typo or pass the wrong shape, you get a compile-time error instead of a runtime bug.
- Below the type definitions, add the cartReducer function:
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find((item) => item.id === action.payload.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { ...action.payload, quantity: 1 }] };
}
case "REMOVE_ITEM":
return {
items: state.items.filter((item) => item.id !== action.payload.id),
};
case "UPDATE_QUANTITY":
return {
items: state.items
.map((item) =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
)
.filter((item) => item.quantity > 0),
};
case "CLEAR_CART":
return { items: [] };
default:
return state;
}
}
What does the reducer do?
A reducer is a pure function that takes the current state and an action, then returns a new state. It never mutates the original state directly.
- ADD_ITEM checks if the product is already in the cart. If so, it increments the quantity. Otherwise, it adds a new entry with quantity 1.
- REMOVE_ITEM filters out the item by ID.
- UPDATE_QUANTITY sets a new quantity and removes the item entirely if that quantity hits 0.
- CLEAR_CART resets to an empty array.
- Below the reducer, add the contexts, provider component, and custom hooks:
const CartStateContext = createContext<CartState | undefined>(undefined);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | undefined>(
undefined
);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export function useCartState() {
const context = useContext(CartStateContext);
if (context === undefined) {
throw new Error("useCartState must be used within a CartProvider");
}
return context;
}
export function useCartDispatch() {
const context = useContext(CartDispatchContext);
if (context === undefined) {
throw new Error("useCartDispatch must be used within a CartProvider");
}
return context;
}
Why two separate contexts?
The cart uses two contexts instead of one: CartStateContext holds the data and CartDispatchContext holds the dispatch function.
This is a performance pattern. Components that only need to fire actions (like the Add to Cart button) consume only the dispatch context. They will not re-render when cart items change because they are not subscribed to the state context.
- Save context/CartContext.tsx.
- Return to your browser at localhost:3000. The error should disappear and your site should load again.
Still seeing a module-not-found error?
- Check that the file is named exactly CartContext.tsx (capital C, capital C) inside a folder named exactly context at the project root.
- Confirm the first line is "use client"; with the semicolon. Without this directive, Next.js treats the file as a Server Component and the React hooks will fail.
- Make sure your import path in app/layout.tsx says @/context/CartContext (no file extension).
Help me fix my CartContext import error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
"use client";
import { createContext, useContext, useReducer, ReactNode } from "react";
import { CartItem } from "@/types/product";
interface CartState {
items: CartItem[];
}
type CartAction =
| { type: "ADD_ITEM"; payload: CartItem }
| { type: "REMOVE_ITEM"; payload: { id: string } }
| { type: "UPDATE_QUANTITY"; payload: { id: string; quantity: number } }
| { type: "CLEAR_CART" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find((item) => item.id === action.payload.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { ...action.payload, quantity: 1 }] };
}
case "REMOVE_ITEM":
return {
items: state.items.filter((item) => item.id !== action.payload.id),
};
case "UPDATE_QUANTITY":
return {
items: state.items
.map((item) =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
)
.filter((item) => item.quantity > 0),
};
case "CLEAR_CART":
return { items: [] };
default:
return state;
}
}
const CartStateContext = createContext<CartState | undefined>(undefined);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | undefined>(
undefined
);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export function useCartState() {
const context = useContext(CartStateContext);
if (context === undefined) {
throw new Error("useCartState must be used within a CartProvider");
}
return context;
}
export function useCartDispatch() {
const context = useContext(CartDispatchContext);
if (context === undefined) {
throw new Error("useCartDispatch must be used within a CartProvider");
}
return context;
}
Verify the cart integration
Your app/layout.tsx already wraps the entire app with CartProvider, and your ProductCard component already dispatches the ADD_ITEM action. Now that CartContext.tsx exists, those imports resolve and the full cart functionality is live.
- Navigate to localhost:3000/products in your browser.
- You should see all 8 products displayed in a responsive grid with images, names, prices, category badges, and Add to Cart buttons.
- Click Add to Cart on any product.
- Look at the Navbar. A small badge with the number 1 should appear next to the cart icon, confirming the item was added to state.
- Click Add to Cart on the same product again. The badge should update to 2, showing that the reducer incremented the quantity instead of adding a duplicate.
How does the badge update without a page refresh?
The Navbar calls useCartState() to subscribe to the cart state context. Whenever the cart's items array changes (because a dispatch updated it), React re-renders the Navbar and the badge shows the new count. No prop drilling or manual refresh needed.
Badge not appearing after clicking Add to Cart?
- Open your browser console (F12 or Cmd+Option+J on macOS). If you see an error about "useCartState must be used within a CartProvider", your layout is not wrapping the app with CartProvider. Check that app/layout.tsx imports and renders <CartProvider> around the Navbar, main, and Footer.
- If the page loads but the button does nothing, check that components/ProductCard.tsx has "use client" at the top. Only client components can use hooks.
Why is my Add to Cart button not updating the badge?
Your storefront now has a full product catalog and a working cart state system. Next up, you will build the Cart page where shoppers can review their items, adjust quantities, and see a running total.
Build the Cart Page
Your Products page lets shoppers add items to their cart. But right now, there is no way for them to review what they have selected, adjust quantities, or see a total price.
In this step, you will build the Cart page that reads from your CartContext and lets users manage their selections. You will also confirm that the Navbar badge updates in real time as items change.
In this step, get ready to:
- Create the Cart page with item display and quantity controls.
- Add a cart summary with a derived total price and a Clear Cart button.
- Verify the Navbar badge updates as you add and remove items.
Create the cart page with item display and quantity controls
The Cart page needs to handle two states: an empty cart (with a friendly message linking back to products) and a populated cart showing each item with controls to change quantity or remove it.
- Create a new file at app/cart/page.tsx in your VS Code Explorer sidebar.
- Paste the following code into the file:
"use client";
import Link from "next/link";
import { useCartState, useCartDispatch } from "@/context/CartContext";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
export default function CartPage() {
const { items } = useCartState();
const dispatch = useCartDispatch();
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
if (items.length === 0) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-3xl font-bold mb-4">Your Cart is Empty</h1>
<p className="text-muted-foreground mb-8">
Looks like you have not added anything to your cart yet.
</p>
<Button asChild>
<Link href="/products">Browse Products</Link>
</Button>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Your Cart ({itemCount} items)</h1>
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex-1">
<h3 className="font-medium">{item.name}</h3>
<p className="text-sm text-muted-foreground">
${item.price.toFixed(2)} each
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() =>
dispatch({
type: "UPDATE_QUANTITY",
payload: { id: item.id, quantity: item.quantity - 1 },
})
}
>
-
</Button>
<span className="w-8 text-center">{item.quantity}</span>
<Button
variant="outline"
size="icon"
onClick={() =>
dispatch({
type: "UPDATE_QUANTITY",
payload: { id: item.id, quantity: item.quantity + 1 },
})
}
>
+
</Button>
</div>
<div className="ml-4 text-right">
<p className="font-medium">
${(item.price * item.quantity).toFixed(2)}
</p>
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700"
onClick={() =>
dispatch({ type: "REMOVE_ITEM", payload: { id: item.id } })
}
>
Remove
</Button>
</div>
</div>
))}
</div>
<Separator className="my-8" />
<div className="flex items-center justify-between">
<div>
<p className="text-2xl font-bold">Total: ${total.toFixed(2)}</p>
<p className="text-sm text-muted-foreground">{itemCount} items</p>
</div>
<div className="flex gap-4">
<Button
variant="outline"
onClick={() => dispatch({ type: "CLEAR_CART" })}
>
Clear Cart
</Button>
<Button>Checkout</Button>
</div>
</div>
</div>
);
}
What does this code do?
This component handles two rendering paths based on cart state:
- useCartState() reads the current cart items. useCartDispatch() gives you the dispatch function to send actions.
- The if (items.length === 0) guard renders an empty-state message with a link back to /products when the cart has nothing in it.
- Each cart item row dispatches UPDATE_QUANTITY for the +/- buttons. When quantity reaches 0, the reducer automatically removes the item.
- The REMOVE_ITEM action deletes the item immediately regardless of quantity.
- Save app/cart/page.tsx.
- In your browser, navigate to /products and click Add to Cart on two different products.
- Navigate to /cart. You should see both items listed with their names, prices, and quantity set to 1.
Don't see your cart items?
- Make sure the file is saved as app/cart/page.tsx (inside the app/cart/ folder, not app/ directly).
- Check that your file starts with "use client"; on the first line. Hooks like useCartState() only work in client components.
- Confirm you added items on the Products page first. Navigate to /products, click Add to Cart, then go to /cart.
Help me debug why my cart page shows no items.
Verify the cart summary and quantity controls
The summary section at the bottom of the cart uses a pattern called derived state. The total price is never stored anywhere. It is calculated fresh from the items array every time the component renders.
Why derive the total instead of storing it?
Storing a total alongside items creates two sources of truth. If you update a quantity but forget to update the total, your UI shows the wrong price.
By computing total with items.reduce() on every render, the displayed price is always correct. React re-runs this calculation whenever the cart state changes.
- On the /cart page, click the + button on one of your items.
- Confirm the quantity increases and the subtotal on the right updates to reflect the new quantity.
- Click the - button until the quantity reaches 0. The item should disappear from the list.
- Add a few items back on /products, return to /cart, and click Clear Cart. All items should disappear and the empty state message should appear.
The total at the bottom should always equal the sum of each item's price multiplied by its quantity. No manual recalculation needed.
Quantity goes below zero or item doesn't disappear?
- Check that your cartReducer in context/CartContext.tsx includes the .filter((item) => item.quantity > 0) chain in the UPDATE_QUANTITY case. This removes items when they hit zero.
- Make sure the dispatch payload passes item.quantity - 1 for decrement and item.quantity + 1 for increment.
Help me debug my cart quantity controls.
Verify the Navbar badge updates in real time
Your Navbar already includes the badge logic from when you built it in an earlier step. Now that the cart is fully functional, you can confirm the badge responds to every cart change.
Why does the Navbar update automatically?
The Navbar calls useCartState() which subscribes it to CartStateContext. Whenever the cart items array changes (via dispatch from any page), React re-renders the Navbar and recalculates itemCount. The badge appears when the count is above zero and disappears when the cart is empty.
- Navigate to /products and look at the cart icon in the top-right corner of the Navbar.
- Click Add to Cart on a product. A small badge should appear on the cart icon showing 1.
- Add the same product again. The badge should update to 2.
- Navigate to /cart and click Clear Cart. The badge should disappear from the Navbar.
Badge not appearing on the cart icon?
- The badge only shows on desktop (md: breakpoint and above). If your browser window is narrow, widen it past 768px.
- Confirm that components/Navbar.tsx imports useCartState from @/context/CartContext and destructures items from it.
Help me fix my Navbar cart badge.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/cart/page.tsx and your dev server is still running.
ⓧ I'd like to double check the full code
Here is the complete app/cart/page.tsx for comparison:
"use client";
import Link from "next/link";
import { useCartState, useCartDispatch } from "@/context/CartContext";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
export default function CartPage() {
const { items } = useCartState();
const dispatch = useCartDispatch();
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
if (items.length === 0) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-3xl font-bold mb-4">Your Cart is Empty</h1>
<p className="text-muted-foreground mb-8">
Looks like you have not added anything to your cart yet.
</p>
<Button asChild>
<Link href="/products">Browse Products</Link>
</Button>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Your Cart ({itemCount} items)</h1>
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex-1">
<h3 className="font-medium">{item.name}</h3>
<p className="text-sm text-muted-foreground">
${item.price.toFixed(2)} each
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() =>
dispatch({
type: "UPDATE_QUANTITY",
payload: { id: item.id, quantity: item.quantity - 1 },
})
}
>
-
</Button>
<span className="w-8 text-center">{item.quantity}</span>
<Button
variant="outline"
size="icon"
onClick={() =>
dispatch({
type: "UPDATE_QUANTITY",
payload: { id: item.id, quantity: item.quantity + 1 },
})
}
>
+
</Button>
</div>
<div className="ml-4 text-right">
<p className="font-medium">
${(item.price * item.quantity).toFixed(2)}
</p>
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700"
onClick={() =>
dispatch({ type: "REMOVE_ITEM", payload: { id: item.id } })
}
>
Remove
</Button>
</div>
</div>
))}
</div>
<Separator className="my-8" />
<div className="flex items-center justify-between">
<div>
<p className="text-2xl font-bold">Total: ${total.toFixed(2)}</p>
<p className="text-sm text-muted-foreground">{itemCount} items</p>
</div>
<div className="flex gap-4">
<Button
variant="outline"
onClick={() => dispatch({ type: "CLEAR_CART" })}
>
Clear Cart
</Button>
<Button>Checkout</Button>
</div>
</div>
</div>
);
}
Your storefront is now fully functional. Shoppers can browse products, add them to a cart, adjust quantities, remove items, and see a live-updating total. The Navbar badge ties it all together by showing the cart count from any page. Next up, you will tackle the Secret Mission and build a contact form with client-side validation.
Secret mission
Build the Contact Page
Every real store needs a way for customers to get in touch. Build a Contact page with a validated form that checks all fields before submission and shows clear error messages when something is missing.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The ecommerce-store project folder on your Desktop (includes all source code and node_modules).
- The Next.js dev server running at localhost:3000.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next project in the series.
- Your dev server stays running at localhost:3000.
- Your project files remain on your Desktop, ready for the next session.
Pause - I'll come back to this later
Shut down the running dev server to free up memory and your terminal. Your project files stay on disk.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the server.
- When you come back, navigate into the ecommerce-store folder and run npm run dev to start again.
Delete - I don't want to use this again
Remove all project files from your machine. This is permanent.
- Switch to the terminal where your dev server is running and press Ctrl+C to stop the server.
- Delete the project folder by running the following command (macOS/Linux):
cd ~/Desktop
rm -rf ecommerce-store
On Windows, run this instead:
cd ~/Desktop
rmdir /s /q ecommerce-store
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see ecommerce-store in the output.
Nice Work!
Nice Work!
You just built a fully functional e-commerce storefront from scratch using Next.js, TypeScript, and shadcn/ui. Your storefront has multiple pages, a shared layout, responsive product grids, and a working shopping cart with typed state management.
You've learned how to:
- Built a multi-page Next.js application with file-based routing for Home, Products, and Cart pages.
- Implemented a fully typed shopping cart using useReducer + useContext with TypeScript discriminated unions to manage complex state.
- Designed a reusable component architecture with responsive product grids using shadcn/ui Card components and Tailwind CSS v4.
- Secret Mission: Built a Contact page with client-side form validation using shadcn/ui form primitives and TypeScript.
Ready to quiz yourself?