Implement page enhancements

Elevate, May 20-22-2025, Miami Beach, Florida

Learn how to enrich the product detail page (PDP): implement breadcrumbs, related products, reviews, and error handling.

After completing this page, you should be able to:

  • Implement page enhancements like category breadcrumbs using Composable Commerce reference expansion.
  • Select and apply appropriate strategies (for example, asynchronous calls, reference expansion) to efficiently load and display related product data and customer reviews.
  • Apply robust error handling practices within a Backend-for-Frontend (BFF) for API interactions.

To create a truly engaging PDP and an effective shopping experience, you should provide rich contextual navigation and relevant product recommendations, build customer trust through product reviews, and ensure the robustness of your application through effective error handling. Let’s look at how we can enhance our PDPs.

Category breadcrumbs using reference expansion

On the PDP, breadcrumbs offer a valuable navigation aid by showing where a product lives within your catalog hierarchy—such as Home > Furniture > Living Room Furniture > Armchairs. To enable this in Composable Commerce, you can use the expand query parameter on the Product Projections endpoint to retrieve detailed Category information directly within the Product response.
By expanding Category references using categories[*], you gain access to each Category’s obj, which includes its name, slug, and optionally its ancestors—a list of categories representing its hierarchical path. This structure allows you to dynamically generate breadcrumbs that reflect the full path from the root Category to the Product’s current placement.

Code example

Following is a sample code snippet using the TypeScript SDK. This method fetches a Product Projection by its slug and expands its Category references using expand=categories[*].

/**
   * Fetches product data along with expanded category references.
   * @param slug - Product slug.
   * @param locale - Customer locale.
   * @returns The product projection with expanded category data.
   */
  async fetchProductWithCategories(
    slug: string,
    locale: string,
  ): Promise<ProductProjection> {
    try {
      const response = (await apiRoot
        .productProjections()
        .get({
          queryArgs: {
            where: `slug(${locale} = "${slug}")`,
            staged: false,
            localeProjection: locale,
            expand: ['categories[*]'], // Expand all categories for breadcrumbs
          },
        })
        .execute()) as ClientResponse<ProductProjectionPagedQueryResponse>;

      const product = response.body.results?.[0];
      if (!product) {
        throw new NotFoundException(`Product with slug '${slug}' not found.`);
      }

      this.logger.log(
        `Fetched product with categories: ${JSON.stringify(product)}`,
      );
      return product;
    } catch (error) {
      this.handleServiceError(error, `slug '${slug}' with categories`);
    }
  }

Build breadcrumbs from expanded Category data

Each Category reference in the response includes an obj with localized fields and, optionally, ancestors. These fields are used to construct the breadcrumb trail. To build a complete and user-friendly breadcrumb trail on your PDP using expanded Category data, follow this structured approach:
  1. Extract Category references: start by retrieving the categories array from the Product Projection response. Each element in this array is a reference object that contains an obj field—populated due to reference expansion (expand=categories[*]). This obj holds the full details of the Category, including localized name, slug, and, if available, the Category’s ancestors.
  2. Sort Categories to reflect hierarchy: if the Category object includes an ancestors array, you can use this to reconstruct the full Category path—starting from the root and ending at the current Category.
    • Each ancestor represents a parent Category.

    • Append the current Category (obj) to the end of the sorted ancestors to complete the breadcrumb chain.
    • For Categories without ancestors, use the current Category on its own.

  3. Build breadcrumb links: for each sorted Category object (root → leaf), construct a clickable link using its localized name and slug. This ensures that your breadcrumb navigation is human-readable and SEO-friendly.
  4. Render the breadcrumb trail: finally, join all breadcrumb links using a separator, such as >, and render them in a dedicated UI container—usually placed at the top of the PDP.

Pseudocode example: frontend logic for breadcrumb construction

// Assume 'product' is the API response with expanded category references.

function getSortedCategories(categoryReference) {
  const ancestors = categoryReference.obj.ancestors || [];
  return [...ancestors, categoryReference.obj]; // root → leaf
}

