LearnAI ToolsCareerPractice BuildsPlayContact
Next.jsIntermediate~2 hours

E-commerce Product Catalog

Build product listing and detail pages with dynamic routes and optimized images.

Dynamic Routesnext/imageRoute Handlers

Overview

A product catalog is the standard project for dynamic routes because it has two pages with a real, unavoidable relationship: a listing page showing every product at a glance, and a detail page for exactly one of them, addressed by an id that lives in the URL itself rather than in a query string. It is also the natural place to see why `next/image` exists at all — a page full of unoptimized product photos is one of the most common reasons a real e-commerce site fails a performance audit.

By the end of this tutorial you will have a `/products` grid, a `/products/[id]` detail route reading its id from a dynamic segment, a `GET /api/products` Route Handler backing that same data over plain HTTP for anything outside the Next.js app that needs it, and every product photo served through `next/image` instead of a plain `<img>` tag.

What You'll Build
  • A `lib/products.ts` data layer with `getAllProducts()` and `getProduct(id)`.
  • A `GET /api/products` Route Handler exposing the same catalog over a normal HTTP endpoint.
  • A `/products` grid page using `next/image` with explicit `width`/`height` to prevent layout shift.
  • A `/products/[id]` dynamic route rendering one product's full detail page.
  • A detail-page hero image using `next/image`'s `fill` and `priority` props for a responsive, fast-loading layout.
  • A `notFound()` call for any id that does not match a real product.

Prerequisites

  • The `app/` directory convention, dynamic segments (`[id]`), and async Server Components, covered in this course's first practice build.
  • Basic REST concepts — what a `GET` request and a JSON response are.
  • Solid core JavaScript/TypeScript — array `find`/`map`, interfaces, and typed function parameters.
  • This is your first project using `next/image` and Route Handlers, so both are introduced from the ground up here.

Project Structure

`lib/products.ts` is the single source of truth both the Route Handler and the two page components read from, so the catalog data is only ever defined once. The Route Handler and the pages exist side by side deliberately: Server Components call `getAllProducts()`/`getProduct()` directly (faster, since it skips an HTTP round trip entirely), while `/api/products` exists for any client that cannot make a direct function call — a mobile app, a third-party integration, or a client-side fetch from elsewhere in the same app.

app/
products/
page.tsx // Listing grid — statically rendered, no dynamic segment
[id]/
page.tsx // One product's detail page, reading id from the dynamic segment
api/
products/
route.ts // GET handler exposing the same catalog over plain HTTP
lib/
products.ts // Data layer: getAllProducts(), getProduct(id)

Step 1: Model the Product Catalog

Product ids are slugs like `wireless-mouse` rather than numbers, which means the URL for a product page (`/products/wireless-mouse`) is both human-readable and stable even if the catalog is later re-sorted or re-seeded — a numeric auto-increment id would not survive that as cleanly.

lib/products.ts
// lib/products.ts
export interface Product {
id: string;
name: string;
price: number;
description: string;
imageUrl: string;
}
const PRODUCTS: Product[] = [
{ id: 'wireless-mouse', name: 'Wireless Mouse', price: 24.99, description: 'A reliable 2.4GHz wireless mouse with a 12-month battery life.', imageUrl: '/products/wireless-mouse.jpg' },
{ id: 'mechanical-keyboard', name: 'Mechanical Keyboard', price: 89.99, description: 'Hot-swappable switches with per-key RGB lighting.', imageUrl: '/products/mechanical-keyboard.jpg' },
{ id: 'usb-c-hub', name: 'USB-C Hub', price: 34.5, description: '7-in-1 hub with HDMI, an SD card reader, and 100W passthrough charging.', imageUrl: '/products/usb-c-hub.jpg' },
{ id: 'laptop-stand', name: 'Laptop Stand', price: 42.0, description: 'Adjustable aluminum stand that raises your screen to eye level.', imageUrl: '/products/laptop-stand.jpg' },
];
export async function getAllProducts(): Promise<Product[]> {
return PRODUCTS;
}
export async function getProduct(id: string): Promise<Product | undefined> {
return PRODUCTS.find((product) => product.id === id);
}

