Build a Next.js Online Shop with Firestore
Build a live product catalog shop with Next.js, Firestore, and Vercel.
Introduction
30 Second Summary
Think about the last time you browsed an online shop. Every product had its own page, prices loaded from a database, and your cart followed you as you clicked around.
In this project, you will build that same experience from scratch using Next.js and Firestore. You'll create a product catalog, dynamic product pages, a shopping cart, and deploy it all to a live URL on Vercel.
What You'll Build
You'll share a live URL where anyone can browse your product catalog, click into detail pages, and add items to a shopping cart.
By the end of this project, you'll have:
- A product catalog homepage where visitors browse a responsive grid of cards showing names, prices, and categories, all fetched live from Firestore.
- Product detail pages at /products/wireless-headphones and /products/smart-watch, all generated from a single dynamic route template.
- A working shopping cart where you add items from any product page, watch the nav counter update, and review your selections on a dedicated cart page.
- Secret Mission: an optional challenge to push your skills further.
Do I need React experience?
This project assumes you're comfortable with React fundamentals like components, props, state, and hooks.
You'll also need Node.js, a code editor, and accounts on GitHub and Vercel.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to think through the goal now makes every step ahead click into place.
Set Up Next.js and Firebase
Every online shop needs two foundations: a frontend framework to render pages and a database to store product data. For NextShop, that means Next.js for the frontend and Firestore for the database.
In this step, you will scaffold a Next.js project, create a Firebase project with a Firestore database, and wire them together with environment variables and an initialization file. Getting both running now means every step after this one produces visible results.
In this step, get ready to:
- Scaffold a Next.js 16 project and confirm the dev server runs.
- Create a Firebase project with a Cloud Firestore database.
- Connect Firebase to your Next.js project with environment variables and an initialization file.
Scaffold the Next.js project
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new Next.js project by running this command:
npx create-next-app@latest nextshop --yes
What does --yes do?
The --yes flag skips all interactive prompts and accepts the recommended defaults. Your project will include TypeScript, Tailwind CSS, ESLint, and the App Router.
The download and install may take a minute or two depending on your internet connection.
- Once it finishes, move into the project folder by running:
cd nextshop
- Start the development server by running:
npm run dev
What should I see?
Your terminal displays a local URL at http://localhost:3000. The dev server is now running and occupies this terminal window. Leave it running for the rest of the project.
- Open your browser and visit http://localhost:3000.
You should see the default Next.js starter page with the Next.js logo and links to documentation.
Page not loading?
- Make sure the terminal shows a line containing http://localhost:3000 before visiting the URL.
- If port 3000 is already in use, stop any other running servers first, then run npm run dev again.
- Help me debug my Next.js dev server
Create a Firebase project
Firebase is a platform from Google that provides backend services like databases and authentication. You will use its Cloud Firestore database to store and retrieve product data for NextShop.
- Open console.firebase.google.com in your browser and sign in with your Google account.
- Click Add project.
- Enter nextshop as the project name and click Continue.
- Toggle off Google Analytics (you do not need it for this project).
- Click Create project and wait for it to finish provisioning.
- Click Continue to enter your new project dashboard.
Your Firebase project is now ready. Next, you will create the Firestore database inside it.
- In the left sidebar, go to Databases & Storage, then select Firestore.
- Click Create database.
- Choose Standard edition and click Next.
- Select the region closest to your location and click Next.
- Select Start in test mode and click Create.
Why test mode?
Test mode allows any read and write to your database without authentication. This is perfect for development, but you should never ship a production app with test-mode rules.
Your empty Firestore database is now live. The last Firebase step is to register a web app so you can get the configuration values your Next.js project needs.
- In the left sidebar, click Project Overview to return to the main dashboard.
- Click the web app icon (</>) to add a new web app.
- Enter nextshop as the app nickname.
- Leave Also set up Firebase Hosting unchecked.
- Click Register app.
- Firebase displays a code snippet containing your configuration values (apiKey, authDomain, projectId, etc.). Copy these values somewhere handy. You will paste them into your project in the next substep.
- Click Continue to console.
Keep your config values accessible
Paste the config values into a temporary text file or a notes app. You will need all six values in the next substep.
Connect Firebase to the project
Your Firestore database is running in the cloud. Now you need to tell your Next.js app how to connect to it. That means storing your Firebase config in environment variables and writing an initialization file.
- Your dev server is still running in your first terminal. Open a new terminal tab or window.
- Move into your project folder by running:
cd ~/Desktop/nextshop
- Install the Firebase SDK by running:
npm install firebase@12.18.0
You should see the install complete with a confirmation message in your terminal.
Install failed?
- Make sure you are inside the nextshop folder. Run pwd to check your current directory.
- If you see a permissions error, try running the command with sudo on macOS/Linux.
- Help me fix my Firebase install
- In your code editor, create a new file called .env.local in the root of the nextshop folder (the same level as package.json).
- Paste the following into .env.local, replacing each placeholder with the matching value from your Firebase config:
NEXT_PUBLIC_FIREBASE_API_KEY=your-api-key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-auth-domain
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-storage-bucket
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your-messaging-sender-id
NEXT_PUBLIC_FIREBASE_APP_ID=your-app-id
Why the NEXT_PUBLIC_ prefix?
Next.js only exposes environment variables to the browser if they start with NEXT_PUBLIC_. Without this prefix, the variables would only be available on the server and the Firebase client SDK would not be able to read them.
- Save .env.local.
- Create a lib folder in the root of nextshop, then create a new file called firebase.ts inside it.
- Paste the following code into lib/firebase.ts:
import { initializeApp, getApps, getApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
// Read Firebase config from environment variables
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
// Only initialize the app if it hasn't been initialized yet
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
// Create a Firestore database instance
const db = getFirestore(app);
export { db };
What does this code do?
- firebaseConfig reads each environment variable you set in .env.local and passes them to Firebase.
- getApps().length === 0 checks whether Firebase has already been initialized. Next.js server components can re-execute modules, so this guard prevents a "Firebase App already initialized" error.
- db is your Firestore database instance. Every file that needs to read or write data will import db from this file.
- Save lib/firebase.ts.
Before moving on, what do you expect to see if you check the dev server terminal after adding these new files?
- Switch to the terminal where npm run dev is running.
- Confirm there are no error messages in the output.
- In your second terminal, confirm lib/firebase.ts exists by running:
ls lib/
You should see firebase.ts in the output.
Seeing errors in the dev server?
- If the error mentions a missing module, make sure npm install firebase@12.18.0 completed successfully.
- If the error mentions environment variables, confirm .env.local is in the root of nextshop (not inside lib/ or app/). You may need to restart the dev server with Ctrl+C then npm run dev for environment variable changes to take effect.
- Help me debug my Firebase setup in Next.js
✔️ Awesome, I've got everything!
Great. Double check that you have saved both .env.local and lib/firebase.ts.
ⓧ I'd like to double check the full code
Here is the complete lib/firebase.ts file. Make sure yours matches exactly:
import { initializeApp, getApps, getApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
const db = getFirestore(app);
export { db };
Your .env.local should have six lines, each starting with NEXT_PUBLIC_FIREBASE_ and containing your own Firebase config values.
Your Next.js project and Firebase database are wired up and ready. Next up, you will seed Firestore with product data and build a product catalog that pulls it onto the homepage.
Build the Product Catalog
In the last step, you scaffolded your Next.js project and connected it to a live Firestore database. Everything is wired up, but the shop itself is still completely empty.
An empty shop teaches nothing. In this step, you will seed Firestore with product data, build a data-fetching module, and render a responsive grid of product cards on the homepage.
In this step, get ready to:
- Seed Firestore with six sample product documents.
- Create a data-fetching module in lib/products.ts.
- Rewrite the homepage to display a server-rendered product grid.
Seed Firestore with sample products
Your Firestore database has no data yet. Before you can display products in the app, you need to add product documents to Firestore manually through the Firebase Console.
- Return to the Firebase Console in your browser and navigate to Firestore Database in the left sidebar.
- Click Start collection.
- Enter products as the Collection ID.
- Click Next.
Firestore now asks you to create the first document in the collection. Each document represents one product in your shop.
- Click Auto ID to let Firestore generate a document ID for you.
- Add these six fields to the document, setting each field's type carefully:
- name (string): Wireless Headphones.
- slug (string): wireless-headphones.
- description (string): Premium wireless headphones with noise cancellation and 30-hour battery life.
- price (number): 79.99.
- image (string): 🎧.
- category (string): Electronics.
- Click Save.
Your first product document appears in the data viewer. Now repeat the process for the remaining five products.
- Click Add document inside the products collection.
- Click Auto ID and add the same six fields (name, slug, description, price, image, category) using the data from the reference list below.
- Click Save after each document.
Product data reference
Use these values for the remaining five products. Remember to set price as a number type, not a string.
- Running Sneakers: slug running-sneakers, category Footwear, price 59.99, image 👟, description: Lightweight running sneakers with cushioned soles for all-day comfort.
- Smart Watch: slug smart-watch, category Electronics, price 199.99, image ⌚, description: Feature-packed smart watch with fitness tracking and notification alerts.
- Leather Backpack: slug leather-backpack, category Accessories, price 89.99, image 🎒, description: Genuine leather backpack with padded laptop compartment and multiple pockets.
- Coffee Maker: slug coffee-maker, category Home, price 49.99, image ☕, description: Programmable coffee maker with built-in grinder and thermal carafe.
- Yoga Mat: slug yoga-mat, category Fitness, price 29.99, image 🧘, description: Non-slip yoga mat made from eco-friendly materials with alignment guides.
After adding all six documents, your products collection should show six rows in the data viewer.
Why emojis for product images?
Using emoji strings instead of real image URLs keeps the project focused on Next.js routing and Firestore data fetching. It avoids the complexity of configuring remote image patterns, downloading placeholder images, or setting up Firebase Storage.
Don't see all six documents?
Double-check that you clicked Save after each document. If a document is missing a field, click the document in the data viewer and use Add field to add it.
Make sure price is set to the number type, not string. If it has quotes around the value in the viewer, delete the field and re-add it as a number.
Having trouble with the Firestore console?
Create the product data layer
Your Firestore database now has product data. To use that data in your Next.js app, you need a TypeScript module that knows how to query Firestore and return typed product objects.
- Create a new file called products.ts inside the lib folder (next to firebase.ts).
- Paste in the Product type definition and the getProducts() function by copying this code:
import { collection, getDocs, query, where } from "firebase/firestore";
import { db } from "./firebase";
// Define the shape of a product document from Firestore
export type Product = {
id: string;
name: string;
slug: string;
description: string;
price: number;
image: string;
category: string;
};
// Fetch every product in the collection
export async function getProducts(): Promise<Product[]> {
const productsRef = collection(db, "products");
const snapshot = await getDocs(productsRef);
return snapshot.docs.map((d) => ({
id: d.id,
...d.data(),
})) as Product[];
}
What does this code do?
- The Product type defines the exact shape of each product document. TypeScript will catch mistakes if you try to access a field that does not exist.
- getProducts() grabs a reference to the products collection, fetches all documents with getDocs, and maps each document into a typed Product object.
- The id: d.id line pulls the auto-generated document ID from Firestore and attaches it to the product object. The rest of the fields come from d.data().
- Save lib/products.ts.
- Check your terminal where the dev server is running. It should show no errors.
You will also need a function that fetches a single product by its URL-friendly slug. You will use this in the next step when you build individual product pages.
- Add the getProductBySlug() function below getProducts() in the same file:
// Fetch a single product matching the given URL slug
export async function getProductBySlug(
slug: string
): Promise<Product | null> {
const productsRef = collection(db, "products");
const q = query(productsRef, where("slug", "==", slug));
const snapshot = await getDocs(q);
if (snapshot.empty) return null;
const productDoc = snapshot.docs[0];
return { id: productDoc.id, ...productDoc.data() } as Product;
}
What does this code do?
- query() with where("slug", "==", slug) builds a Firestore query that filters documents down to the one whose slug field matches the URL parameter.
- If no document matches, the function returns null. Your page component will use that to show a 404.
- Save lib/products.ts.
- Confirm the terminal still shows no errors from the dev server.
Seeing a module not found error?
Make sure the file is saved at lib/products.ts inside your nextshop folder, not inside app/ or the project root.
Check that lib/firebase.ts exists and exports db. The import from "./firebase" expects that file to be in the same lib/ directory.
Help me fix a module import error in my products.ts file.
✔️ Awesome, I've got everything!
Great. Double check that lib/products.ts is saved and your dev server is running without errors.
ⓧ I'd like to double check the full code
Your lib/products.ts file should contain these sections in order. Scroll up to confirm each one is present:
- Imports from firebase/firestore (collection, getDocs, query, where) and db from ./firebase.
- The Product type with seven fields (id, name, slug, description, price, image, category).
- The getProducts() function that fetches all documents from the products collection.
- The getProductBySlug() function that queries for a single product by its slug.
Build the product grid
The data layer is ready. Now you need to replace the default Next.js homepage with a server component that calls getProducts() and renders the results as a styled, responsive product grid.
What is a server component?
In Next.js, every component inside the app/ directory is a server component by default. Server components run on the server, not in the browser. This means they can directly call databases, read files, or fetch APIs without exposing that logic to the client.
Because the homepage fetches from Firestore, running it as a server component means the database call happens on the server and the browser receives ready-to-display HTML.
- Open app/page.tsx in your code editor.
- Select all the existing code and delete it.
- Paste in the page setup, including the imports, data fetch, and grid structure:
import Link from "next/link";
import { getProducts } from "@/lib/products";
// Force dynamic rendering so products are fetched fresh each request
export const dynamic = "force-dynamic";
export default async function Home() {
// Fetch all products from Firestore on the server
const products = await getProducts();
return (
<div>
<h1 className="text-3xl font-bold mb-8">Our Products</h1>
{/* Responsive grid: 1 column on mobile, 2 on tablet, 3 on desktop */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
className="group block bg-gray-900 rounded-lg overflow-hidden border border-gray-800 hover:border-indigo-600 transition-colors"
>
- Directly below the opening <Link> tag, paste the product card content that completes the component:
{/* Product emoji as the card image */}
<div className="aspect-square bg-gray-800 flex items-center justify-center text-6xl">
{product.image}
</div>
{/* Product name, category, and price */}
<div className="p-4">
<h2 className="text-lg font-semibold group-hover:text-indigo-400 transition-colors">
{product.name}
</h2>
<p className="text-gray-400 text-sm mt-1">{product.category}</p>
<p className="text-indigo-400 font-bold mt-2">
${product.price.toFixed(2)}
</p>
</div>
</Link>
))}
</div>
</div>
);
}
What does this page do?
- export const dynamic = "force-dynamic" tells Next.js to fetch fresh data on every request instead of caching the page at build time.
- The async keyword on Home() makes this a server component that awaits the Firestore fetch before rendering.
- Each product card is a Link component pointing to /products/${product.slug}. Clicking a card will navigate to that product's detail page (which you will build in the next step).
- The Tailwind CSS grid classes create a responsive layout: one column on mobile, two on tablet (sm:grid-cols-2), and three on desktop (lg:grid-cols-3).
- Save app/page.tsx.
Before you check the browser, what do you expect to see? Will the default Next.js starter page still be there, or something new?
- Switch to your browser and visit http://localhost:3000.
Six product cards appear in a responsive grid. Each card shows an emoji, the product name, category, and price. The data is coming live from your Firestore database.
Seeing an error or a blank page?
- Check your terminal for error messages. A red error about Cannot find module means the import path in app/page.tsx does not match your file location. Confirm lib/products.ts exists.
- If you see the heading "Our Products" but no cards, your Firestore products collection may be empty. Return to the Firebase Console and verify six documents exist.
- If you see a PERMISSION_DENIED error, your Firestore security rules may have expired. Open the Rules tab in the Firebase Console and make sure test mode rules are still active.
- Make sure the six environment variables in .env.local match your Firebase project. If you changed them, restart the dev server with Ctrl+C then npm run dev.
My product grid is not showing up. Help me debug.
✔️ Awesome, I've got everything!
Great. Confirm app/page.tsx is saved and you can see all six product cards in the browser.
ⓧ I'd like to double check the full code
Your app/page.tsx file should contain these sections in order. Scroll up to confirm each one is present:
- Imports for Link from next/link and getProducts from @/lib/products.
- export const dynamic = "force-dynamic" on its own line.
- An async Home() function that calls await getProducts().
- A grid div with classes grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6.
- A Link inside the map pointing to /products/${product.slug}, containing an image area and a details section with name, category, and price.
Your product catalog is live. The homepage pulls real data from Firestore and displays it in a clean, responsive grid. Next up, you will make those product cards clickable by building individual product detail pages with dynamic routes.
Add Dynamic Product Pages
Your shop homepage is looking great with six product cards loaded straight from Firestore. Right now, though, clicking any product card leads to a 404 error because there is no page at /products/[slug] yet.
Next.js solves this with dynamic routes. Instead of creating a separate page file for every single product, you create one [slug] folder that handles all of them automatically.
In this step, get ready to:
- Add a function to look up a single product from Firestore by its slug.
- Create a dynamic product detail page that renders any product at its own URL.
Add the product lookup function
Your lib/products.ts file can fetch all products, but it has no way to fetch just one. You need a function that queries Firestore for a single product matching a specific slug value.
- In lib/products.ts, add this function below the existing getProducts function:
export async function getProductBySlug(
slug: string
): Promise<Product | null> {
const productsRef = collection(db, "products");
// Query for a product where the slug field matches
const q = query(productsRef, where("slug", "==", slug));
const snapshot = await getDocs(q);
// Return null if no matching product found
if (snapshot.empty) return null;
const productDoc = snapshot.docs[0];
return { id: productDoc.id, ...productDoc.data() } as Product;
}
What does this function do?
- query and where build a Firestore query that filters the products collection to find documents where the slug field matches the URL parameter.
- If no document matches, the function returns null. The page component will use this to show a 404.
- The imports for query and where are already at the top of your file from the previous step.
- Save lib/products.ts.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
Your lib/products.ts should now contain, in order:
- The imports for collection, getDocs, query, and where from Firestore, plus the db import.
- The Product type definition with all seven fields.
- The getProducts() function from the previous step.
- The new getProductBySlug() function you just added.
Create the product detail page
Dynamic routes in Next.js use folders with square brackets in the name. A folder named [slug] tells Next.js to match any URL segment in that position and pass it to the page component as a parameter.
What is a dynamic route?
When someone visits /products/wireless-headphones, Next.js matches the [slug] folder and passes { slug: "wireless-headphones" } to the page component. One folder handles all six products.
- Inside the app folder, create a new folder called products.
- Inside products, create another folder called [slug]. Type the square brackets as part of the folder name.
- Inside [slug], create a file called page.tsx and paste the following code:
import { getProductBySlug } from "@/lib/products";
import { notFound } from "next/navigation";
// Fetch fresh data on every request
export const dynamic = "force-dynamic";
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
// Extract the slug from the URL params
const { slug } = await params;
// Fetch the matching product from Firestore
const product = await getProductBySlug(slug);
// Show a 404 page if no product matches the slug
if (!product) {
notFound();
}
return (
<div className="max-w-2xl mx-auto">
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<div className="aspect-square bg-gray-800 flex items-center justify-center text-8xl">
{product.image}
</div>
What does this code do?
- params is a Promise in Next.js 16. You must await it before accessing the slug value.
- export const dynamic = "force-dynamic" tells Next.js to fetch fresh data from Firestore on every request instead of caching a static version.
- notFound() triggers the built-in Next.js 404 page if no product matches the slug in the URL.
- Directly below the image div (after the closing </div> on the line with {product.image}), add the product info section and close out the component:
{/* Product details and actions */}
<div className="p-6">
<p className="text-indigo-400 text-sm font-medium mb-1">
{product.category}
</p>
<h1 className="text-2xl font-bold mb-2">{product.name}</h1>
<p className="text-gray-400 mb-4">{product.description}</p>
<p className="text-3xl font-bold text-indigo-400 mb-6">
${product.price.toFixed(2)}
</p>
{/* Placeholder button - wired up in the next step */}
<button className="w-full bg-indigo-600 text-white py-3 px-6 rounded-lg hover:bg-indigo-700 transition-colors font-medium">
Add to Cart
</button>
</div>
</div>
</div>
);
}
What does this section render?
- The category label, product name, description, and price are all pulled from the Firestore document and displayed with Tailwind CSS styling.
- The Add to Cart button is a plain HTML <button> with no click handler. It does nothing yet. You will wire it up in the next step.
- Save app/products/[slug]/page.tsx.
Before you click a product card, what do you expect to see? Will it still show a 404, or will you see the product details?
- Switch back to your browser at http://localhost:3000 and click any product card.
The browser navigates to a URL like /products/wireless-headphones and you see the full product detail page. A large emoji sits at the top, followed by the category label, product name, description, price, and the Add to Cart button.
Seeing a 404 or an error?
- Make sure the folder is named exactly [slug] with square brackets. Without the brackets, Next.js treats it as a static route that only matches the literal word "slug".
- Check that your file path is app/products/[slug]/page.tsx. A misplaced folder level means Next.js cannot find the page.
- Verify that the slug values in your Firestore documents match the URLs your product cards link to. For example, the card for Wireless Headphones links to /products/wireless-headphones, so the Firestore document needs slug: "wireless-headphones".
Why is my product detail page not loading?
✔️ 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
Your app/products/[slug]/page.tsx should contain, in order:
- The imports for getProductBySlug and notFound.
- export const dynamic = "force-dynamic".
- The ProductPage async function that awaits params, calls getProductBySlug, and checks for notFound().
- The return JSX with the image area (first code block you pasted) followed by the product info section with the Add to Cart button (second code block you pasted).
Every product in your shop now has its own detail page, all powered by a single [slug] folder. Next up, you will make that Add to Cart button actually work by building a shopping cart with React Context.
Build the Shopping Cart
Your product detail pages look great, and each one has an "Add to Cart" button. There is one problem: clicking it does nothing.
The button is sitting inside a server component, and server components cannot handle user interactions like clicks or manage state. In this step, you will solve that by building a cart system with React Context and your first 'use client' components.
In this step, get ready to:
- Create a CartProvider context and a navigation header with a cart icon.
- Build an AddToCartButton client component and a /cart page.
- Test the full add-to-cart, remove, and navigation flow.
Before building anything, take a moment to experience the limitation firsthand.
- In your browser at http://localhost:3000, click any product card to open its detail page.
- Click the Add to Cart button.
Nothing happens. The button has no behavior attached to it at all.
Why doesn't the button work?
The product page (app/products/[slug]/page.tsx) is a server component. Server components run on the server and send finished HTML to the browser. They cannot use onClick, useState, or any browser APIs.
To make the button interactive, you need to extract it into a separate 'use client' component that runs in the browser.
Create the cart provider and navigation
The cart needs to be accessible from every page in the shop. React Context lets you share cart state across the entire component tree without passing props through every level.
- In your code editor, create a new folder called components inside your nextshop project folder.
- Inside components, create a new file called CartProvider.tsx.
- Start building the cart context by pasting the type definitions and context setup:
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
import { Product } from "@/lib/products";
// A cart item is a product with a quantity
type CartItem = Product & { quantity: number };
// Shape of the values the cart context provides
type CartContextType = {
items: CartItem[];
addToCart: (product: Product) => void;
removeFromCart: (productId: string) => void;
totalItems: number;
totalPrice: number;
};
// Create the context with undefined default
const CartContext = createContext<CartContextType | undefined>(undefined);
What does this code do?
- "use client" at the top marks this file as a client component. Everything in this file runs in the browser, which means it can use useState and other React hooks.
- CartItem extends your existing Product type with a quantity field, so each cart item knows how many the shopper wants.
- CartContextType defines the interface that any component using the cart will have access to: the items array, add/remove functions, and computed totals.
- Below the context creation, add the CartProvider component with the addToCart function:
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
// Add a product, or increment quantity if already in cart
const addToCart = (product: Product) => {
setItems((prev) => {
const existing = prev.find((item) => item.id === product.id);
if (existing) {
return prev.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
};
What does addToCart do?
When a shopper clicks "Add to Cart", this function checks if the product is already in the cart. If it is, it increments the quantity by one. If it is not, it adds a brand new entry with a quantity of 1.
- Still in CartProvider.tsx, add the removeFromCart function, computed totals, the provider return, and the useCart hook below addToCart:
// Remove a product from the cart entirely
const removeFromCart = (productId: string) => {
setItems((prev) => prev.filter((item) => item.id !== productId));
};
// Compute totals from the current items
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return (
<CartContext.Provider
value={{ items, addToCart, removeFromCart, totalItems, totalPrice }}
>
{children}
</CartContext.Provider>
);
}
// Custom hook for components to access the cart
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart must be used within a CartProvider");
}
return context;
}
What does the rest of the provider do?
- removeFromCart filters out the product entirely. It does not decrement the quantity.
- totalItems and totalPrice are computed from the items array using reduce. They recalculate automatically whenever the cart changes.
- The useCart hook gives any child component easy access to the cart context. It also throws a helpful error if used outside the provider.
- Save CartProvider.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved components/CartProvider.tsx before moving on.
ⓧ I'd like to double check the full code
The full file is built from the three chunks above. Scroll up and confirm each section is present in order:
- The "use client" directive, imports, CartItem type, CartContextType, and CartContext creation.
- The CartProvider function opening with useState and addToCart.
- removeFromCart, computed totals, the <CartContext.Provider> return, and the useCart hook.
Next, create the cart icon that reads from the context and shows a badge with the item count.
- Inside the components folder, create a new file called CartIcon.tsx.
- Build the cart icon component by pasting:
"use client";
import Link from "next/link";
import { useCart } from "./CartProvider";
// Displays a "Cart" link with a badge showing the item count
export default function CartIcon() {
const { totalItems } = useCart();
return (
<Link href="/cart" className="relative text-gray-300 hover:text-white transition-colors">
Cart
{totalItems > 0 && (
<span className="absolute -top-2 -right-4 bg-indigo-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{totalItems}
</span>
)}
</Link>
);
}
What does CartIcon do?
CartIcon reads totalItems from the cart context using the useCart hook. When the cart has one or more items, a small indigo badge appears with the count. The whole thing links to /cart.
- Save CartIcon.tsx.
- Check the terminal where your dev server is running. You should see no errors. Both files compile cleanly because nothing imports them yet.
Seeing a TypeScript error in the terminal?
- Make sure CartProvider.tsx and CartIcon.tsx are both inside the components folder at the root of your nextshop project.
- Check that the import path "@/lib/products" in CartProvider.tsx matches the @/ import alias your project uses.
- Help me debug a TypeScript error in CartProvider or CartIcon
Now wire the provider and icon into the app layout so every page has access to the cart.
- Open app/layout.tsx in your code editor.
- Replace the entire contents of the file with the imports, font configuration, and metadata:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { CartProvider } from "@/components/CartProvider";
import CartIcon from "@/components/CartIcon";
import Link from "next/link";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "NextShop",
description: "An online shop powered by Next.js and Firebase",
};
What changed in the imports?
Three new imports join the existing ones: CartProvider to wrap the app in cart state, CartIcon for the nav badge, and Link from next/link for client-side navigation in the header.
- Below the metadata, add the RootLayout component that wraps everything in the CartProvider and adds a navigation header:
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-950 text-gray-100`}
>
<CartProvider>
<header className="border-b border-gray-800 px-6 py-4">
<nav className="max-w-6xl mx-auto flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-white">
NextShop
</Link>
<CartIcon />
</nav>
</header>
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
</CartProvider>
</body>
</html>
);
}
What does the new layout do?
- CartProvider wraps the entire app, so every page and component can access the cart context.
- A <header> with a nav bar appears on every page. It shows "NextShop" as a home link on the left and the CartIcon on the right.
- The <main> element keeps page content centered and padded within the layout.
- Save app/layout.tsx.
- Refresh your browser at http://localhost:3000. You should see a dark header with "NextShop" on the left and "Cart" on the right.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/layout.tsx before moving on.
ⓧ I'd like to double check the full code
The full file is built from the two chunks above. Scroll up and confirm each section is present in order:
- The imports (including CartProvider, CartIcon, Link), font configuration, and metadata.
- The RootLayout component wrapping children in CartProvider, with a <header> containing the nav bar and CartIcon.
Don't see the header?
- Make sure you replaced the entire contents of app/layout.tsx, not just appended to the existing layout.
- Check that CartProvider.tsx and CartIcon.tsx are saved in the components folder (not app/components).
- Help me debug the missing header
Wire up the add-to-cart button and build the cart page
The header is in place and the cart context is live. Now you need a button that actually calls addToCart, and a page where shoppers can view their cart.
- Inside the components folder, create a new file called AddToCartButton.tsx.
- Build the interactive button by pasting:
"use client";
import { useCart } from "./CartProvider";
import { Product } from "@/lib/products";
// A client component that handles the add-to-cart click
export default function AddToCartButton({ product }: { product: Product }) {
const { addToCart } = useCart();
return (
<button
onClick={() => addToCart(product)}
className="w-full bg-indigo-600 text-white py-3 px-6 rounded-lg hover:bg-indigo-700 transition-colors font-medium"
>
Add to Cart
</button>
);
}
Why is this a separate component?
The product detail page is a server component that fetches data from Firestore. It cannot use onClick. By extracting the button into its own 'use client' file, you keep the page as a server component (good for performance) while making just the button interactive.
The product prop is serializable data (strings, numbers), so Next.js can safely pass it from the server component to this client component.
- Save AddToCartButton.tsx.
Now swap the plain HTML button in the product detail page for this new interactive component.
- Open app/products/[slug]/page.tsx.
- Find the import lines at the top of the file:
import { getProductBySlug } from "@/lib/products";
import { notFound } from "next/navigation";
- Add a new import for AddToCartButton right below those two lines, so the imports now look like this:
import { getProductBySlug } from "@/lib/products";
import { notFound } from "next/navigation";
import AddToCartButton from "@/components/AddToCartButton";
- Scroll down to find the plain HTML button near the bottom of the file:
<button className="w-full bg-indigo-600 text-white py-3 px-6 rounded-lg hover:bg-indigo-700 transition-colors font-medium">
Add to Cart
</button>
- Replace those three lines with the AddToCartButton component, so the line now reads:
<AddToCartButton product={product} />
- Save app/products/[slug]/page.tsx.
- Refresh a product detail page in your browser and click Add to Cart.
The "Cart" link in the top-right corner now shows a small indigo badge with the number 1. Click Add to Cart again and the badge updates to 2.
Badge not appearing?
- Make sure you removed all three lines of the old <button> element, including the opening tag, the text, and the closing tag. The replacement is a single self-closing <AddToCartButton product={product} /> line.
- Check that the import path is @/components/AddToCartButton, not ./components/AddToCartButton.
- Help me debug the missing cart badge
The last piece is a dedicated cart page where shoppers can see everything they have added.
- Inside the app folder, create a new folder called cart.
- Inside app/cart, create a new file called page.tsx.
- Start building the cart page with the imports, function setup, and empty-state view:
"use client";
import { useCart } from "@/components/CartProvider";
import Link from "next/link";
export default function CartPage() {
const { items, removeFromCart, totalPrice } = useCart();
// Show empty state when cart has no items
if (items.length === 0) {
return (
<div className="text-center py-16">
<p className="text-xl text-gray-400 mb-4">Your cart is empty</p>
<Link
href="/"
className="text-indigo-400 hover:text-indigo-300 underline"
>
Continue shopping
</Link>
</div>
);
}
What does this section do?
This is a 'use client' page because it reads live cart state with useCart. The early return handles the empty cart gracefully with a message and a link back to the shop.
- Below the empty-state block, add the main return that renders each cart item as a card:
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Your Cart</h1>
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded-lg p-4"
>
<div className="flex items-center gap-4">
<span className="text-3xl">{item.image}</span>
<div>
<h2 className="font-semibold">{item.name}</h2>
<p className="text-gray-400 text-sm">Qty: {item.quantity}</p>
</div>
</div>
<div className="flex items-center gap-4">
<p className="font-bold text-indigo-400">
${(item.price * item.quantity).toFixed(2)}
</p>
<button
onClick={() => removeFromCart(item.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Remove
</button>
</div>
</div>
))}
</div>
What does the items list render?
Each cart item gets a card showing the emoji image, product name, quantity, and the line total (price times quantity). A red "Remove" button calls removeFromCart to drop the item from the cart entirely.
- Below the items list </div>, add the totals section and close the component:
<div className="border-t border-gray-800 mt-6 pt-6 flex justify-between items-center">
<p className="text-xl font-bold">Total: ${totalPrice.toFixed(2)}</p>
<button className="bg-indigo-600 text-white py-3 px-8 rounded-lg hover:bg-indigo-700 transition-colors font-medium">
Checkout
</button>
</div>
</div>
);
}
What does the totals section show?
A border separates the item list from the totals. The total price updates live as items are added or removed. The Checkout button is a placeholder for now.
- Save app/cart/page.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/cart/page.tsx before moving on.
ⓧ I'd like to double check the full code
The full file is built from the three chunks above. Scroll up and confirm each section is present in order:
- The "use client" directive, imports, function setup, and empty-state if block.
- The main return with the heading, items.map rendering each cart item card, and the closing </div> for the items list.
- The totals section with total price, Checkout button, and the closing braces of the component.
Test the full cart flow
Every piece is in place. Time to test the entire shopping flow from browsing to viewing the cart.
- Go to http://localhost:3000 in your browser.
Before you start clicking, what do you think will happen when you add the same product twice? Will you see two separate entries, or one entry with a higher quantity?
- Click any product card to open its detail page.
- Click Add to Cart.
- Check the badge on "Cart" in the header. It should show 1.
- Click Add to Cart again on the same product.
- The badge should update to 2. The addToCart function detected the duplicate and incremented the quantity instead of adding a second entry.
- Click NextShop in the header to go back to the homepage.
- Click a different product and add it to the cart. The badge should now show 3.
- Click Cart in the header to navigate to /cart.
You should see two items listed. The first product shows "Qty: 2" with its line total doubled. The second shows "Qty: 1" with its regular price. The total at the bottom reflects the sum of all items.
- Click Remove next to one of the items.
The item disappears from the list and the total updates instantly.
Cart page showing "Your cart is empty"?
- Cart state lives in memory only. If you refreshed the browser before navigating to /cart, the state resets. Add a product, then navigate to /cart without refreshing.
- Make sure app/layout.tsx wraps {children} inside <CartProvider>. If the provider is missing, the cart context resets on every navigation.
- Help me fix my empty cart page
Your shop now has a fully working cart. Next up, you will deploy the whole thing to Vercel so anyone can visit it.
Deploy to Vercel
Your NextShop works perfectly on your machine. Products load from Firestore, detail pages render with dynamic routes, and the cart tracks items with React Context.
There is one problem. Only you can see it. Deploying to Vercel gives you a live URL anyone can visit, and it automatically redeploys every time you push to GitHub.
In this step, get ready to:
- Push your NextShop code to a GitHub repository.
- Deploy the project to Vercel with your Firebase environment variables.
- Test the live shop and confirm automatic deployments.
Push your code to GitHub
Before Vercel can deploy your app, it needs access to your code through a Git repository. You will initialize a local Git repo, push it to GitHub, then connect it to Vercel.
- Switch back to the terminal where your dev server is running.
- Press Ctrl+C to stop the dev server.
- Initialize a Git repository and commit all your files by running these commands:
git init
git add .
git commit -m "feat: nextshop complete"
You should see a summary listing the files that were committed.
What about .env.local?
Next.js includes .env*.local in its .gitignore by default. Your Firebase config values are excluded from the commit automatically, so they will never appear in your public repository.
Now create a new repository on GitHub to host your code.
- Open github.com in your browser.
- Click the + icon in the top right corner.
- Click New repository.
- Enter nextshop as the Repository name.
- Leave it as Public.
- Do NOT check any of the initialization options (README, .gitignore, or license).
- Click Create repository.
GitHub shows an empty repository page with setup instructions. You already have a local repo, so you just need to connect it.
- Connect your local repository to GitHub and push your code by running these commands:
git remote add origin https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/nextshop.git
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin links your local repo to the GitHub repository you just created.
- git branch -M main renames the default branch to main.
- git push -u origin main uploads all your committed files to GitHub.
- Refresh your GitHub repository page in the browser.
You should see all your project files listed, including the app/, components/, and lib/ folders. Notice that .env.local is not listed. It was excluded by .gitignore.
Push failed?
- If Git asks for a username and password, GitHub no longer accepts passwords for HTTPS pushes. You need a Personal Access Token. Go to GitHub Settings, then Developer settings, then Personal access tokens to create one.
- Double check that the repository name in the git remote add command matches the one you created on GitHub exactly.
Help me troubleshoot my git push to GitHub.
Deploy to Vercel
Your code is on GitHub. Now you will import it into Vercel, add your Firebase environment variables, and deploy.
- Open vercel.com in your browser and sign in.
- Click the Add New... button.
- Choose Project from the list.
- Find your nextshop repository in the Import Git Repository section and click Import.
Vercel auto-detects that your project uses Next.js. Before you deploy, you need to add the Firebase environment variables that .gitignore excluded from your repository.
- Scroll down to the Environment Variables section on the configure page.
- Open your .env.local file in your code editor for reference.
- For each of the six variables below, enter the variable name in the Name field, paste its value from .env.local into the Value field, then click Add:
- NEXT_PUBLIC_FIREBASE_API_KEY
- NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
- NEXT_PUBLIC_FIREBASE_PROJECT_ID
- NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
- NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
- NEXT_PUBLIC_FIREBASE_APP_ID
Why add these again?
Your .env.local file only exists on your machine. Vercel needs its own copy of these values to connect to Firestore when it builds your app. Without them, the app would deploy but show zero products because the Firebase connection would fail.
- Confirm all six variables are listed, then click Deploy.
Vercel begins building your project. This takes about a minute. You can watch the build logs in real time on the deployment page.
When the build completes, Vercel shows a congratulations screen with a preview of your site and a production URL.
Build failed?
- Check the build log for missing environment variables. If any of the six NEXT_PUBLIC_FIREBASE_ variables are missing or misspelled, the build may fail or the app will not connect to Firestore.
- Make sure you copied the full value for each variable, with no extra spaces at the start or end.
Help me debug my Vercel build failure.
Before you click the link, think about what you expect to see. Will the product catalog load with real Firestore data? Will the cart work?
- Click the production URL (or the Visit button) to open your live shop.
- Browse the product grid on the homepage. All six products should appear with names, categories, and prices from Firestore.
- Click a product card to verify the detail page loads at its dynamic URL.
- Click Add to Cart and confirm the cart count in the nav bar increments.
- Navigate to /cart and verify items appear with correct prices and a total.
Your entire shop is live on the internet. The product catalog, dynamic routes, and shopping cart all work exactly as they did on localhost.
One last thing to confirm. Vercel auto-deploys every push to your main branch. Try it now.
- Return to your terminal.
- Make a small edit to confirm auto-deploy works by running these commands:
echo "# NextShop" > README.md
git add README.md
git commit -m "docs: update readme"
git push
- Return to your Vercel dashboard in the browser.
You should see a new deployment building automatically. Vercel detected the push to main and started a fresh build with no manual action needed.
How does auto-deploy work?
When you imported your GitHub repository, Vercel installed a GitHub App that watches your main branch. Every push to main triggers a new production deployment automatically. No manual clicks needed.
Secret mission
Add Category Filtering
Your shop shows every product at once, but real online shops let visitors filter by category. Can you add filter buttons that update the URL and narrow down the product grid using Firestore queries?
Clean Up Your Resources
Clean Up Your Resources
This project runs on the Firebase Spark plan and the Vercel Hobby plan, both of which are free. There are no ongoing costs, so you can safely leave everything running.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local nextshop project folder on your Desktop.
- Firebase project ("nextshop") with a Firestore database and products collection.
- GitHub repository (nextshop).
- Vercel project connected to the GitHub repository.
Keep everything running
No action needed. Choose this if you are still actively building on this project or plan to extend it with Firebase Auth in the follow-on project.
- Your Vercel deployment stays live at its .vercel.app URL at no cost.
- Your Firestore database remains available on the free Spark plan.
- Your GitHub repository stays accessible for future pushes.
Pause - I'll come back to this later
Shut down running processes to free up local resources while keeping your cloud services intact for later.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
- Your Vercel deployment, Firebase project, and GitHub repository will remain available when you return. No cloud costs accumulate on the free plans.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to clean everything up.
Delete the Vercel project:
- Go to your Vercel dashboard at vercel.com/dashboard and select your nextshop project.
- Click Settings in the sidebar.
- Scroll to the bottom of the General page and find the Delete Project section.
- Click Delete, type your project name to confirm, then click Continue.
Delete the GitHub repository:
- Go to your nextshop repository on GitHub.
- Click Settings (under the repository name).
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository, then click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
Delete the Firebase project:
- Go to the Firebase Console at console.firebase.google.com and select your nextshop project.
- Click the gear icon and select Project settings.
- Scroll to the bottom and click Delete project.
- Follow the confirmation prompts to complete the deletion. This removes the Firestore database and all associated data.
Delete the local project folder:
- Delete the nextshop folder from your Desktop by running this command:
macOS
cd ~/Desktop
rm -rf nextshop
Windows
cd ~\Desktop
rmdir /s /q nextshop
Nice Work!
Nice Work!
Nice work! You just built and deployed a full online shop powered by Next.js and Firestore.
You've learned how to:
- Build a product catalog that fetches live data from Firestore in an async server component, and create individual product pages with dynamic [slug] route segments.
- Create a shopping cart using React Context and 'use client' components, experiencing the server/client component boundary firsthand.
- Deploy the shop to Vercel with automatic GitHub deployments, giving you a live URL that updates every time you push to main.
- Complete an optional Secret Mission to push your skills further.
Ready to quiz yourself?