function buildBreadcrumb(categoryObj) {
  const name = categoryObj.name['en-US']; // Change locale key as needed
  const url = `/categories/${categoryObj.slug['en-US']}`;
  return `<a href="${url}">${name}</a>`;
}

function constructBreadcrumbTrail(product) {
  let breadcrumbLinks = [];

  product.categories.forEach((categoryRef) => {
    // Only build once using the first category path (adjust as needed)
    if (breadcrumbLinks.length === 0 && categoryRef.obj) {
      const sorted = getSortedCategories(categoryRef);
      breadcrumbLinks = sorted.map(buildBreadcrumb);
    }
  });

  return breadcrumbLinks.join(' > ');
}

// Usage
document.getElementById('breadcrumb-container').innerHTML =
  constructBreadcrumbTrail(product);

Category external cache

As an alternative to reference expansion, you might query your internal cache of the Categories, via fetching by the Category ID. See our Category queries best practices module for more insight.

Execute and observe the response

To test this logic, call the fetchProductWithCategories() method from your controller and observe the response. Look for:
  • categories[*].obj.name and slug: these fields provide the Category name and slug in the desired locale for rendering the breadcrumb links.
  • ancestors array: if present, this array outlines the full hierarchy—use it to sort and build root-to-leaf breadcrumbs.

Reference expansion

  • Description: Use the expand parameter in your initial API query for the main Product. Composable Commerce will automatically fetch and embed the full data for the referenced products within the same response.
  • Pros:
    • Simplicity: single API call retrieves both main and related Product data.
    • Data consistency: ensures related data is fetched at the same time as the main Product.
    • Easier frontend logic: no need to manage separate API calls for related items.
  • Cons:
    • Performance impact: can increase the size and processing time of the initial API response, potentially slowing down the main PDP load, especially with many references that have large Product JSON responses.

Separate async API call

  • Description: First, fetch the main Product data. Then, extract the IDs of the referenced Products. Finally, make a second, separate API call asynchronously after the initial page render. Ideal if the reference Products are rendered below the page fold.
  • Pros:
    • Faster initial load: the primary PDP data loads fast as it's not bloated by expanded references.
    • Decoupled loading: related Products can load slightly later without blocking the main content.
    • More control: fine-grained control over when and how related data is fetched. You may even choose to only fetch the reference if the customer scrolls down the page or indicates that they will interact with the related products page element.
  • Cons: Requires managing multiple API calls and potentially handling loading states for the related products section.

External cache / data store

  • Description: Similar to the Separate async API call option. Retrieve the reference IDs from the main API response. Then, fetch the full data for these referenced Products from a separate, optimized data store after the page is rendered.
  • Pros:
    • High performance: can be a fast option if the cache is well-implemented.
    • Flexibility: allows data aggregation or transformation outside of Composable Commerce if needed.
    • Useful if you already have an external cache.
  • Cons:
    • Synchronization: requires mechanisms to keep the cache data consistent and up-to-date with Composable Commerce (unless the data is the source of truth).

You can expose separate endpoints for fetching the main Product and the referenced data. For example:

  • GET /products/slug/:slug: returns the localized Product.
  • GET /products/:id: returns full details for a specific Product (used for frequently bought together items).

Frontend pseudocode for asynchronous reference fetching

Below is a pseudocode example illustrating how the frontend can use asynchronous calls to load the additional referenced data after the main Product has been rendered:

// Fetch and render the main product data.
async function loadProduct(slug, locale) {
 const product = await fetchProductBySlug(slug, locale); // Calls the /products/slug/:slug endpoint
 renderProduct(product); // Render main product details immediately
 return product;
}

  // Check for frequently-bought-together-ref in master variant attributes.
 const freqBoughtAttr = product.masterVariant.attributes.find(
   attr => attr.name === 'frequently-bought-together-ref' // Name of your Product attribute that holds the Product Refences
 );
  // Initiate asynchronous calls if references exist.

 const freqBoughtPromises = freqBoughtAttr && Array.isArray(freqBoughtAttr.value)
   ? freqBoughtAttr.value.map(ref => fetchProductById(ref.id)) // Calls the /products/:id endpoint for each
   : [];
  // Use Promise.all to load all referenced data concurrently.
 const [frequentlyBoughtProducts] = await Promise.all([
   ...freqBoughtPromises,
 ]);
  // Render the additional sections.
 renderFrequentlyBoughtSection(frequentlyBoughtProducts);
}

