Mastering Product Projections for dynamic product listing pages, including category mapping, data retrieval, sorting, price selection, and attribute filtering considerations.
After completing this page, you should be able to:
- Describe how category URLs are mapped to API queries to retrieve relevant product data in Composable Commerce.
- Build a product listing page query using the Product Projections endpoint, including category filtering, sorting, pricing, and store-specific configurations.
URL to query mapping
electronicstech.com.au/en-au/c/headphones-speakers-audio
We can map the domain to:
domain
: May map to a Store.en-au
: Maps to the localized values./c/
: Indicated that this route is a category.headphones-speakers-audio
: Theslug
for the Category.
/product-projections/
query.We may also need to think about getting the Category description, meta title, meta description and Custom Fields that might have additional information or images that are needed to display. For some projects this information might be stored in a separate CMS, but we will demonstrate how to get the data from Composable Commerce Categories if they were being used for this purpose.
slug
. We derive the locale from the en-au
part of the URL.apiRoot.categories().get({
/**
* Query arguments to filter and control the category retrieval.
* @type {object}
* @property {string} where - The predicate string to filter categories.
*/
queryArgs: {
withTotal: false,
where: `slug(en-au="women")`,
limit: 1 // The limit is optional, as slugs are unique per locale
},
}).execute()
categories
field is an Array containing CategoryReference objects. Critically, these references identify Categories by their unique id
. Consequently, filtering Product Projections by Category requires you to know the specific ID of the Category you're interested in.There are two primary strategies to get this Category ID:
- Querying the Category Endpoint: Perform an API call to fetch the desired category (often by querying its unique
key
orslug
) and extract itsid
. We will demonstrate this approach shortly. - Retrieving from Cache: Access the Category ID from your application's cache. Given that category structures tend to be stable, caching them is an effective optimization. As potentially covered in the Category queries best practices module, you might already have a cached category tree that you can reuse, eliminating the need for a separate API call and accelerating your product queries.
Our example will focus on the first method. While this involves two distinct API requests (one for the Category ID, one for the Product Projections), the initial request to find the category is generally very efficient as it targets a unique field.
async function findProductsByCategorySlug(slugLocale: string, slugValue: string) {
try {
// --- Step 1: Get Category by Slug ---
const categoryResponse: ClientResponse<CategoryPagedQueryResponse> = await apiRoot
.categories()
.get({
queryArgs: {
withTotal: false,
// Construct the where clause dynamically for slug
where: `slug(${slugLocale}="${slugValue}")`,
limit: 1, // Slugs are unique per locale, so using a limit is optional
},
})
.execute();
// --- Step 2: Extract Category ID ---
if (categoryResponse.body.results.length === 0) {
console.error(`Category with slug ${slugLocale}="${slugValue}" not found.`);
return; // Exit if category not found
}
const category = categoryResponse.body.results[0];
const categoryId = category.id;
console.log(`Found category "${category.name[slugLocale] || Object.values(category.name)[0]}" with ID: ${categoryId}`);
// --- Step 3: Query Product Projections using Category ID ---
console.log(`Fetching product projections for category ID: ${categoryId}...`);
const productProjectionResponse: ClientResponse<ProductProjectionPagedQueryResponse> = await apiRoot
.productProjections()
.get({
queryArgs: {
withTotal: false,
// Filter product projections by the found category ID
// Use the 'categories.id' field for filtering
where: `categories(id="${categoryId}")`,
offset: 0,
staged: false,
// Ensure that we only get the locale matching the value in the URL
localeProjection: 'en-au',
limit: 20, // Adjust limit as needed
// You might want to add other filters or sorting here, for example:
// sort: 'name.en asc',
// staged: true, // or false depending if you want current or staged data
},
})
.execute();
// --- Step 4: Process Product Projection Results ---
const products = productProjectionResponse.body.results;
console.log(`Found ${products.length} product projections in category "${category.name[slugLocale] || Object.values(category.name)[0]}":`);
// Optional: Log product names or other details
products.forEach(product => {
console.log(` - ${product.name[slugLocale] || Object.values(product.name)[0]} (ID: ${product.id})`);
});
// return products;
} catch (error) {
console.error("Error fetching data from Commercetools:", error);
}
}
// --- Execute the function ---
// Call the function with the desired slug locale and value
findProductsByCategorySlug('en', 'women');
Great, now we have all the information we need to render the page.
But what if we have multiple prices, product tailoring, Stores or we want to limit the locales returned so that they match the stores locales?
distributionChannels
, supplyChannels
, then we only need to add the storeProjection
and the key of the Store to the get conditions.const categoryId = 'your-category-id'; // Replace with the actual category ID
const storeKey = 'your-store-key'; // Replace with the key of your target Store
apiRoot
.productProjections()
.get({
queryArgs: {
// Specify the store key for store-specific projections
storeProjection: storeKey,// new value
where: `categories(id="${categoryId}")`,
offset: 0,
staged: false,
limit: 20, // Adjust limit as needed
},
})
.execute();
localeProjection
and replace it with storeProjection
. storeProjection
can return multiple locales based on its setting, whilst localeProjection
will return the locales that you explicitly request (passing multiple localeProjection is possible).Sorting results
categoryOrderHints
. This is important because categoryOrderHints
will allow you to use the visually merchandised order of the products for that Category. The results might have been visually merchandised in the Merchant Center.categoryOrderHints
and the current category id and then the sort order:categoryOrderHints.{category-id} asc
Let’s add that parameter below to the API request:
apiRoot
.productProjections()
.get({
queryArgs: {
storeProjection: storeKey,
where: `categories(id="${categoryId}")`,
sort: `categoryOrderHints.${categoryId} asc`, // new value
offset: 0,
staged: false,
limit: 20,
},
})
.execute();
Price selection
In order for us to know what the best matching price is for the user, we need to use the Price selection fields:
// --- Define variables for Price Selection ---
const categoryId = 'your-category-id'; // Replace with the actual category ID
const storeKey = 'your-store-key'; // Replace with the key of your target Store
// Replace these with the actual values relevant to your pricing context
const priceCurrencyCode = 'AUD';
const priceCountryCode = 'AU';
const priceCustomerGroupId = undefined; // Set to a customer group ID if needed, otherwise undefined or null
const priceChannelId = undefined; // Set to a channel ID if needed (for channel-specific prices), otherwise undefined or null
//
apiRoot
.productProjections()
.get({
queryArgs: {
// Specify the store key for store-specific projections
storeProjection: storeKey,
// Filter product projections by the found category ID
// Use the 'categories.id' field for filtering
where: `categories(id="${categoryId}")`,
offset: 0,
staged: false, // Usually false for live store/price data
limit: 20, // Adjust limit as needed
// --- Use variables for Price Selection ---
priceCurrency: priceCurrencyCode, // Pass the currency variable
priceCountry: priceCountryCode, // Pass the country variable
priceCustomerGroup: priceCustomerGroupId, // Pass the customer group ID variable (or undefined/null)
priceChannel: priceChannelId, // Pass the channel ID variable (or undefined/null)
// ---
},
})
.execute();
Great, now we have covered everything that is needed to build queries for the Category listing page.
Product Attribute filtering
Zen Electron wants to allow customers to do basic filtering on the Category listing page, to help customers find relevant products. They want to support filtering on the brand, color and price.
If we still use the Product Projection endpoint, we will need to constrain the complexity of the queries and the frequency of the queries. This can be done by not allowing the customer to select many active values for filtering and enforcing this limit on the BFF API layer, this could be a simple limit of two or three filters. This means that if you may have future requirements for more filters, then you should use Products Search and not Product Projections. Some examples of how this may work:
- Filter by Brand A, B and Colour Y
- Filter by Price greater than A and less than B and Colour Y
If you are in doubt about the complexity of your queries and your options, you can always talk to the friendly people in customer support.
Another important solution to improve the customer experience by reducing the latency to view the page and reduce the load on this endpoint, would be to cache the result. You may use a shared HTML or JSON cache, whichever is most relevant for your architecture.
Zen Electron aims to enhance the customer experience on Category Listing Pages by introducing basic product filtering options: brand, color, and price.
Choosing the right approach
It's crucial to understand the distinction between Composable Commerce APIs for filtering:
- Product Search: This is the preferred and optimized solution for handling product filtering, especially for complex queries or high-volume traffic.
- Product Projection: While possible to query Product Attributes via the Product Projection endpoint, this method is inefficient for complex filtering and should be avoided for high-traffic use cases to prevent performance degradation.
Using Product Projections (with caution)
If your use case requires basic filtering implemented by using the Product Projection endpoint, you need to apply strict controls for the query filters:
- Constrain query complexity: Limit the number of filter values a user can select at the same time. Enforce a hard limit (for example, a maximum of three active filters), ideally in the BFF/API layer.
- Examples: Filter by Brand A, Brand B, and Color Y; or filter by Price between A and B.
- Performance enhancement: Regardless of the constraints, implement caching when using Product Projections for filtering. Caching the results (using shared HTML or JSON caches suitable for your architecture) significantly improves page load times and reduces the load on the Product Projection endpoint.
- Further assistance: For specific advice on query complexity or choosing the optimal filtering strategy for your scenario, reach out to the Composable Commerce support team.
Keep in mind that the Product Projections query endpoint does not return facets. This is intentional because Product Search should be used for faceting.
Let's look at how we can support basic filtering.
const storeKey: string = 'your-store-key';
const categoryId: string = 'your-category-id';
const priceA: number = 1000; // Example: Price A in cents (e.g., 10.00)
const priceB: number = 5000; // Example: Price B in cents (e.g., 50.00)
// Construct the where clause
const attributeFilter = `variants((attributes(name="brand" and value="TOP BLUSH") or attributes(name="brand" and value="TOP BLUSH"))) and variants((attributes(name="color" and value="black")))
`;
const priceFilter = `(variants.price.current.centAmount between ${priceA} and ${priceB})`;
// Combine attribute and price filters with OR, then combine with category filter using AND
const whereClause = `categories(id="${categoryId}") and (${attributeFilter} or ${priceFilter})`;
apiRoot
.productProjections()
.get({
queryArgs: {
// Specify the store key for store-specific projections
storeProjection: storeKey,
// Filter product projections using the constructed complex predicate
where: whereClause,
offset: 0,
staged: false,
limit: 20, // Adjust limit as needed
sort: 'price asc',
},
})
.execute();