Build a Product Showcase with Next.js

Build a multi-page Next.js app using Server Components, dynamic routes, and layouts.

Introduction

30 Second Summary

Think about the last time you browsed products on a website. Someone built every page you clicked through, from the catalog grid to the detail view to the search bar that filtered results in real time.

In this project, you will build a product showcase app with Next.js. You will create a product catalog, dynamic detail pages, and a real-time search bar while learning the App Router patterns that interviewers expect you to know.

What You'll Build

You will have a locally-running product showcase where you can browse a grid of products, click into detail pages that stream in behind a loading skeleton, and filter the entire catalog in real time with a search bar.

By the end of this project, you'll have:

  • A home page with a product grid rendered as a Server Component, meaning zero client-side JavaScript ships for the initial view.
  • Dynamic product detail pages at /products/[id] that load data using async params, with built-in loading skeletons and an error boundary that catches errors gracefully with a retry button.
  • A search bar powered by a Client Component that filters products in real time, teaching you exactly where the Server/Client boundary sits.
  • Secret Mission: Add a favorites feature with a heart icon on every product card and a live favorites count in the header that persists across page navigations.

Are there any prerequisites?

This project assumes you are comfortable with React fundamentals like components, props, state, and hooks. You will also need Node.js installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Next.js Project

In plain React, you wire up routes manually using a library like React Router. Next.js takes a different approach with its App Router, where files and folders inside an app/ directory automatically become routes.

To see how that works, you first need a working project. In this step, you will scaffold a Next.js app, explore the files it generates, and start the development server.

In this step, get ready to:
  • Scaffold a Next.js project using create-next-app.
  • Explore the key files the App Router creates.
  • Start the development server and see the default page.
Scaffold the project with create-next-app
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop and scaffold your Next.js project by running these commands:
  • Press Enter if npx asks to install a package before continuing.
cd ~/Desktop
npx create-next-app@latest product-showcase --yes

What does --yes do?

The --yes flag tells create-next-app to skip all interactive prompts and use the recommended defaults.

Your project is automatically set up with TypeScript, Tailwind CSS, ESLint, the App Router, and Turbopack. This may take a minute while packages download and install.

Once the command finishes, you should see a success message in your terminal.

  • Move into your new project folder by running:
cd product-showcase

Seeing an error after the scaffold command?

  • If you see command not found, Node.js may not be installed correctly. You need version 20.9.0 or higher.
  • If the installation hangs or fails with a network error, check your internet connection and try running the command again.
  • Why is my create-next-app command failing?
Explore the project structure
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the product-showcase folder.
  • Click Open.
  • If VS Code asks whether you trust the authors of this folder, click Trust to continue.

In the Explorer sidebar on the left, you can see the files that create-next-app generated. Here are the key ones:

  • app/layout.tsx is the root layout. It wraps every page in your app and must contain the <html> and <body> tags.
  • app/page.tsx is the home page, mapped to the / route.
  • next.config.ts holds the Next.js configuration.
  • package.json lists your project dependencies.

Where is the pages/ folder?

There is no pages/ folder. The App Router uses the app/ folder exclusively for routing. Every file and folder inside app/ maps directly to a URL in your application.

