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.
Code example
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`);
}
}
Category external cache
Execute and observe the response
fetchProductWithCategories()
method from your controller and observe the response. Look for:-
categories[*].obj.name
andslug
: 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
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
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
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
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
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.