Step 2: Serve Products via a Route Handler

A Route Handler is the App Router's equivalent of an old `pages/api` file: a `route.ts` file exporting a function named after the HTTP method it responds to. `GET` here exists specifically for consumers that are not a Server Component in this same app — an external client can only ever reach data through a real network request, never through a direct function call.

app/api/products/route.ts
// app/api/products/route.ts
import { NextResponse } from 'next/server';
import { getAllProducts } from '@/lib/products';
export async function GET() {
const products = await getAllProducts();
return NextResponse.json(products);
}

Step 3: Build the Product Listing Grid

`next/image` requires either an explicit `width` and `height`, or the `fill` prop used in Step 5 — plain `width`/`height` is the right choice here because every card in a grid is meant to be the same fixed size. Reserving that space up front, before the actual image file has finished downloading, is what stops the page from visibly jumping around as images load in — a metric called Cumulative Layout Shift that real performance audits measure directly.

app/products/page.tsx
// app/products/page.tsx
import Image from 'next/image';
import Link from 'next/link';
import { getAllProducts } from '@/lib/products';
export default async function ProductsPage() {
const products = await getAllProducts();
return (
<div className="product-grid">
<h1>Shop</h1>
<div className="grid">
{products.map((product) => (
<Link key={product.id} href={'/products/' + product.id} className="product-card">
<Image
src={product.imageUrl}
alt={product.name}
width={300} // required alongside height whenever fill isn't used —
height={300} // together they reserve the image's space before it finishes loading, preventing layout shift
className="product-image"
/>
<h2>{product.name}</h2>
<p>${product.price.toFixed(2)}</p>
</Link>
))}
</div>
</div>
);
}

Step 4: Build the Product Detail Page

The `[id]` folder name becomes the dynamic segment read through `params.id` — the same pattern the blog project used for `[slug]`. Unlike the blog project, this route has no `generateStaticParams`, so it renders on demand for whichever id is requested instead of being pre-built for every product at deploy time; that is a reasonable default for a catalog whose stock and pricing can change far more often than a blog post's content does.

app/products/[id]/page.tsx
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
interface Props {
params: { id: string };
}
export default async function ProductDetailPage({ params }: Props) {
const product = await getProduct(params.id);
if (!product) {
notFound();
}
return (
<div className="product-detail">
{/* The hero image itself is built out with fill/priority in Step 5 */}
<h1>{product.name}</h1>
<p className="price">${product.price.toFixed(2)}</p>
<p>{product.description}</p>
<button type="button">Add to Cart</button>
</div>
);
}

Step 5: Optimize the Detail Hero Image

The detail page's hero image behaves differently from the grid: it needs to stretch to fill a CSS-controlled wrapper of varying size rather than sit at one fixed pixel size, which is exactly what the `fill` prop is for — it makes the image absolutely position itself to cover its nearest `position: relative` ancestor. Because `fill` replaces `width`/`height`, that wrapper has to declare its own size in CSS instead. `priority` is set here because this is the single largest, most important image on the page — the one a Core Web Vitals audit calls the Largest Contentful Paint candidate — so it should start loading immediately rather than being lazy-loaded like an image further down the page would be.

app/products/[id]/page.tsx (hero image)
// Replaces the placeholder comment from Step 4, at the top of the returned JSX
import Image from 'next/image';
<div className="product-hero">
<Image
src={product.imageUrl}
alt={product.name}
fill // stretches to cover the nearest position:relative ancestor (.product-hero below) instead of using explicit width/height
priority // skips lazy-loading — this is the page's Largest Contentful Paint candidate, so it should start downloading immediately
className="product-detail-image"
/>
</div>
/* fill requires its parent to establish the size the image should cover */
.product-hero {
position: relative; /* fill's absolute positioning is calculated relative to THIS element */
width: 100%;
height: 400px;
}
.product-detail-image {
object-fit: cover; /* crops rather than stretches/distorts the photo to fit the box above */
}

