> This page is for Developer.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.bigcommerce.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.bigcommerce.com/_mcp/server.

# 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:

```bash showLineNumbers={false}
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:

```bash showLineNumbers={false}
corepack enable pnpm && pnpm dlx create-next-app@latest -e https://github.com/bigcommerce-edu/lab-nextjs-storefront/tree/e-catalog-start /path/to/working/directory
```

2. **Copy** `.env.example` as `.env.local` and **modify** `.env.local` with configuration details for your store.
3. **Start** the dev server process:

```bash showLineNumbers={false}
cd /path/to/working/directory
pnpm run dev
```

4. **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](https://github.com/bigcommerce-edu/lab-nextjs-storefront/tree/e-catalog-complete)

## 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.

```text {3-16,26,31-35} showLineNumbers={false}
...

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;
}

...
```

2. In the same file, **modify** `getHeaderSettings` to add nav categories to the result object.

```js {9,17} showLineNumbers={false}
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,
  };
});
```

3. **Open** the file `components/header/index.tsx`. **Add** `navCategories` to the destructuring of `getHeaderSettings`.

```js {4} showLineNumbers={false}
...

const Header = async () => {
  const { settings, navCategories } = await getHeaderSettings({});

  ...
```

4. In the same file, **add** HTML structure for the main nav to the returned JSX.

```js {11-21} showLineNumbers={false}
...

  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](https://github.com/bigcommerce-edu/lab-nextjs-storefront/compare/e-catalog-01-pre...e-catalog-01-post?diff=split)

## 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 `CategoryProduct`and `BasicCategory` interfaces as shown below.

```js {2-14,18-24} showLineNumbers={false}
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.

2. **Open** the file `app/category/[...catPath]/page-data.ts` and **add** the following code to define the query and the various necessary types.

```js {3-127} showLineNumbers={false}
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.

3. **Update** `getCategoryWithProducts` to accept the proper input, perform the query, and parse the category and product data.

```js {2-6,8-12,15-31} showLineNumbers={false}
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.

4. **Update** the return value of `getCategoryWithProducts` to return the appropriate values.

```js {8-11} showLineNumbers={false}
export const getCategoryWithProducts = cache(async ({
  ...
}: {
  ...
}) => {
  ...

  return {
    ...category,
    products,
  };
}
```

5. **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.
6. 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.

```js {2,4,6-25} showLineNumbers={false}
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 `getCategoryWithProducts`to fetch the data.

7. 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](https://github.com/bigcommerce-edu/lab-nextjs-storefront/compare/e-catalog-02-pre...e-catalog-02-post?diff=split)

## 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.

```js {15-17} showLineNumbers={false}
export default async function CategoryPage({
  ...
}: {
  ...
}) {
  ...

  let category;
  try {
    ...
  } catch(err) {
    ...
  }

  if (!category) {
    return notFound();
  }

  return ...
}
```

2. **Update** the page component to flesh out its content.

```js {10-34} showLineNumbers={false}
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.

3. **Open** the file `components/product-card/index.tsx` and **update** the component as follows.

```js {2-3,5-6,9-12,14-28} showLineNumbers={false}
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>
  );
};
```

4. In your browser, **navigate** to a category with a description and image attached and **observe** the details in the category page.

[Example Code](https://github.com/bigcommerce-edu/lab-nextjs-storefront/compare/e-catalog-03-pre...e-catalog-03-post?diff=split)

## 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.

```text {7-14,30-32,49,85} showLineNumbers={false}
...

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}
`;

...
```

2. In the same file, **modify** the response type definition to account for page data.

```text {10-15} showLineNumbers={false}
...

interface GetCategoryWithProductsResp {
  data: {
    site: {
      route: {
        node: BasicCategory & {
          "__typename": string;
          products: {
            pageInfo: {
              hasNextPage: boolean;
              hasPreviousPage: boolean;
              startCursor: string | null;
              endCursor: string | null;
            }
            edges: {
              node: CategoryProduct;
            }[]
          }
        }
      }
    }
  }
}

...
```

3. **Modify** the return logic in `getCategoryWithProducts` to include info about the current page.

```js {10-15,20} showLineNumbers={false}
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}`.

4. **Modify** `app/category/[...catPath]/page.tsx` to use the `page` field on the category to conditionally output paging links.

```js {20-37} showLineNumbers={false}
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>
    </>
  )
}
```

5. In the same file, **modify** the component to capture any `before` or `after` querystring params and pass them into the query.

```js {3,6,10,18-22} showLineNumbers={false}
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 ...
}
```

6. **Browse** to a category again and **observe** the pagination links in action.

> **Info**
>
> 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](https://github.com/bigcommerce-edu/lab-nextjs-storefront/compare/e-catalog-04-pre...e-catalog-04-post?diff=split)

## 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`.

```js {2-15} showLineNumbers={false}
export interface Product {
  entityId: number;
  sku: string;
  name: string;
  description: string;
  prices: {
      price: {
          value: number;
          currencyCode: string;
      }
  }
  defaultImage?: {
      url: string;
      altText?: string;
  }
}
```

2. **Open** the file `app/product/[...productPath]/page-data.ts` and **add** the GraphQL query and vars/response types.

```js {3-49} showLineNumbers={false}
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 ({
  ...
```

3. In the same file, **update** the `getProduct` function to perform the query and return the product.

```js {2-4,6-8,11-24,26} showLineNumbers={false}
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;
});
```

4. **Open** the file *app/product/\[...productPath]/page.tsx* and **update** the page component as follows to capture the URL path and fetch the product.

```js {2,4,7-23} showLineNumbers={false}
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 ...
}
```

5. 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](https://github.com/bigcommerce-edu/lab-nextjs-storefront/compare/e-catalog-05-pre...e-catalog-05-post?diff=split)

## 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.

```js {15-17} showLineNumbers={false}
export default async function ProductPage({
  ...
}: {
  ...
}) {
  ...

  let product;
  try {
    ...
  } catch (err) {
    ...
  }

  if (!product) {
    return notFound();
  }

  ...
}
```

2. In the same file, **update** the page component definition with the following content.

```js {10-31} showLineNumbers={false}
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>
    </>
  );
}
```

3. From a category page in your browser, **navigate** to a product detail page to verify the product's basic details are displayed.

[Example Code](https://github.com/bigcommerce-edu/lab-nextjs-storefront/compare/e-catalog-06-pre...e-catalog-06-post?diff=split)

## Full Lab Code

[Full Lab Code](https://github.com/bigcommerce-edu/lab-nextjs-storefront/compare/e-catalog-start...e-catalog-complete?diff=split)

## **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.