Start the dev server
  • In VS Code, press Ctrl+` (both macOS and Windows) to open the integrated terminal.

What do you think you will see when you start the development server and open it in the browser? Make a prediction before you run it.

  • Start the development server by running:
npm run dev

What does this command do?

npm run dev starts the Next.js development server at http://localhost:3000. The server takes over this terminal window, so you will not be able to type more commands here while it runs.

Press Ctrl+C to stop the server when you need to. For now, leave it running.

  • Open your browser and navigate to http://localhost:3000.

You should see the default Next.js starter page with the Next.js logo and links to documentation. This page is rendered by app/page.tsx.

Not seeing the page?

  • Make sure the npm run dev command is still running in your terminal. If you accidentally closed it, run the command again.
  • Double-check that you typed http://localhost:3000 in your browser's address bar. If port 3000 is in use, the terminal output will show a different port number.
  • Why isn't my Next.js dev server loading?

Your Next.js project is up and running. Next up, you will replace the default page with a product catalog built entirely with Server Components.

Build the Product Catalog Page

Your Next.js project is running, but the home page is still the default starter template. Your visitors need to see actual products, not a "Get started" message.

Every page in the App Router is a Server Component by default. This means you can fetch data with await directly inside the component. No useEffect, no loading state management, no client-side fetch.

In this step, get ready to:
  • Create a product data file with hardcoded product listings.
  • Build a product grid that fetches data as a Server Component.
  • Add a shared navigation layout that wraps every page.
Create the product data

Every product showcase needs products. You will create a data file with a hardcoded array of six products and two async functions to access them.

Why hardcoded data?

Using hardcoded product data keeps the focus on Next.js patterns instead of database setup. The async functions simulate real data access with short delays, so the fetching patterns you learn here transfer directly to a real database or API later.

  • In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder lib.
  • Right-click the new lib folder and select New File.
  • Name the file products.ts.

You should see app/lib/products.ts open in the editor. It is empty.

  • Define the product shape and the first two products by pasting the following into products.ts:
export interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  image: string;
}

const products: Product[] = [
  {
    id: "1",
    name: "Wireless Headphones",
    price: 79.99,
    description:
      "Premium over-ear headphones with active noise cancellation and 30-hour battery life.",
    image: "/images/headphones.jpg",
  },
  {
    id: "2",
    name: "Mechanical Keyboard",
    price: 129.99,
    description:
      "Compact 75% layout with hot-swappable switches and RGB backlighting.",
    image: "/images/keyboard.jpg",
  },
  • Add the next three products directly below the last comma. Paste this code after the Mechanical Keyboard entry:
  {
    id: "3",
    name: "Ultrawide Monitor",
    price: 449.99,
    description:
      "34-inch curved ultrawide display with 144Hz refresh rate and USB-C connectivity.",
    image: "/images/monitor.jpg",
  },
  {
    id: "4",
    name: "Ergonomic Mouse",
    price: 59.99,
    description:
      "Vertical design reduces wrist strain. Features adjustable DPI and silent clicks.",
    image: "/images/mouse.jpg",
  },
  {
    id: "5",
    name: "USB-C Hub",
    price: 39.99,
    description:
      "7-in-1 hub with HDMI, USB-A, USB-C passthrough, SD card reader, and ethernet.",
    image: "/images/hub.jpg",
  },
  • Complete the file by pasting the last product, closing the array, and adding two data access functions below the USB-C Hub entry:
  {
    id: "6",
    name: "Laptop Stand",
    price: 49.99,
    description:
      "Adjustable aluminum stand with ventilation slots. Supports laptops up to 17 inches.",
    image: "/images/stand.jpg",
  },
];

export async function getProducts(): Promise<Product[]> {
  // Simulate async data access
  await new Promise((resolve) => setTimeout(resolve, 100));
  return products;
}

export async function getProductById(
  id: string
): Promise<Product | undefined> {
  // Simulate async data access with a small delay
  await new Promise((resolve) => setTimeout(resolve, 500));
  return products.find((p) => p.id === id);
}

What does this file do?

The Product interface defines the shape of every product object: an id, name, price, description, and an image path.

getProducts() returns all six products after a 100ms simulated delay. getProductById(id) finds a single product by its id with a 500ms delay. Both functions are async to simulate how real database or API calls work.

  • Save products.ts.
  • Confirm the file appears in the Explorer sidebar under app > lib > products.ts.

Don't see the lib folder?

Make sure you created the lib folder inside the app folder, not at the project root. The path should be app/lib/products.ts.

Help me find where my products.ts file should go in the project.

✔️ Awesome, I've got everything!

Great. Double check you have saved products.ts before moving on.

ⓧ I'd like to double check the full code

Your app/lib/products.ts file is over 30 lines, so scroll through it and confirm these sections appear in order:

  • The Product interface with five fields (id, name, price, description, image).
  • A products array containing six product objects (Wireless Headphones through Laptop Stand).
  • An exported getProducts() async function with a 100ms delay.
  • An exported getProductById(id) async function with a 500ms delay.
Build the product grid

With product data ready, you can build the home page. Because page.tsx is a Server Component by default, you will call await getProducts() directly inside the component function.

  • Open app/page.tsx in the Explorer sidebar.
  • Select all the existing code (Cmd+A on macOS, Ctrl+A on Windows) and delete it.
  • Replace it with this Server Component that fetches and displays a heading by pasting the following:
import Link from "next/link";
import { getProducts } from "@/app/lib/products";

export default async function Home() {
  // Fetch all products on the server
  const products = await getProducts();

  return (
    <div>
      <h1 className="text-3xl font-bold text-gray-900 mb-2">Our Products</h1>
      <p className="text-gray-600 mb-6">
        Browse our collection of tech accessories.
      </p>
    </div>
  );
}

What's happening here?

The function is marked async, which lets you use await directly inside the component. This is only possible because Server Components run on the server, not in the browser.

The @/app/lib/products import uses the @/ alias that create-next-app configured in tsconfig.json. It maps to the project root, so you never need fragile relative paths.

  • Save page.tsx and refresh http://localhost:3000 in your browser.

You should see the heading "Our Products" and the subtitle "Browse our collection of tech accessories." on a plain white page.

Seeing an error instead?

Check that app/lib/products.ts is saved and the file path matches the import in page.tsx. The import must be @/app/lib/products (no .ts extension).

Help me debug my page.tsx import error.

The heading proves your Server Component is working and the data file is connected. Now add the product grid that displays all six products.

  • In page.tsx, find the closing </div> tag just before the closing ); at the bottom of the return statement.
  • Add the product grid by pasting the following code just above that </div>:
      <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.id}`}
            className="block bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
          >
            <div className="w-full h-40 bg-gray-100 rounded-md mb-4 flex items-center justify-center text-gray-400">
              {product.name}
            </div>
            <h2 className="text-lg font-semibold text-gray-900">
              {product.name}
            </h2>
            <p className="text-blue-600 font-medium mt-1">
              ${product.price.toFixed(2)}
            </p>
            <p className="text-gray-500 text-sm mt-2 line-clamp-2">
              {product.description}
            </p>
          </Link>
        ))}
      </div>