Complete Code

Here is the complete project — the data layer, the Route Handler, and both pages, fully assembled from the steps above.

lib/products.ts
export interface Product {
id: string;
name: string;
price: number;
description: string;
imageUrl: string;
}
const PRODUCTS: Product[] = [
{ id: 'wireless-mouse', name: 'Wireless Mouse', price: 24.99, description: 'A reliable 2.4GHz wireless mouse with a 12-month battery life.', imageUrl: '/products/wireless-mouse.jpg' },
{ id: 'mechanical-keyboard', name: 'Mechanical Keyboard', price: 89.99, description: 'Hot-swappable switches with per-key RGB lighting.', imageUrl: '/products/mechanical-keyboard.jpg' },
{ id: 'usb-c-hub', name: 'USB-C Hub', price: 34.5, description: '7-in-1 hub with HDMI, an SD card reader, and 100W passthrough charging.', imageUrl: '/products/usb-c-hub.jpg' },
{ id: 'laptop-stand', name: 'Laptop Stand', price: 42.0, description: 'Adjustable aluminum stand that raises your screen to eye level.', imageUrl: '/products/laptop-stand.jpg' },
];
export async function getAllProducts(): Promise<Product[]> {
return PRODUCTS;
}
export async function getProduct(id: string): Promise<Product | undefined> {
return PRODUCTS.find((product) => product.id === id);
}
app/api/products/route.ts
import { NextResponse } from 'next/server';
import { getAllProducts } from '@/lib/products';
export async function GET() {
const products = await getAllProducts();
return NextResponse.json(products);
}
app/products/page.tsx
import Image from 'next/image';
import Link from 'next/link';
import { getAllProducts } from '@/lib/products';
export default async function ProductsPage() {
const products = await getAllProducts();
return (
<div className="product-grid">
<h1>Shop</h1>
<div className="grid">
{products.map((product) => (
<Link key={product.id} href={'/products/' + product.id} className="product-card">
<Image src={product.imageUrl} alt={product.name} width={300} height={300} className="product-image" />
<h2>{product.name}</h2>
<p>${product.price.toFixed(2)}</p>
</Link>
))}
</div>
</div>
);
}
app/products/[id]/page.tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
interface Props {
params: { id: string };
}
export default async function ProductDetailPage({ params }: Props) {
const product = await getProduct(params.id);
if (!product) {
notFound();
}
return (
<div className="product-detail">
<div className="product-hero">
<Image src={product.imageUrl} alt={product.name} fill priority className="product-detail-image" />
</div>
<h1>{product.name}</h1>
<p className="price">${product.price.toFixed(2)}</p>
<p>{product.description}</p>
<button type="button">Add to Cart</button>
</div>
);
}

Sample Run

Browsing the Catalog

Click Run to see what this code prints.

Extend This Project

  • Add search and category filtering with `?q=` and `?category=` search params read in the page component.
  • Add pagination to `/products` and to the `GET /api/products` Route Handler using `?page=`/`?limit=` query parameters.
  • Wire an "Add to Cart" Server Action into the detail page, reusing the Context-based cart state pattern from the React course's Shopping Cart project.
  • Add `generateStaticParams` to `/products/[id]` so every product is pre-rendered at build time, the same way the blog project pre-renders posts.
  • Cache the Route Handler's response with `revalidateTag` so external consumers of `/api/products` get the same background-refresh behavior ISR gives page routes.

Summary

You built a product catalog with a dynamic `[id]` route for detail pages, a Route Handler exposing the same data over plain HTTP for consumers outside the app, and `next/image` optimizing every product photo — fixed `width`/`height` in the grid to prevent layout shift, and `fill`/`priority` on the detail hero for a responsive, fast-loading centerpiece image. Dynamic routes plus optimized images are the backbone of almost any content-per-item site you build next, from a catalog to a user profile page to a documentation site.