// Combined flow
async function initializeProductPage(slug, locale) {
 const product = await loadProduct(slug, locale);
 // Once the product is rendered, fetch and display referenced data asynchronously.
 loadReferencedData(product);
}

// Example usage:
initializeProductPage('charlie-armchair', 'en-US');

Key takeaways

  • Separation of concerns: fetch the main product first to display essential information, then retrieve referenced data (like cross-sell or frequently bought together items) via separate API calls. This minimizes the initial payload and enhances performance.
  • Asynchronous data loading: use asynchronous calls (for example, Promise.all) on the frontend to load supplementary data concurrently, ensuring that the PDP remains responsive.
  • Reference attributes: the product model includes attributes of type product reference, which serve as pointers to related Products. This can be used to enrich the PDP.
  • Frontend: implement the pseudocode logic to first load the main product and then asynchronously fetch the referenced data. Verify that the main product renders immediately, while cross-sell and frequently bought together sections load subsequently.

By decoupling the main Product fetch from the retrieval of referenced data, you create a more responsive and scalable PDP. This approach ensures that Zen Electron’s customers experience a fast-loading product page that dynamically enriches with targeted recommendations—improving both performance and user engagement.

Customer reviews and ratings

Imagine you're building the PDP for Zen Electron. Beyond detailed product information, showcasing customer reviews and ratings is key to building trust and social proof. Composable Commerce offers a dedicated Review API that allows you to manage, query, and moderate product reviews directly.
The Product response object contains the aggregated ReviewRatingStatistics, which might be sufficient for rendering the key above the fold information. But the page may also need to be enriched with detailed Reviews below the fold. This makes for another good use case for using an asynchronous request to get detailed review data after the page is loaded.

Code example: fetch reviews via the Composable Commerce Review API

Below is a sample method in your BFF that demonstrates how to fetch reviews for a specific product by its ID using the Composable Commerce Review API.


/**
 * Fetches reviews for a given product using the Composable Commerce Review API.
 * @param productId - The unique identifier of the product.
 * @returns An array of reviews associated with the product.
 */
async fetchReviews(productId: string): Promise<any[]> {
  try {
    // Use the Reviews endpoint with a query to filter reviews by product ID.
    const response = await apiRoot
      .reviews()
      .get({
        queryArgs: {
          where: `target(id="${productId}"and typeId=product )`, // Filter reviews for the specified product
        },
      })
      .execute();

    // Log the retrieved reviews for debugging.
    this.logger.log(`Fetched reviews for product ${productId}: ${JSON.stringify(response.body.results)}`);

    return response.body.results;
  } catch (error) {
    this.handleServiceError(error, `reviews for product ID '${productId}'`);
  }
}

Frontend pseudocode for asynchronous review fetching

To keep your PDP responsive, the main product data is loaded first. Then, based on user interactions or page design, the frontend can trigger additional asynchronous API calls to fetch review data. Here's an example of how your frontend might handle this:

// Step 1: Fetch and render the main product details (assume this is already implemented).
async function loadProductPage(slug, locale) {
  const product = await fetchProductBySlug(slug, locale);
  renderProductDetails(product);
  // After rendering the main product, fetch reviews asynchronously.
  loadReviews(product.id);
}

// Step 2: Fetch reviews asynchronously.
async function loadReviews(productId) {
  try {
    const reviews = await fetchReviews(productId); // Calls the BFF endpoint for reviews
    renderReviewsSection(reviews); // Function to render reviews on the page
  } catch (error) {
    console.error('Error loading reviews:', error);
    // Optionally, render a fallback message.
    renderReviewsSection([]);
  }
}