What does this grid do?

The Tailwind CSS grid classes create a responsive layout: one column on mobile, two on medium screens, three on large screens.

Each product is wrapped in a Link component from next/link. Clicking a card navigates to /products/${product.id}. You will build that detail page in the next step.

Before you refresh, predict: how many product cards do you think will appear on the page?

  • Save page.tsx and refresh your browser.

You should see a grid of six product cards, each showing the product name, price, and description. The cards link to product detail pages that do not exist yet, so clicking one will show a 404 page.

Cards not showing?

Check that the grid <div> is placed inside the outer <div> of the return statement, after the <p> tag and before the closing </div>.

Also verify your products.ts file has the closing ]; after the last product. A missing bracket will cause a syntax error.

Help me fix my product grid.

✔️ Awesome, I've got everything!

Great. Confirm page.tsx is saved before moving on.

ⓧ I'd like to double check the full code

Scroll through app/page.tsx and confirm these sections appear in order:

  • Two imports: Link from next/link and getProducts from @/app/lib/products.
  • An async function Home() that calls await getProducts().
  • An <h1> heading and a <p> subtitle.
  • A grid <div> containing products.map(), with each product wrapped in a Link to /products/${product.id}.
Add a shared layout with navigation

Your product grid is live, but there is no way to navigate back to the home page once you leave it. The layout.tsx file in Next.js wraps every page and persists across navigations. It is the right place for a site-wide header.

  • Open app/layout.tsx in the Explorer sidebar.
  • Find the import lines at the top of the file. Add this new import after the existing imports:
import Link from "next/link";
  • Find the <body> tag inside the return statement. It currently has a className with font variables and antialiased.
  • Add min-h-screen bg-gray-50 to the end of the existing className string, so the full value looks like this:
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-gray-50`}
  • Replace everything inside the <body> tag (between the opening > and </body>) with the following header and main wrapper:
        <header className="bg-white border-b border-gray-200">
          <nav className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
            <Link href="/" className="text-xl font-bold text-gray-900">
              Product Showcase
            </Link>
            <Link
              href="/"
              className="text-sm text-gray-600 hover:text-gray-900"
            >
              All Products
            </Link>
          </nav>
        </header>
        <main className="max-w-6xl mx-auto px-4 py-8">{children}</main>

What does the layout do?

The <header> adds a navigation bar with the app name on the left and an "All Products" link on the right. Both use the Link component to navigate back to the home page.

The <main> tag wraps {children}, which is the page content. This layout wraps every page in your app and persists across navigations, so the header never re-renders when you switch pages.

  • Save layout.tsx and refresh your browser.

You should see a white navigation bar at the top with "Product Showcase" on the left and "All Products" on the right. Below it, your product grid displays all six cards on a light gray background.

Header not appearing?

Make sure the import Link from "next/link"; line is at the top of layout.tsx alongside the other imports. Without it, the Link component is undefined and the page will error.

Also check that {children} is still inside the <main> tag. If you accidentally deleted it, your page content will disappear.

Help me fix my layout.tsx.

✔️ Awesome, I've got everything!

Great. Confirm you have saved layout.tsx before moving on.

ⓧ I'd like to double check the full code

Scroll through app/layout.tsx and confirm these sections appear in order:

  • Imports for Metadata, Geist fonts, Link from next/link, and globals.css.
  • Font configuration for geistSans and geistMono.
  • Metadata with title "Product Showcase".
  • A <body> with min-h-screen bg-gray-50 in the className, containing a <header> with two Link components and a <main> wrapping {children}.

Your product catalog is live with six product cards, a responsive grid, and a persistent navigation layout. Next up, you will build the product detail pages so those card links actually go somewhere.

Add Dynamic Product Detail Pages

Your home page is looking great with a grid of product cards. Right now, each card links to a URL like /products/1, but clicking one leads to a 404 because that page does not exist yet.

In this step, you will create dynamic routes so that a single file handles every product URL. This is how Next.js generates pages from data, and it is one of the patterns interviewers expect you to know.

In this step, get ready to:
  • Create a dynamic route folder structure with a [id] segment.
  • Build a product detail page that reads the product ID from the URL.
  • Handle missing products with a built-in 404 page.
Create the dynamic route folder structure

In the App Router, every folder inside app/ becomes a URL segment. Wrapping a folder name in square brackets turns it into a dynamic segment that matches any value.

  • In the Explorer sidebar in VS Code, right-click the app folder and select New Folder.
  • Name the folder products.
  • Right-click the new products folder and select New Folder again.
  • Name this folder [id] (including the square brackets).
  • Right-click the [id] folder and select New File.
  • Name the file page.tsx.

Your folder structure should now look like: app/products/[id]/page.tsx.

What is a dynamic route?

The [id] folder is a dynamic segment. It matches any value in the URL at that position. So /products/1, /products/42, and /products/headphones all route to the same page.tsx file.

This is fundamentally different from React Router, where you define routes in code. In Next.js, the file system IS the router.

Build the product detail page

The dynamic route is ready. Now you need to write the component that reads the product ID from the URL, fetches the matching product, and renders its details.

  • In app/products/[id]/page.tsx, add the imports and data-fetching logic by pasting this code:
import Link from "next/link";
import { getProductById } from "@/app/lib/products";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  // Await the params Promise to get the product ID
  const { id } = await params;
  // Fetch the product data using the ID from the URL
  const product = await getProductById(id);

Why is params a Promise?

In Next.js 16, params is typed as Promise<{ id: string }>. You must await it to get the actual ID value. This lets Next.js stream the page shell before the params are fully resolved.

The getProductById(id) function is the same one you created in app/lib/products.ts. Because this is a Server Component, you can call it directly with await.

  • Still in app/products/[id]/page.tsx, add the return statement directly below the code you just pasted:

  return (
    <div>
      {/* Link back to the home page */}
      <Link
        href="/"
        className="text-blue-600 hover:underline text-sm mb-6 inline-block"
      >
        &larr; Back to all products
      </Link>
      {/* Product detail card with name, price, and description */}
      <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
        <div className="w-full h-64 bg-gray-100 rounded-md mb-6 flex items-center justify-center text-gray-400 text-lg">
          {product.name}
        </div>
        <h1 className="text-3xl font-bold text-gray-900">{product.name}</h1>
        <p className="text-2xl text-blue-600 font-medium mt-2">
          ${product.price.toFixed(2)}
        </p>
        <p className="text-gray-600 mt-4 leading-relaxed">
          {product.description}
        </p>
      </div>
    </div>
  );
}

What does this JSX render?

  • A Link component that navigates back to the home page without a full page reload.
  • A card showing the product's name, price, and description, styled with Tailwind CSS.
  • A gray placeholder box where an image would go. It displays the product name for now.
  • Save app/products/[id]/page.tsx.
  • Switch back to your browser at http://localhost:3000 and click any product card.

You should land on a dedicated detail page showing the product's name, price, and description inside a white card. A blue "Back to all products" link appears at the top.

Seeing a 404 or blank page?

  • Check that your folder structure is exactly app/products/[id]/page.tsx. The square brackets must be part of the folder name, not the filename.
  • Make sure you saved the file. Unsaved changes will not trigger a rebuild.
  • Confirm the function is exported with export default async function. Missing the default keyword means Next.js cannot find the page component.

Need more help? Why isn't my dynamic route page showing up in Next.js?

Handle missing products

Your product IDs are 1 through 6. If someone visits /products/999, getProductById returns undefined and the page crashes trying to access product.name. Next.js provides a built-in way to handle this.

  • In app/products/[id]/page.tsx, find the imports at the top of the file:
import Link from "next/link";
import { getProductById } from "@/app/lib/products";
  • Add a new import for notFound between the two existing imports, so the top of the file looks like this:
import Link from "next/link";
import { notFound } from "next/navigation";
import { getProductById } from "@/app/lib/products";
  • Now find this line inside the function:
  const product = await getProductById(id);
  • Add the following check directly below that line, before the return statement:
  // Show the built-in 404 page if no product matches the ID
  if (!product) {
    notFound();
  }

What does notFound() do?

Calling notFound() immediately stops rendering and shows Next.js's built-in 404 page. You do not need to write your own error page or return a special component. The function is imported from next/navigation.

  • Save app/products/[id]/page.tsx.

Before you test this, think about what you expect to see when you visit a product ID that does not exist. Will the page crash, show an error, or show something else?

  • In your browser, navigate to http://localhost:3000/products/999.

You should see a "404 | This page could not be found" message. That is the built-in Not Found page from Next.js, triggered by your notFound() call.

  • Click Product Showcase in the header to return to the home page, then click any product card to confirm the detail pages still work for valid IDs.

Not seeing the 404 page?

  • Make sure the if (!product) check is placed after const product = await getProductById(id) and before the return statement.
  • Verify that the import line says import { notFound } from "next/navigation" and not next/router.

Still stuck? Why is notFound() not showing a 404 page in my Next.js product detail route?

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/products/[id]/page.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm your app/products/[id]/page.tsx has all three sections in order:

  • Three imports: Link from next/link, notFound from next/navigation, and getProductById from @/app/lib/products.
  • The async function with await params, await getProductById(id), and the if (!product) { notFound(); } guard.
  • The return JSX with a back link, image placeholder, product name, price, and description inside a styled card.

You now have a fully working product catalog with clickable detail pages and graceful 404 handling. Next up, you will add loading skeletons and error boundaries to make the app feel polished and resilient.

Add Loading and Error States

Your product detail pages load data and handle missing products. But right now, the user stares at a blank screen for the half-second it takes getProductById to return data.

In plain React, you would build loading spinners and error boundaries from scratch. Next.js has dedicated file conventions for both. Drop a loading.tsx into a route folder and Next.js wraps your page in a React Suspense boundary automatically. Drop in an error.tsx and you get a built-in error boundary.

In this step, get ready to:
  • Create a loading skeleton for product detail pages.
  • Add an error boundary with a retry button.
  • Test both states to see them in action.
Add a loading skeleton

When a user clicks a product card, the detail page takes 500ms to fetch data. During that time, they should see a placeholder skeleton instead of a blank page.

  • Create a new file at app/products/[id]/loading.tsx and paste the following code:
// Skeleton UI that shows while the product detail page loads
export default function Loading() {
  return (
    <div>
      {/* Placeholder for the "Back to all products" link */}
      <div className="h-4 w-32 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
        {/* Placeholder for the product image */}
        <div className="w-full h-64 bg-gray-200 rounded-md mb-6 animate-pulse" />
        {/* Placeholder for the product name */}
        <div className="h-8 w-64 bg-gray-200 rounded animate-pulse" />
        {/* Placeholder for the price */}
        <div className="h-6 w-32 bg-gray-200 rounded animate-pulse mt-2" />
        {/* Placeholder for the description */}
        <div className="h-4 w-full bg-gray-200 rounded animate-pulse mt-4" />
        <div className="h-4 w-3/4 bg-gray-200 rounded animate-pulse mt-2" />
      </div>
    </div>
  );
}

What does this code do?

Each div is a gray rectangle that pulses with Tailwind's animate-pulse class. The shapes and sizes mirror the actual product detail layout so the transition feels seamless.

By naming this file loading.tsx, Next.js automatically wraps your page.tsx in a React Suspense boundary. You did not write any Suspense code yourself.

  • Save the file.
  • In your browser, go to http://localhost:3000 and click any product card.

You should see a brief flash of pulsing gray rectangles before the product details appear. The 500ms simulated delay in your data function makes the skeleton just barely visible.

Don't see the skeleton?

  • Make sure you saved loading.tsx inside app/products/[id]/, not inside app/products/. The loading file only wraps the route segment it lives in.
  • The skeleton flashes quickly at 500ms. Try clicking a different product card or doing a hard refresh (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows) to clear the cache.
  • help me see the loading skeleton in my Next.js app

✔️ Awesome, I've got everything!

Great. Make sure you have saved loading.tsx.

ⓧ I'd like to double check the full code

export default function Loading() {
  return (
    <div>
      <div className="h-4 w-32 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
        <div className="w-full h-64 bg-gray-200 rounded-md mb-6 animate-pulse" />
        <div className="h-8 w-64 bg-gray-200 rounded animate-pulse" />
        <div className="h-6 w-32 bg-gray-200 rounded animate-pulse mt-2" />
        <div className="h-4 w-full bg-gray-200 rounded animate-pulse mt-4" />
        <div className="h-4 w-3/4 bg-gray-200 rounded animate-pulse mt-2" />
      </div>
    </div>
  );
}
Add an error boundary

Loading skeletons handle the happy path. But what happens when a data fetch fails? Next.js uses a file called error.tsx to wrap your page in a React Error Boundary that catches runtime errors.

  • Create a new file at app/products/[id]/error.tsx and paste the following setup code:
"use client";

import { useEffect } from "react";

// Error boundaries must be Client Components
export default function Error({
  error,
  retry,
}: {
  error: Error & { digest?: string };
  retry: () => void;
}) {
  // Log the error so you can debug it in the browser console
  useEffect(() => {
    console.error(error);
  }, [error]);

Why does this file start with 'use client'?

React Error Boundaries rely on class component lifecycle methods that only run in the browser. Next.js requires error.tsx to be a Client Component so it can catch errors on the client side.

The retry prop re-fetches and re-renders the error boundary's children. The useEffect logs the error so you can inspect it in the browser console.

  • Still in error.tsx, add the return statement directly below the useEffect block:
  // Show a friendly error message with a retry button
  return (
    <div className="text-center py-16">
      <h2 className="text-2xl font-bold text-gray-900 mb-2">
        Something went wrong!
      </h2>
      <p className="text-gray-600 mb-6">
        We could not load this product. Please try again.
      </p>
      <button
        onClick={() => retry()}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
      >
        Try again
      </button>
    </div>
  );
}

What does the retry button do?

When the user clicks Try again, it calls retry(), which re-fetches and re-renders the page content inside the error boundary. If the error was temporary (like a network hiccup), the page loads normally.

  • Save error.tsx.

You will not see the error boundary yet because no error is happening. The next substep triggers one so you can test it.

✔️ Awesome, I've got everything!

Great. Double check that error.tsx is saved.

ⓧ I'd like to double check the full code

Scroll up and confirm both chunks are present in your file, in this order:

  • Chunk 1: The "use client" directive, the useEffect import, and the Error function signature with the useEffect block inside it.
  • Chunk 2: The return statement with a centered div containing the heading, paragraph, and the Try again button. The file ends with a closing } for the function.
Test both states

The loading skeleton only flashed for half a second, and the error boundary has not appeared at all. Let's temporarily change the data function to see both states clearly.

  • In app/lib/products.ts, find the getProductById function and locate this line:
  await new Promise((resolve) => setTimeout(resolve, 500));
  • Change 500 to 3000 so the line looks like this:
  await new Promise((resolve) => setTimeout(resolve, 3000));
  • Save products.ts.
  • In your browser, go to http://localhost:3000 and click any product card.

You should see the loading skeleton pulse for about 3 seconds before the product details appear. Those gray rectangles match the shape of your actual product layout.

Now let's trigger the error boundary. You need to make getProductById throw an error.

  • In app/lib/products.ts, find the return products.find((p) => p.id === id); line inside getProductById.
  • Add this line directly above it:
  throw new Error("Test error for product detail page");
  • Save products.ts.

Before you click a product card, what do you think you will see?

  • Click any product card on the home page.

You should see the loading skeleton first (for 3 seconds), then the "Something went wrong!" error message with a Try again button. That is your error boundary at work.

What is the component hierarchy here?

Next.js nests these files in a specific order: layout.tsx wraps error.tsx, which wraps loading.tsx, which wraps page.tsx. This is the hierarchy interviewers expect you to know.

Seeing the default Next.js error page instead?

  • Make sure error.tsx is inside app/products/[id]/ and has "use client" as the very first line.
  • Confirm the function is exported as the default export. Named exports are not picked up by the file convention.
  • help me debug my Next.js error boundary

Time to reset your test changes so the app works normally again.

  • In app/lib/products.ts, delete the throw new Error line you just added.
  • Change the delay back from 3000 to 500 so the line reads:
  await new Promise((resolve) => setTimeout(resolve, 500));
  • Save products.ts.

Before you test the final state, what do you expect to see when you click a product card? And what about visiting a product ID that does not exist?

  • Click any product card on the home page.

You should see the loading skeleton flash briefly, then the full product detail page.

  • Now visit http://localhost:3000/products/999 in your browser.

You see a 404 not-found page. That is the notFound() call from step 3 handling the missing product, working alongside your new loading and error conventions.

Your product detail pages now have three safety nets: a loading skeleton while data streams in, an error boundary if something breaks, and a 404 page for missing products. Next up, you will add a search bar and discover what happens when you try to use React hooks in a Server Component.

Add Interactive Search with Client Components

Your product detail pages now handle both loading and error states gracefully. The next feature on the list is a search bar so users can filter the product catalog in real time.

Every component you have written so far is a Server Component. Server Components run on the server, so they cannot use React hooks like useState. If you try to add search state directly to your home page, Next.js will stop you with an error.

In this step, get ready to:
  • Try adding state to a Server Component and see the error.
  • Extract a Client Component for the search bar.
  • Compose Server and Client Components together on the home page.
Try adding state to the Server Component

Your home page (app/page.tsx) is a Server Component by default. You need a search bar that tracks what the user types, which means you need useState. What happens if you try to use a hook directly in a Server Component?

  • In VS Code, open app/page.tsx.
  • Find the existing imports at the top of the file:
import Link from "next/link";
import { getProducts } from "@/app/lib/products";
  • Add this import on a new line below the existing imports:
import { useState } from "react";
  • Inside the Home function, add this line directly below const products = await getProducts();:
const [query, setQuery] = useState("");

Before you save, what do you think will happen? Will the page work, or will something go wrong?

  • Save app/page.tsx.
  • Check your browser at http://localhost:3000.

You see an error. Next.js tells you that you are using a React client hook in a Server Component. This is the designed obstacle.

Why does this error happen?

Server Components run entirely on the server. Their code never reaches the browser, so they cannot use browser-only APIs or React hooks like useState and useEffect.

When you need interactivity (state, event handlers, effects), you must move that code into a Client Component by adding 'use client' at the top of the file.

  • Remove the import { useState } from "react"; line you just added.
  • Remove the const [query, setQuery] = useState(""); line.
  • Save app/page.tsx. Your browser should go back to showing the product grid.
Extract a Client Component for search

The fix is to move the interactive search logic into its own file marked with 'use client'. This file becomes a Client Component that can use hooks freely.

  • Create a new folder called components inside your app folder.
  • Create a new file called search-bar.tsx inside app/components/.
  • Add the Client Component with the search input by pasting this code into app/components/search-bar.tsx:
"use client";

import { useState } from "react";
import Link from "next/link";
import type { Product } from "@/app/lib/products";

export default function SearchBar({ products }: { products: Product[] }) {
  // Track the current search query
  const [query, setQuery] = useState("");

  // Filter products by name or description
  const filtered = products.filter(
    (product) =>
      product.name.toLowerCase().includes(query.toLowerCase()) ||
      product.description.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <input
        type="text"
        placeholder="Search products..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="w-full px-4 py-2 mb-6 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
    </div>
  );
}

What does this code do?

  • "use client" at the top marks this file as a Client Component. Now it can use hooks and browser APIs.
  • The component accepts a products prop. The Server Component will pass the product data down.
  • useState tracks what the user types in the search field. The filtered array updates automatically as the query changes.
Compose Server and Client Components together

The SearchBar component only renders an input right now. Before adding the product grid to it, wire it up to the home page so you can see it in action.

  • Switch back to app/page.tsx.
  • Replace the entire file with this updated version:
import Link from "next/link";
import { getProducts } from "@/app/lib/products";
import SearchBar from "@/app/components/search-bar";

export default async function Home() {
  // Fetch products on the server
  const products = await getProducts();

  return (
    <div>
      <h1 className="text-3xl font-bold text-gray-900 mb-2">Our Products</h1>
      <p className="text-gray-600 mb-6">
        Browse our collection of tech accessories.
      </p>
      {/* Pass server-fetched data to the Client Component */}
      <SearchBar products={products} />
    </div>
  );
}

What changed?

  • The page imports SearchBar and passes the products array as a prop. Data flows from Server Component to Client Component through props.
  • The product grid that was inline in page.tsx is gone. The SearchBar will own both the search input and the product grid.
  • The page itself stays an async Server Component. It fetches data with zero client-side JavaScript.
  • Save both app/page.tsx and app/components/search-bar.tsx.
  • Refresh your browser at http://localhost:3000. You should see the heading, description, and a search input. The product cards are gone for now.

Don't see the search input?

  • Make sure search-bar.tsx is inside app/components/, not at the project root or inside app/products/.
  • Check that the import path in page.tsx is exactly @/app/components/search-bar.
  • Confirm your dev server is still running in the terminal.

Why is my SearchBar component not rendering on the page?

The search input is showing, but the product cards are missing. Time to add the product grid to the SearchBar component so it can filter and display them.

  • Switch back to app/components/search-bar.tsx.
  • Find the closing </div> inside the return statement, right after the <input /> element.
  • Add the product grid and empty-state message by pasting this code between the <input /> and the closing </div>:
      {/* Product grid filtered by the search query */}
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
        {filtered.map((product) => (
          <Link
            key={product.id}
            href={`/products/${product.id}`}
            className="block bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
          >
            <div className="w-full h-40 bg-gray-100 rounded-md mb-4 flex items-center justify-center text-gray-400">
              {product.name}
            </div>
            <h2 className="text-lg font-semibold text-gray-900">
              {product.name}
            </h2>
            <p className="text-blue-600 font-medium mt-1">
              ${product.price.toFixed(2)}
            </p>
            <p className="text-gray-500 text-sm mt-2 line-clamp-2">
              {product.description}
            </p>
          </Link>
        ))}
      </div>
      {filtered.length === 0 && (
        <p className="text-center text-gray-500 mt-8">
          No products match your search.
        </p>
      )}

What does this code do?

  • The grid maps over the filtered array (not the raw products array). As the user types, the filter runs and the grid updates instantly.
  • Each card is wrapped in a Link to the product detail page, just like the original grid.
  • The empty-state paragraph shows when no products match the search query.

What do you think will happen when you type in the search bar now?

  • Save app/components/search-bar.tsx.
  • Refresh your browser at http://localhost:3000. The product cards are back, now displayed below the search input.
  • Type keyboard in the search bar.

The grid filters instantly, showing only the Mechanical Keyboard card. Clear the search and all six products return.

Products not filtering when you type?

  • Make sure the grid maps over filtered, not products. If you map over products directly, the filter logic is bypassed.
  • Check that the "use client" directive is on the very first line of search-bar.tsx. It must come before any imports.

Why isn't my search bar filtering the product grid?

Why not just make page.tsx a Client Component?

You could add 'use client' to page.tsx and use useState there directly. But that would turn the entire page into a Client Component, sending all of its code to the browser.

By keeping page.tsx as a Server Component and only making the search bar a Client Component, you send less JavaScript to the browser. The server handles data fetching, and only the interactive search logic runs on the client. This composability pattern is what interviewers want to hear about.

✔️ Awesome, I've got everything!

Make sure you have saved both app/components/search-bar.tsx and app/page.tsx.

ⓧ I'd like to double check the full code

Here is the full app/page.tsx:

import Link from "next/link";
import { getProducts } from "@/app/lib/products";
import SearchBar from "@/app/components/search-bar";

export default async function Home() {
  const products = await getProducts();

  return (
    <div>
      <h1 className="text-3xl font-bold text-gray-900 mb-2">Our Products</h1>
      <p className="text-gray-600 mb-6">
        Browse our collection of tech accessories.
      </p>
      <SearchBar products={products} />
    </div>
  );
}

For app/components/search-bar.tsx, scroll up and confirm these sections are present in this order:

  • "use client" directive on line 1, followed by the imports for useState, Link, and the Product type.
  • The SearchBar function with query state and the filtered array.
  • The return statement with an <input> element, a responsive product grid mapping over filtered, and an empty-state paragraph.

Secret mission

Add a Favorites Feature

You've built a working product showcase with Server and Client Components. Now push further by adding a favorites feature that persists across page navigations. You'll create a React context provider as a Client Component, wrap it around your Server Component layout, and see firsthand how Server and Client Components interleave at the layout level.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files, stop the dev server, or delete everything entirely.

Resources you used:

  • The product-showcase project folder on your Desktop.
  • The Next.js dev server process running at http://localhost:3000.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your product showcase.

  • Your code stays in the product-showcase folder on your Desktop.
  • Run npm run dev from inside the folder anytime to start the dev server again.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • When you are ready to come back, open a terminal in the product-showcase folder and run npm run dev.

Delete - I don't want to use this again

Remove all project files and stop the dev server. This frees up disk space and fully cleans up.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • Delete the project folder by running this command:

macOS

cd ~/Desktop
rm -rf product-showcase

Windows

cd ~\Desktop
Remove-Item -Recurse -Force product-showcase
  • Confirm the folder is gone by running:
ls

You should no longer see product-showcase in the output.

Nice Work!

Nice Work!

Nice work! You just built a fully functional product showcase app with Next.js, complete with dynamic routes, loading states, and a search bar that filters products in real time.

You've learned how to:

  • Built a multi-page Next.js app using the App Router with file-system routing, where each file in the app/ directory maps directly to a route in your application.
  • Created dynamic route pages with [id] segments and async params, then added loading skeletons with loading.tsx and error boundaries with error.tsx to handle real-world edge cases.
  • Hit the Server Component and Client Component boundary wall firsthand, then composed them together so the server fetches data and the client handles interactivity with 'use client'.
  • Secret Mission: Added a favorites feature using React context in a Client Component wrapper that persists across page navigations, with a live favorites count in the header.

Ready to quiz yourself?