Product Catalog
Build a product catalog with filtering, search, and pagination using the Atlas Public API.
Product Catalog & E-Commerce
A classic use case for a Headless CMS is managing a product catalog. In this guide, we'll build a product listing page that supports category filtering and searching using the Atlas CMS Public API.
Schema Setup
Assume you have a product content type in Atlas with fields like name, description, price, category (dropdown/string), and image.
What We're Building
We will create a Next.js Server Component that reads query parameters (e.g., ?category=shoes&q=sneaker) from the URL and passes them to the Atlas API for filtering and search.
1. API Helper
We need a function to fetch products that accepts filtering parameters.
const BASE = process.env.ATLAS_BASE_URL + '/api/v1/public'
const headers = {
'X-API-Key': process.env.ATLAS_API_KEY!,
}
export interface Product {
slug: string
data: {
name: string
description?: string
price: number
category: string
image?: { url: string; width: number; height: number; alt?: string }
}
}
export async function listProducts(options: {
page?: number
category?: string
search?: string
}): Promise<{
data: Product[]
meta: { total: number }
}> {
const url = new URL(`${BASE}/entries`)
url.searchParams.append('type', 'product')
url.searchParams.append('limit', '12')
url.searchParams.append('page', String(options.page || 1))
// Exact match filter for category
if (options.category) {
url.searchParams.append('filter[category]', options.category)
}
// Search across text fields (e.g., name, description)
if (options.search) {
url.searchParams.append('search', options.search)
}
const res = await fetch(url.toString(), { headers, next: { revalidate: 60 } })
if (!res.ok) return { data: [], meta: { total: 0 } }
return res.json()
}2. Product Listing Page
We'll build a page that reads searchParams and passes them to our API helper.
import Image from 'next/image'
import Link from 'next/link'
import { listProducts } from '@/lib/atlas'
type Props = {
searchParams: Promise<{ category?: string; q?: string; page?: string }>
}
export default async function ProductsPage({ searchParams }: Props) {
const { category, q, page } = await searchParams
const currentPage = Number(page || 1)
const { data: products, meta } = await listProducts({
category,
search: q,
page: currentPage,
})
return (
<main className="container py-12">
<h1 className="text-3xl font-bold mb-8">Our Products</h1>
{/* Filter Controls */}
<div className="flex gap-4 mb-8">
<Link href="/products" className={`px-4 py-2 border rounded ${!category ? 'bg-black text-white' : ''}`}>
All
</Link>
<Link href="/products?category=shoes" className={`px-4 py-2 border rounded ${category === 'shoes' ? 'bg-black text-white' : ''}`}>
Shoes
</Link>
<Link href="/products?category=apparel" className={`px-4 py-2 border rounded ${category === 'apparel' ? 'bg-black text-white' : ''}`}>
Apparel
</Link>
</div>
<p className="text-sm text-muted mb-6">Showing {products.length} of {meta.total} products</p>
{/* Product Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{products.map((product) => (
<div key={product.slug} className="border rounded-lg p-4 group">
{product.data.image && (
<Image
src={product.data.image.url}
alt={product.data.image.alt ?? product.data.name}
width={product.data.image.width}
height={product.data.image.height}
className="rounded-lg mb-4 w-full object-cover aspect-square bg-gray-100 group-hover:scale-105 transition-transform"
/>
)}
<h2 className="text-lg font-semibold">{product.data.name}</h2>
<p className="text-muted">${product.data.price.toFixed(2)}</p>
</div>
))}
</div>
</main>
)
}Next Steps
- Implement client-side forms for the search input to update the
qquery parameter. - Add Pagination controls at the bottom of the grid.