Execute and observe the response

Call the fetchReviews() method from your BFF controller. A sample JSON response might look like this:
{
  "results": [
    {
      "id": "rev-001",
      "target": {
        "typeId": "product",
        "id": "2bbb247c-9d08-4018-b3ee-07500af7b50b"
      },
      "authorName": "John Doe",
      "rating": 4,
      "comment": "Great product! Very comfortable and stylish.",
      "createdAt": "2025-03-01T10:15:30.000Z"
    },
    {
      "id": "rev-002",
      "target": {
        "typeId": "product",
        "id": "2bbb247c-9d08-4018-b3ee-07500af7b50b"
      },
      "authorName": "Jane Smith",
      "rating": 5,
      "comment": "Absolutely love it! Highly recommended.",
      "createdAt": "2025-03-05T08:20:45.000Z"
    }
  ]
}

By fetching customer reviews asynchronously—whether via the Composable Commerce Review API or through third-party solutions—you can enrich Zen Electron's PDP with valuable social proof. This decoupled approach not only ensures fast, efficient page loads but also enhances customer trust and engagement, ultimately driving higher conversion rates.

Error handling

When developing your Backend-for-Frontend while using frameworks, robust error handling is crucial. API calls can fail for many reasons—such as a missing product (404) or network issues—and handling these errors gracefully improves both debugging and the end-user experience.

Best practices

  • Centralized error handling: consolidate error processing into a common helper method (for example, handleServiceError()). This approach reduces code repetition and ensures consistent error responses across all service methods.
  • Explicitly handle error: define your error cases and handle them gracefuly.

Code example: error handling in a service method

Below is an example of a service method in your BFF that demonstrates how to fetch a Product by its ID. Notice how the error handling is centralized using handleServiceError(), making the code cleaner and easier to maintain.
/**
 * Fetches a product projection by its unique ID.
 * @param id - The unique identifier of the product.
 * @returns The product projection.
 */
async fetchProductById(id: string): Promise<ProductProjection> {
  try {
    const response = (await apiRoot
      .productProjections()
      .withId({ ID: id })
      .get({ queryArgs: { staged: false } })
      .execute()) as ClientResponse<ProductProjection>;

    // If no product is found, throw a NotFoundException.
    if (!response.body) {
      throw new NotFoundException(`Product with ID '${id}' not found.`);
    }

    this.logger.log(`Fetched product by ID: ${JSON.stringify(response.body)}`);
    return response.body;
  } catch (error) {
    // Delegate error handling to the centralized method.
    this.handleServiceError(error, `ID '${id}'`);
  }
}

/**
 * Centralized error handling logic for service methods.
 * Logs detailed error information and throws a relevant HTTP exception.
 * @param error - The error object from the API call.
 * @param context - Contextual information (For example, product ID, key, or slug).
 * @throws NotFoundException for 404 errors; otherwise, InternalServerErrorException.
 */
private handleServiceError(error: any, context: string): never {
  this.logger.error(`Error occurred while fetching ${context}`, error.stack || error);

  // If the error is a 404, throw a NotFoundException.
  if (error.statusCode === 404) {
    throw new NotFoundException(`Resource not found for ${context}`);
  }

  // For all other errors, throw an InternalServerErrorException.
  throw new InternalServerErrorException(
    `An error occurred while processing request for ${context}`,
  );
}

Execute and observe the response

When you invoke the service method from your controller (for example, via a GET /products/:id endpoint) verify that:
  • When the product is not found, a 404 response is returned with a clear message.
  • Other errors yield an Internal Server Error response with a meaningful error message.
  • The logs provide detailed context for troubleshooting.

By centralizing error handling in your BFF, you create a consistent and maintainable approach that ensures Zen Electron’s PDP remains robust and user-friendly—even when encountering unexpected issues. This practice not only simplifies debugging but also improves overall system resilience and customer satisfaction.

Test your knowledge