Build an Online Shop with Next.js #2: Product Catalog
Last time we set up the route tree, the data model, and the product seed JSON. This time we render real screens — the goal is to finish two pages: the /products list and the /products/[slug] detail page. Both can be pure Server Components: the data is static JSON and there’s no user interaction on these screens.
A shared utility — price formatting #
Prices show up in several places, so let’s extract a formatting function up front. Intl.NumberFormat is part of the standard library, so no external dependency is needed.
src/app/lib/format.js:
const krw = new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
});
export function formatPrice(amount) {
return krw.format(amount);
}Pass in 24000 and you get ₩24,000. Instead of scattering toLocaleString calls across every screen, keeping it in one place means a single change if the currency ever needs to change.
The ProductCard component #
First, a card component used by both the list page and the home page. Each card contains:
- Product image
- Product name
- Price
- A sold-out badge (when stock === 0)
src/app/components/ProductCard.js:
import Link from 'next/link';
import Image from 'next/image';
import { formatPrice } from '../lib/format';
export default function ProductCard({ product }) {
const isOutOfStock = product.stock === 0;
return (
<Link href={`/products/${product.slug}`} className="block group">
<div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover transition group-hover:scale-105"
/>
{isOutOfStock && (
<span className="absolute left-2 top-2 rounded bg-black/70 px-2 py-1 text-xs text-white">
Sold out
</span>
)}
</div>
<div className="mt-2 text-sm">{product.name}</div>
<div className="mt-1 text-base font-semibold">{formatPrice(product.price)}</div>
</Link>
);
}We’re using next/image in fill mode. When the parent container has a fixed size via relative + aspect-square, the image fills it and gets cropped by object-cover. It’s the simplest pattern for keeping card heights from going uneven.
Wrapping the whole card in Link makes the entire card clickable — a detail that’s especially important on mobile.
next/image domain configuration #
All images in this post are local assets under public/, so no extra setup is needed. But if you later use external images (a CDN, S3, etc.), you’ll need to register them in images.remotePatterns in next.config.mjs. For now we keep the default configuration.
If some slugs don’t have images yet, next/image will hit a 404 and you’ll see a broken-image icon. While learning, you can point to an arbitrary placeholder image (e.g. https://placehold.co/600x600) or temporarily swap the image paths in the JSON to an existing asset like /next.svg. To use an external placeholder, you have to register the domain in next.config.mjs.
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'placehold.co' },
],
},
};
export default nextConfig;/products — the product list page
#
Now for the main page. By default it renders every product; if a query string like ?category=apparel arrives, it shows only that category.
src/app/products/page.js:
import Link from 'next/link';
import ProductCard from '../components/ProductCard';
import { getAllCategories, getProductsByCategory } from '../lib/products';
export const metadata = {
title: 'Products',
description: 'Products currently on sale.',
};
export default async function ProductsPage({ searchParams }) {
const { category } = await searchParams;
const categories = getAllCategories();
const products = getProductsByCategory(category);
return (
<main className="mx-auto max-w-5xl px-4 py-8">
<h1 className="text-2xl font-bold">Products</h1>
<nav className="mt-4 flex flex-wrap gap-2">
<CategoryChip current={category} value={undefined} label="All" />
{categories.map((c) => (
<CategoryChip key={c} current={category} value={c} label={c} />
))}
</nav>
{products.length === 0 ? (
<p className="mt-8 text-gray-500">No products match the filter.</p>
) : (
<ul className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{products.map((p) => (
<li key={p.slug}>
<ProductCard product={p} />
</li>
))}
</ul>
)}
</main>
);
}
function CategoryChip({ current, value, label }) {
const isActive = current === value || (!current && !value);
const href = value ? `/products?category=${value}` : '/products';
return (
<Link
href={href}
className={
'rounded-full border px-3 py-1 text-sm ' +
(isActive ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:border-gray-500')
}
>
{label}
</Link>
);
}A few points worth noting:
searchParamsis awaited — since Next.js 15,params/searchParamsare Promises. Same pattern as in the previous posts.- The category chips are plain links — they work even without JS. A natural payoff of Server Components.
- The
isActivecheck — compare withcurrent === value, but the “All” chip needs a separate branch so it activates whencurrentis absent. - Responsive grid — 2 columns on mobile, 3 on tablet, 4 on desktop. Tailwind’s
grid-cols-{n}handles it cleanly.
/products/[slug] — the product detail page
#
A page showing one product in detail: a large image, a description, the price, and a cart button.
src/app/products/[slug]/page.js:
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getAllProducts, getProductBySlug } from '../../lib/products';
import { formatPrice } from '../../lib/format';
export function generateStaticParams() {
return getAllProducts().map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) return {};
return {
title: product.name,
description: product.description,
};
}
export default async function ProductDetailPage({ params }) {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) notFound();
const isOutOfStock = product.stock === 0;
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="grid gap-8 md:grid-cols-2">
<div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
priority
/>
</div>
<div className="flex flex-col gap-4">
<div className="text-sm text-gray-500">{product.category}</div>
<h1 className="text-2xl font-bold">{product.name}</h1>
<div className="text-2xl font-semibold">{formatPrice(product.price)}</div>
<p className="text-gray-700">{product.description}</p>
{isOutOfStock ? (
<button
type="button"
disabled
className="rounded-md bg-gray-300 px-4 py-3 text-white"
>
Sold out
</button>
) : (
<button
type="button"
className="rounded-md bg-black px-4 py-3 text-white hover:bg-gray-800"
>
Add to cart
</button>
)}
<div className="text-sm text-gray-500">
In stock: {product.stock}
</div>
</div>
</div>
</main>
);
}Key points:
generateStaticParams— generates every slug statically at build time. Every product in the JSON ends up in the build output as a static page, so there’s no server rendering cost when the page is opened.generateMetadata— each product page gets its own<title>and<meta description>, which feed directly into SEO and social previews.notFound()— a bad slug gets sent to the 404 page. It’s cleaner than handling it yourself with something likeif (!product) return <NotFoundUI />, and Next.js sends the proper status code along with it.priority— gives the main image on the detail page a load-priority hint, improving LCP (Largest Contentful Paint).- The “Add to cart” button — an empty button for now. We’ll wire up the click handler in the next post.
Tidying up the home page #
The home page only serves as the catalog’s entry point. Let’s lightly clean up the default page.js we made in part 1.
src/app/page.js:
import Link from 'next/link';
import ProductCard from './components/ProductCard';
import { getAllProducts } from './lib/products';
export default function HomePage() {
const featured = getAllProducts().slice(0, 4);
return (
<main className="mx-auto max-w-5xl px-4 py-8">
<section className="rounded-lg bg-gray-100 p-8 text-center">
<h1 className="text-3xl font-bold">Our Shop</h1>
<p className="mt-2 text-gray-600">A shop built for learning — great products await.</p>
<Link
href="/products"
className="mt-4 inline-block rounded-md bg-black px-5 py-2 text-white"
>
Browse all products
</Link>
</section>
<section className="mt-8">
<h2 className="text-xl font-bold">Featured products</h2>
<ul className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
{featured.map((p) => (
<li key={p.slug}>
<ProductCard product={p} />
</li>
))}
</ul>
</section>
</main>
);
}“Featured products” simply takes the first four for now. In a real service you’d add a featured: true flag to the data or maintain separate curation data, but this is plenty for the learning stage.
Shared layout — the header #
The same header needs to appear across every page, so it goes in layout.js. We’re upgrading the RootLayout from part 1.
src/app/layout.js:
import Link from 'next/link';
import './globals.css';
export const metadata = {
title: {
default: 'Our Shop',
template: '%s | Our Shop',
},
description: 'A shop built for learning',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header className="border-b">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<Link href="/" className="font-bold">Our Shop</Link>
<nav className="flex gap-4 text-sm">
<Link href="/products">Products</Link>
<Link href="/cart">Cart</Link>
</nav>
</div>
</header>
{children}
</body>
</html>
);
}The cart item-count badge is intentionally left out for now. Cart state lives on the client, so in the next post we’ll slot in a dedicated Client Component for it.
Verify it works #
Start the server with pnpm dev and look around.
- http://localhost:3000 — home, four featured products
- http://localhost:3000/products — the full product grid
- http://localhost:3000/products?category=apparel — apparel only
- http://localhost:3000/products/minimal-tee-black — a detail page
- http://localhost:3000/products/react-book — a sold-out product (disabled button)
- http://localhost:3000/products/does-not-exist — the 404 page
Click a category chip and the URL changes while the grid narrows down. Refresh the page and the ?category= in the URL restores the exact same view. That’s the natural result of combining Server Components with query strings.
Wrap-up #
This post finished the two catalog pages.
- Built a price formatting utility
- Extracted a card component shared by the list and the home page
- Added a category filter to
/productsviasearchParams - Applied
generateStaticParamsandnotFound()to/products/[slug] - Polished the shared header and the home page
Everything so far is all Server Components. Even with JavaScript disabled, browsing the catalog works just fine.
The mood shifts in the next post. In “Build an Online Shop with Next.js #3: Shopping Cart,” client-side state enters the picture — we’ll manage the cart with Context API + localStorage, make the “Add to cart” button actually work, and light up the item-count badge in the header.