Lab - Build Basic Catalog Pages

Plan: Composable Developer

Lesson 14 of 27 · 45 min

Introduction

Your basic Next.js storefront build currently features only a store logo. Let’s expand it with basic catalog pages.

In this lab, you will:

  • Add a main nav menu with your store’s top level categories
  • Implement a basic category page with a product list
  • Add pagination to the category page
  • Implement a basic product detail page

Prerequisites

This exercise builds on the basic Next.js application begun in the previous lab and has the same technical requirements.

Your store should have the following catalog data associated with your headless channel:

  • Multiple enabled top-level categories with a description, image, and products assigned
  • Visible products with a SKU, default price, description, and default image

Setup

This exercise continues where you left off previously in your basic Next.js project. Simply start the dev server from the project root if it’s not currently running:

pnpm run dev

If you need a fresh start, you can follow the instructions below to set up a new project complete with previous exercise code.

  1. In your terminal, run the following, replacing the path to the working directory with your own path:
corepack enable pnpm && pnpm dlx create-next-app@latest -e https://github.com/bigcommerce-edu/lab-nextjs-storefront/tree/bcu-lab-catalog-start /path/to/working/directory
  1. Copy .env.example as .env.local and modify .env.local with configuration details for your store.
  2. Start the dev server process:
cd /path/to/working/directory
pnpm run dev
  1. Browse to the local URL printed in the pnpm output and verify your home page loads successfully.

Exercise Reference Copy

You may choose to download or clone the completed version of this lab in a separate location to serve as a reference for the final state.

Catalog Lab Snapshot

Add a Main Nav Menu

Let’s add a simple nav menu to the storefront header, consisting of links to top-level categories. This will require querying additional data from the GraphQL Storefront API, specifically categoryTree. Note that, since this data is to be made available to all pages like the store settings you’re already fetching, we’re going for maximum efficiency by including it in the same query. This is part of the power of GraphQL.

  1. In components/header/_data/component-data.ts, modify the GraphQL query to include categoryTree data, and add corresponding details to the response type.
...
const getHeaderSettingsQuery = `
query GetSettings($logoSize: Int!) {
site {
settings {
... (LEAVE UNMODIFIED!)
}
categoryTree {
entityId
name
path
}
}
}
`;
...
interface GetHeaderSettingsResp {
data: {
site: {
settings: {
...
}
categoryTree: NavCategory[]
}
}
}
interface NavCategory {
entityId: number;
name: string;
path: string;
}
...
  1. In the same file, modify getHeaderSettings to add nav categories to the result object.
export const getHeaderSettings = cache(async ({
...
}: {
...
) => {
...
const settings = settingsResp.data.site.settings;
const navCategories = settingsResp.data.site.categoryTree;
return {
settings: {
storeName: settings.storeName ?? null,
logoText: settings.logoV2.text ?? null,
logoImageUrl: settings.logoV2.image?.url ?? null,
},
navCategories,
};
});
  1. Open the file components/header/index.tsx. Add navCategories to the destructuring of getHeaderSettings.
...
const Header = async () => {
const { settings, navCategories } = await getHeaderSettings({});
...
  1. In the same file, add HTML structure for the main nav to the returned JSX.
...
return (
<header ...>
<div ...>
<h1>
...
</h1>
</div>
<div>
{navCategories && (
<ul className="flex">
{navCategories.map(navItem => (
<li key={navItem.path} className="mx-2 relative">
<Link className="font-bold hover:underline" href={`/category${navItem.path}`}>{navItem.name}</Link>
</li>
))}
</ul>
)}
</div>
</header>
)
...

You should be able to verify that the menu now displays on your home page!

Example Code

Implement the Category Page Query

The categories in the main nav menu each link to a URL path like /category/someCategorySlug. Let’s flesh out the page route for this pattern. You’ll first need to implement the right GraphQL query to fetch the needed category information, including the category’s products.

  1. Open the file types/catalog.ts. Fill in the details of the CategoryProductand BasicCategory interfaces as shown below.
export interface CategoryProduct {
sku: string;
name: string;
path: string;
prices: {
price: {
value: number;
currencyCode: string;
}
}
defaultImage?: {
url: string;
altText?: string;
}
}
export interface BasicCategory {
name: string;
path: string;
description?: string;
defaultImage?: {
url: string;
altText?: string;
}
}

These two definitions will help facilitate the right response type in the next step, where the basic category and product fields are the same but the structure of products is not as flat.

  1. Open the file app/category/[...catPath]/page-data.ts and add the following code to define the query and the various necessary types.
import ...
const categoryFragment = `
fragment categoryFields on Category {
name
path
description
defaultImage {
url(width: $mainImgSize)
altText
}
}
`;
const productFragment = `
fragment productFields on Product {
sku
name
path
prices {
price {
value
currencyCode
}
}
defaultImage {
url(width: $thumbnailSize)
altText
}
}
`;
const getCategoryWithBeforeQuery = `
query GetCategory(
$path: String!,
$mainImgSize: Int!,
$thumbnailSize: Int!,
$limit: Int,
$before: String
) {
site {
route(path: $path) {
node {
__typename
... on Category {
... categoryFields
products(
last: $limit,
before: $before
) {
edges {
node {
... productFields
}
}
}
}
}
}
}
}
${categoryFragment}
${productFragment}
`;
const getCategoryWithAfterQuery = `
query GetCategory(
$path: String!,
$mainImgSize: Int!,
$thumbnailSize: Int!,
$limit: Int,
$after: String
) {
site {
route(path: $path) {
node {
__typename
... on Category {
... categoryFields
products(
first: $limit,
after: $after
) {
edges {
node {
... productFields
}
}
}
}
}
}
}
}
${categoryFragment}
${productFragment}
`;
interface GetCategoryWithProductsVars {
path: string;
mainImgSize: number;
thumbnailSize: number;
limit: number;
before?: string;
after?: string;
}
interface GetCategoryWithProductsResp {
data: {
site: {
route: {
node: BasicCategory & {
"__typename": string;
products: {
edges: {
node: CategoryProduct;
}[]
}
}
}
}
}
}
/**
* Fetch a category and its products
*/
export const getCategoryWithProducts = cache(async ({
...

This is a verbose block of code, but mainly because we’ve created two different versions of the query: getCategoryWithBeforeQuery and getCategoryWithAfterQuery. These support the GraphQL variables necessary for “previous” and “next” pagination, which you’ll make use of in subsequent steps. To avoid as much duplication as possible, GraphQL fragments are used for the fields selected for category and product records.

Note the GetCategoryWithProductsResp interface, defining the expected GraphQL response. The products field, rather than directly returning a list, has the sub-field edges, and each item in that list contains the field node. We’ve injected the BasicCategory and CategoryProduct interfaces separately into this structure.

Finally, note that the GraphQL queries already support the before and after arguments even though we’re not yet implementing pagination. This will avoid the need for major refactoring in the following steps.

  1. Update getCategoryWithProducts to accept the proper input, perform the query, and parse the category and product data.
export const getCategoryWithProducts = cache(async ({
path,
mainImgSize,
thumbnailSize,
page,
customerToken,
}: {
path: string,
mainImgSize: number,
thumbnailSize: number,
page: {limit: number, before?: string, after?: string},
customerToken?: string,
}) => {
const categoryResp = await bcGqlFetch<GetCategoryWithProductsResp, GetCategoryWithProductsVars>(
page.before ? getCategoryWithBeforeQuery : getCategoryWithAfterQuery,
{
path,
mainImgSize,
thumbnailSize,
...page,
},
customerToken
);
const category = categoryResp.data.site.route.node;
if (!category || category.__typename !== "Category") {
throw new Error(`No category found for "${path}"`);
}
const products = (category.products?.edges ?? []).map(edge => edge.node);
return {
...
};
});

Just as we’re not implementing pagination yet, we also won’t be performing this fetch with any specific customer context. Nevertheless, the function supports a customerToken parameter that it passes in the GraphQL fetch, supporting this option for the future.

  1. Update the return value of getCategoryWithProducts to return the appropriate values.
export const getCategoryWithProducts = cache(async ({
...
}: {
...
}) => {
...
return {
...category,
products,
};
}
  1. Open the file app/category/[...catPath]/page.tsx, the route file that will actually handle page requests like “/category/{category-name}”. Note that the file path segment in square brackets is a “catch-all” segment that will capture everything after “/category” in the URL.
  2. For now, we’ll simply perform the appropriate fetch when the page is loaded and log the category data. Modify the CategoryPage component as follows.
export default async function CategoryPage({
params,
}: {
params: Promise<{ catPath: string[] }>,
}) {
const { catPath } = await params;
const path = `/${catPath.join('/')}`;
const mainImgSize = 500;
const thumbnailSize = 500;
let category;
try {
category = await getCategoryWithProducts({
path,
mainImgSize,
thumbnailSize,
page: { limit: 12 },
});
} catch(err) {
console.log(err);
category = null;
}
console.log(category);
return ...
}

params is a standard Next.js convention and will contain the values of any dynamic URL segments (in this case, catPath). With the URL path, we have what we need to call getCategoryWithProductsto fetch the data.

  1. In your browser, navigate to a category with a description and image attached. While the page will be blank, the terminal output where pnpm run dev was executed should display a log of the fetched category data.

Example Code

Implement Category Page Content

  1. Still in app/category/[...catPath]/page.tsx, remove the console.log statement and add code to return a 404 response if no category is found.
export default async function CategoryPage({
...
}: {
...
}) {
...
let category;
try {
...
} catch(err) {
...
}
if (!category) {
return notFound();
}
return ...
}
  1. Update the page component to flesh out its content.
export default async function CategoryPage({
...
}: {
...
}) {
...
return (
<>
<PageHeading>{category.name}</PageHeading>
<div className="w-full max-w-screen-2xl flex justify-center">
<div className="px-8 border-x-2 border-neutral-300 xl:w-2/3">
{category.defaultImage && (
<Image src={category.defaultImage.url}
alt={category.defaultImage.altText ?? ''}
width={mainImgSize} height={mainImgSize / 2}
className="max-w-full inline-block mr-4
md:w-1/2 md:max-w-3xl md:float-left" />
)}
{category.description && (
<div className="text-lg" dangerouslySetInnerHTML={{__html: category.description}} />
)}
</div>
</div>
<ul className="w-full max-w-screen-2xl grid grid-cols-1
md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-8">
{category.products.map(product => (
<li key={product.sku} className="bg-neutral-200 rounded-md p-4">
<ProductCard product={product} thumbnailSize={thumbnailSize} />
</li>
))}
</ul>
</>
);
}

This JSX content utilizes a separate component - ProductCard- for each product on the category page. This component is currently empty and doesn’t even accept the props being passed to it, so let’s flesh it out.

  1. Open the file components/product-card/index.tsx and update the component as follows.
const ProductCard = ({
product,
thumbnailSize,
}: {
product: CategoryProduct,
thumbnailSize: number,
}) => {
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: product.prices.price.currencyCode,
});
return (
<Link className="font-bold hover:underline" href={`/product${product.path}`}>
{product.defaultImage && (
<Image src={product.defaultImage.url}
alt={product.defaultImage.altText ?? ''}
width={thumbnailSize} height={thumbnailSize / 2}
className="max-w-full inline-block" />
)}
<h2 className="text-lg">{product.name}</h2>
<p className="font-normal text-sm">{product.sku}</p>
<p className="font-normal my-2">
{currencyFormatter.format(product.prices.price.value)}
</p>
</Link>
);
};
  1. In your browser, navigate to a category with a description and image attached and observe the details in the category page.

Example Code

Add Product List Pagination

Your category page displays one page of a category’s products, but currently there is no way to see more results. Let’s add this support for pagination.

The getCategoryWithProducts query already supports arguments for filtering the product list, but for full pagination support you also need to query data about the current “page” of results.

  1. In app/category/[...catPath]/page-data.ts, add a fragment string defining pageInfo fields, then utilize that fragment in both versions of the category query.
...
const productFragment = `
...
`;
const pageFragment = `
fragment pageFields on PageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
`;
const getCategoryWithBeforeQuery = `
query GetCategory(
...
) {
site {
route(path: $path) {
node {
__typename
... on Category {
... categoryFields
products(
last: $limit,
before: $before
) {
pageInfo {
... pageFields
}
edges {
node {
... productFields
}
}
}
}
}
}
}
}
${categoryFragment}
${productFragment}
${pageFragment}
`;
const getCategoryWithAfterQuery = `
query GetCategory(
...
) {
site {
route(path: $path) {
node {
__typename
... on Category {
... categoryFields
products(
first: $limit,
after: $after
) {
pageInfo {
... pageFields
}
edges {
node {
... productFields
}
}
}
}
}
}
}
}
${categoryFragment}
${productFragment}
${pageFragment}
`;
...
  1. In the same file, modify the response type definition to account for page data.
...
interface GetCategoryWithProductsResp {
data: {
site: {
route: {
node: BasicCategory & {
"__typename": string;
products: {
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
}
edges: {
node: CategoryProduct;
}[]
}
}
}
}
}
}
...
  1. Modify the return logic in getCategoryWithProducts to include info about the current page.
export const getCategoryWithProducts = cache(async ({
...
}: {
...
}) => {
...
const products = (category.products?.edges ?? []).map(edge => edge.node);
const pageOpts = {
before: category.products.pageInfo.hasPreviousPage
? category.products.pageInfo.startCursor : null,
after: category.products.pageInfo.hasNextPage
? category.products.pageInfo.endCursor : null,
}
return {
...category,
products,
page: pageOpts,
};
}

Now the function returns data including the startCursor from the current result set only if there is a previous page and the endCursor if there is a next page.

Next, you’ll modify the category page component to use URL querystring information for paging. The expected URL pattern will be /category/someCategorySlug?before={productCursor} or /category/someCategorySlug?after={productCursor}.

  1. Modify app/category/[...catPath]/page.tsx to use the page field on the category to conditionally output paging links.
export default async function CategoryPage({
...
}: {
...
}) {
...
return (
<>
<PageHeading>{category.name}</PageHeading>
<div className="w-full ...">
...
</div>
<ul className="w-full ...">
...
</ul>
<div className="w-full flex justify-center">
{category.page.before && (
<Link
className="mx-4"
href={`/category${category.path}?before=${category.page.before}`}
>
<ArrowLongLeft />
</Link>
)}
{category.page.after && (
<Link
className="mx-4"
href={`/category${category.path}?after=${category.page.after}`}
>
<ArrowLongRight />
</Link>
)}
</div>
</>
)
}
  1. In the same file, modify the component to capture any before or after querystring params and pass them into to the query.
export default async function CategoryPage({
params,
searchParams,
}: {
params: Promise<{ catPath: string[] }>,
searchParams?: Promise<{ before?: string, after?: string }>,
}) {
...
const { before, after } = await searchParams ?? {};
let category;
try {
category = await getCategoryWithProducts({
path,
mainImgSize,
thumbnailSize,
page: {
limit: 12,
before: before ? String(before) : undefined,
after: after ? String(after) : undefined,
},
});
} catch(err) {
...
}
...
return ...
}
  1. Browse to a category again and observe the pagination links in action.

If your category doesn’t contain enough products for multiple pages, temporarily set the limit value passed to getCategoryWithProducts to 1 or a sufficiently small value to see the effect.

Example Code

Implement a Product Query

The process of creating a product detail page will look similar to that of creating the category page, starting with crafting the GraphQL query.

  1. Fill in the Product interface definition in types/catalog.ts.
export interface Product {
entityId: number;
sku: string;
name: string;
description: string;
prices: {
price: {
value: number;
currencyCode: string;
}
}
defaultImage?: {
url: string;
altText?: string;
}
}
  1. Open the file app/product/[...productPath]/page-data.ts and add the GraphQL query and vars/response types.
import ...
const getProductQuery = `
query GetProduct(
$path: String!,
$imgSize: Int!
) {
site {
route(path: $path) {
node {
__typename
... on Product {
entityId
sku
name
description
prices {
price {
value
currencyCode
}
}
defaultImage {
url(width: $imgSize)
altText
}
}
}
}
}
}
`;
interface GetProductVars {
path: string;
imgSize: number;
}
interface GetProductResp {
data: {
site: {
route: {
node: Product & {
"__typename": string,
}
}
}
}
}
/**
* Fetch a product
*/
export const getProduct = cache(async ({
...
  1. In the same file, update the getProduct function to perform the query and return the product.
export const getProduct = cache(async ({
path,
imgSize,
customerToken,
}: {
path: string,
imgSize: number,
customerToken?: string,
}) => {
const productResp = await bcGqlFetch<GetProductResp, GetProductVars>(
getProductQuery,
{
path,
imgSize,
},
customerToken
);
const product = productResp.data.site.route.node;
if (!product || product.__typename !== "Product") {
throw new Error(`Product not found for "${path}"`);
}
return product;
});
  1. Open the file app/product/[…productPath]/page.tsx and update the page component as follows to capture the URL path and fetch the product.
export default async function ProductPage({
params,
}: {
params: Promise<{ productPath: string[] }>,
}) {
const { productPath } = await params;
const path = `/${productPath.join('/')}`;
const imgSize = 900;
let product;
try {
product = await getProduct({
path,
imgSize,
});
} catch (err) {
console.log(err);
product = null;
}
console.log(product);
return ...
}
  1. From a category page in your browser, navigate to a product detail page to verify the product’s basic details are logged to the output of the terminal where pnpm run dev was executed.

Example Code

Implement Product Page Content

  1. Open the file app/product/[...productPath]/page.tsx, remove the logging statement, and add an early return if no product was found.
export default async function ProductPage({
...
}: {
...
}) {
...
let product;
try {
...
} catch (err) {
...
}
if (!product) {
return notFound();
}
...
}
  1. In the same file, update the page component definition with the following content.
export default async function ProductPage({
...
}: {
...
}) {
...
return (
<>
<PageHeading>{product.name}</PageHeading>
<div className="w-full max-w-screen-2xl flex flex-wrap justify-center">
<div className="w-1/2">
{product.defaultImage && (
<Image src={product.defaultImage.url}
alt={product.defaultImage.altText ?? ''}
width={imgSize} height={imgSize / 2}
className="" />
)}
</div>
<div className="w-1/2 p-4">
<p className="my-2"><label className="font-bold">SKU:</label> {product.sku}</p>
{product.description && (
<div dangerouslySetInnerHTML={{__html: product.description}}
className="my-2" />
)}
<div className="text-lg my-4">
<label className="font-bold">Price:</label>
<span> {currencyFormatter.format(product.prices.price.value)}</span>
</div>
</div>
</div>
</>
);
}
  1. From a category page in your browser, navigate to a product detail page to verify the product’s basic details are displayed.

Example Code

Full Lab Code

Full Lab Code

Taking it Further

What you have so far is a basic but still incomplete catalog implementation. If you want to continue to flesh out your Next.js storefront on your own, below are some possibilities for expansion.

  • Fetch a more deeply nested list of categories with the categoryTree query and utilize this data to build a multi-level main nav with drop-downs for subcategories.
  • Add images to the product query to fetch all images assigned to the product, and utilize this data in the product page component to build a complete gallery.