Leverage the Product Search endpoint for flexible and efficient product listings, including advanced querying, sorting, and tailored product data retrieval.
After completing this page, you should be able to:
- Explain how to construct flexible product listing queries using the Product Search endpoint, including the use of search filters, sorting, and compound expressions.
- Implement a Product Search query for category-based listings with tailored pricing, localization, and variant matching using
productProjectionParameters
.
Now let's take a look at the more flexible and efficient Products Search endpoint.
Products Search can be used for many more use cases:
- Category listing pages
- Custom listing pages
- Search results
- Type-as-you-go queries
The techniques for querying the endpoint to build a page type are much more flexible, and the techniques can be reused across these different types of pages.
ProductSearchRequest
, and contains the following parameters:Parameter | Type | Description |
---|---|---|
query | SearchQuery | The search query against searchable Product fields. |
sort | Array of SearchSorting | Controls how results of your query are sorted . If not provided, the results are sorted by relevance score in descending order. |
limit | Int | Maximum number of search results to be returned in one page . Default: `20` Minimum: `0` Maximum: `100` |
offset | Int | The number of search results to be skipped (offset) in the response for pagination . Default: `0` Minimum: `0` Maximum: `10000` |
markMatchingVariants | Boolean | If the query specifies an expression for a Product Variant field, set this to true to get additional information for each returned Product about which Product Variants match the search query. For details, see matching variants. |
productProjectionParameters BETA | ProductSearchProjectionParams | Controls data integration with Product Projection parameters. If not set, the result does not include the Product Projection. |
facets BETA | Array of ProductSearchFacetExpression | Set this field to request facets . |
postFilter | SearchQuery | Specify an additional filter on the result of the query after the API calculated facets . This feature helps implement faceted search. |
Before we go deeper into the query structure, we will first show the equivalent query for building the Zen Electron Category page. This will help you understand how the different fields and concepts map.
URLs and Categories
slug
, by calling the Category endpoint or using a stored mapping of the values. As we have already shown how this works, we will dive directly into querying the Products Search:categories
: Matches products that explicitly belong to that Category.categoriesSubTree
: Matches products that belong to that Category ID and any child Categories. This option is not possible with the Product Projection endpoint.
We will build the request body using the Categories. The below query assumes that we have the category ID:
// Define the Product Search request body
const searchRequestBody = {
query: {
filter: [
{
"exact": {
"field": "categories",
"fieldType": "keyword",
"value": "7b23115a-3574-4098-9c32-33beb93aadf8"
}
}
],
},
limit: 1, // Adjust limit as needed
offset: 0,
withTotal: true, // Useful to get the total count
};
apiRoot
.products()
.search()
.post({
body: searchRequestBody,
})
.execute();
If you are familiar with the Elasticsearch DSL, this may look similar to you. Although there are similarities in the structure, the Platform's Search query language does not use all of the same field names and provides a slightly higher-level interface.
Having experience with the Elasticsearch DSL can help you get up to speed with the Search query language, but be mindful of the differences. As this is a new and more flexible query language, consult the relevant documentation as needed.
Now lets put the Category and Product Search query together:
async function findProductsByCategorySlugAndPs(
slugLocale: string,
slugValue: string
): Promise<Array<{ id: string }> | undefined> {
try {
// --- Step 1: Get Category by Slug ---
console.log(`Fetching category with slug ${slugLocale}="${slugValue}"...`);
const categoryResponse: ClientResponse<CategoryPagedQueryResponse> =
await apiRoot
.categories()
.get({
queryArgs: {
withTotal: false,
where: `slug(${slugLocale}="${slugValue}")`
},
})
.execute();
// --- Step 2: Extract Category ID ---
if (
!categoryResponse.body ||
categoryResponse.body.results.length === 0
) {
console.error(
`Category with slug ${slugLocale}="${slugValue}" not found.`
);
return undefined;
}
// Since limit is 1 and slug is unique, results[0] is the category if found.
const category: Category = categoryResponse.body.results[0];
const categoryId: string = category.id;
const categoryName: string =
category.name[slugLocale]
console.log(`Found category "${categoryName}" with ID: ${categoryId}`);
// --- Step 3: Query Products using Product Search and Category ID ---
console.log(
`Fetching product IDs via Product Search for category ID: ${categoryId}...`
);
// Define the Product Search request body
const searchRequestBody = {
query: {
filter: [
{
exact: {
field: 'categories.id',
fieldType: 'keyword',
value: categoryId,
},
},
],
},
limit: 20, // Adjust limit as needed
offset: 0,
withTotal: true
}
// Execute the Product Search POST request
const productSearchResponse: ClientResponse<ProductSearchResult> =
await apiRoot
.products()
.search()
.post({
body: searchRequestBody,
})
.execute();
// --- Step 4: Process Product Search Results ---
// The items in 'results' will primarily contain the 'id' when
// productProjectionParameters are omitted or minimal.
// We type it explicitly as Array<{ id: string }> for clarity.
const productsResult: Array<{ id: string }> =
productSearchResponse.body.results.map((p) => ({ id: p.id })); // Ensure only id is taken
const totalProducts =
productSearchResponse.body.total ?? productsResult.length;
console.log(
`Found ${totalProducts} total products (returning IDs for ${productsResult.length}) in category "${categoryName}" via Product Search:`
);
return productsResult; // Return the array of objects containing IDs
} catch (error: any) {
console.error('Error executing Commercetools request:', error.message);
if (error.body?.errors) {
console.error(
'Commercetools API Errors:',
JSON.stringify(error.body.errors, null, 2)
);
}
if (error.body) {
console.error("Full Error Body:", JSON.stringify(error.body, null, 2));
}
return undefined;
}
}
// --- Execute the function ---
// Ensure you have categories with these slugs in your project
findProductsByCategorySlugAndPs('en', 'women') // Example: 'women' slug in English
.then((productIds) => {
if (productIds) {
console.log(
`\nFunction execution finished successfully. Received ${productIds.length} product IDs.`
);
} else {
console.log(
'\nFunction execution finished, but no product IDs were returned (category not found or an error occurred).'
);
}
})
.catch((e) => {
console.error('\nUnhandled error during function execution:', e);
});
Product Search response
Here we can see that the response only returns the Product IDs in the results. If you want to get the actual Products, you can:
- Use the GraphQL Product Projection query on the product field.
- Use Product Projection parameters BETA. This is a Product Search argument that allows you to pass the fields to the Product Projection request.
- Application-side caches: If your use case permits, use application-side caches to store the IDs and SKUs returned with the search results together with the corresponding product data.
{
"total": 2,
"offset": 0,
"limit": 10,
"facets": [],
"results": [
{
"id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27"
},
{
"id": "8d040d38-e7b9-43f2-a5e9-9494e3916830"
}
]
}
- Use the GraphQL Product Projection query on the product field.
- Use Product Projection parameters BETA. This is a Product Search argument that allows us to pass the fields to the Product Projection request
- Application-side caches: If your use case permits, use application-side caches to store the IDs and SKUs returned with the search results together with the corresponding product data.
productProjectionParameters
, set staged: false
, and we will get the current projection.As you can see below, this is just three additional lines of code:
{
"query" : {
"filter": [
{
"exact": {
"field": "categories",
"fieldType": "keyword",
"value": "7b23115a-3574-4098-9c32-33beb93aadf8"
}
}
]
},
"productProjectionParameters": {
"staged": false
},
"limit" : 10,
"offset" : 0
}
Example response showing one product:
{
"total": 2,
"offset": 0,
"limit": 10,
"facets": [],
"results": [
{
"id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27",
"productProjection": {
"id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27",
"version": 11,
"createdAt": "2023-07-26T01:16:50.451Z",
"lastModifiedAt": "2025-05-02T02:38:17.907Z",
"key": "81223",
"productType": {
"typeId": "product-type",
"id": "fa3c47f4-d0a8-4f56-98f8-3b7fe1ffb351"
},
"name": {
"en": "Shirt Aspesi white",
"de": "Bluse Aspesi weiĂŸ"
},
"slug": {
"en": "aspesi-shirt-h805-white",
"de": "aspesi-bluse-h805-weiss"
},
"categories": [
{
"typeId": "category",
"id": "9fa7e767-cd4e-4d43-9ac1-9d3bbfd21734"
}
],
"_trimmed_info_categories": "Showing 1 of 4 categories. 3 more trimmed.",
"categoryOrderHints": {
"7b23115a-3574-4098-9c32-33beb93aadf8": "0.011"
},
"_trimmed_info_categoryOrderHints": "Showing 1 of 2 categoryOrderHints. 1 more trimmed.",
"searchKeywords": {},
"hasStagedChanges": false,
"published": true,
"masterVariant": {
"id": 1,
"sku": "M0E20000000ED0W",
"key": "M0E20000000ED0W",
"prices": [
{
"id": "68c53e56-4c3e-4ecc-9147-fce36e120e77",
"value": {
"type": "centPrecision",
"centAmount": 16625,
"currencyCode": "EUR",
"fractionDigits": 2
}
}
],
"_trimmed_info_masterVariant_prices": "Showing 1 of 17 prices. 16 more trimmed.",
"attributes": [
{
"name": "articleNumberManufacturer",
"value": "H805 C195 85072"
},
{
"name": "articleNumberMax",
"value": "81223"
}
],
"_trimmed_info_masterVariant_attributes": "Showing 2 of 13 attributes. 11 more trimmed.",
"images": [
{
"url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/081223_1_large.jpg",
"dimensions": {
"w": 0,
"h": 0
}
}
],
"assets": []
},
"variants": [
{
"id": 2,
"sku": "M0E20000000ED0X",
"key": "M0E20000000ED0X",
"prices": [
{
"id": "73ae093e-a23b-4bd1-88b0-f63f84945ae9",
"value": {
"type": "centPrecision",
"centAmount": 16625,
"currencyCode": "EUR",
"fractionDigits": 2
}
}
],
"_trimmed_info_variant_prices": "Showing 1 of 17 prices for this variant. 16 more trimmed.",
"attributes": [
{
"name": "articleNumberManufacturer",
"value": "H805 C195 85072"
},
{
"name": "size",
"value": "36"
}
],
"_trimmed_info_variant_attributes": "Showing 2 of 13 attributes for this variant. 11 more trimmed.",
"images": [
{
"url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/081223_1_large.jpg",
"dimensions": {
"w": 0,
"h": 0
}
}
],
"assets": []
}
],
"_trimmed_info_variants": "Showing 1 of 12 variants. 11 more trimmed.",
"taxCategory": {
"typeId": "tax-category",
"id": "e7f44309-d49e-4615-82db-eea4cd00d8a4"
}
}
}
],
"_trimmed_info_results": "Showing 1 of 2 results. 1 more result trimmed."
}
offset
Offset works the same as Product Projection. It can be used to fetch the data by page number. Note that there is a limit of 10,000 offsets and a max limit of 100 results per page.
Product Projection parameters
localeProjection
storeProjection
expand
staged
Price selection fields:
priceCurrency
priceCountry
priceCustomerGroup
priceChannel
All of these fields should be familiar, because we used most of them for the Product Projection endpoint exercise. Let's add each parameter and build the equivalent query that we created earlier for Product Projection.
This query will now return the same results, with the same tailoring values, locales, currencies and best match price hint:
{
"query" : {
"filter": [
{
"exact": {
"field": "categories",
"fieldType": "keyword",
"value": "7b23115a-3574-4098-9c32-33beb93aadf8"
}
}
]
},
productProjectionParameters: {
staged: false,
priceCurrency: 'AUD',
priceCountry: 'AU',
localeProjection: ['en-au'],
expand: [],
priceCustomerGroup: 'your-customer-group-id',
priceChannel: 'your-channel-id',
storeProjection: 'your-store-key',
}
"limit" : 10,
"offset" : 0
}
storeProjection
does not filter the products based on their assortment Product Selections. To support this, you need to update the query object to filter by the selections or by the store.Sorting results
categoryOrderHints
as the default sort, so that customers see results in the merchandised order.{
"query": {
"filter": [
{
"exact": {
"field": "categories",
"fieldType": "keyword",
"value": "7b23115a-3574-4098-9c32-33beb93aadf8"
}
}
]
},
"productProjectionParameters": {
"staged": false,
"priceCurrency": "AUD",
"priceCountry": "AU",
"localeProjection": [
"en-au"
],
"priceCustomerGroup": "your-customer-group-id",
"priceChannel": "your-channel-id",
"storeProjection": "your-store-key"
},
"limit": 10,
"offset": 0,
"sort": [
{
"field": "categoryOrderHints.7b23115a-3574-4098-9c32-33beb93aadf8",
"order": "asc"
}
]
}
Multifield sorting
Here we will sort by the variant price in ascending order and use the score as the tie breaker.
Score sorting is important to understand as it sorts results based on the relevance score. This is most important when you are doing full-text searches and want the results to be ordered by the relevance score. In the current example, we are only filtering by category, so there will not be a score to impact the sort.
{
"sort": [
{
"field": "variants.prices.centAmount",
"order": "asc",
"mode": "min"
},
{
"field": "score",
"order": "desc"
}
]
}
Identify matching Product Variants
variants.attributes.*
, variants.prices.*
), you often need to know which variants matched the query, not just that the parent product had a match. This is crucial for accurately displaying relevant variant information, such as specific images or attributes, on your product listing pages.matchingVariants
parameter set to true
in your ProductSearchRequest
.Enable matching variant identification
matchingVariants
to true
in the request body:{
"query": {
/* your query criteria */
},
"matchingVariants": true
}
matchingVariants
is true
in the request, each product in the search response will include a matchingVariants
object. This object provides details about which of its variants satisfied the query.matchingVariants
object:allMatched
(boolean):true
: Indicates that all variants of this product matched the query.false
: Indicates that only a subset of this product's variants matched the query.
matchedVariants
(array of objects):- If
allMatched
isfalse
, this array lists the specific variants that matched. Each object in the array contains theid
andsku
of a matching variant. - If
allMatched
istrue
, this array will be empty, as it's implied all variants are matches.
- If
Example: partial variant match
In the scenario below, only specific variants of the product matched the query:
{
"id": "babc4246-9a2c-4493-9332-89601d2086ce", // ID of the matching Product
"matchingVariants": {
"allMatched": false,
"matchedVariants": [
{
"id": ffe365f4-63b5-42ff-9ca7-03e915a2827d, // Internal ID of the variant
"sku": "CSKW-093"
},
{
"id": 436ae50f-ac43-4828-8801-5b75c8991ed8,
"sku": "CSKP-0932"
},
{
"id": 17af7efc-0cba-4560-af09-bfb365a077d3,
"sku": "CSKG-023"
}
]
}
}
// ... other products in the response
Example: all variants match
In the below scenario, all variants of the product matched the query:
{
"id": "8ef50e1b-a63f-42d2-80b4-84345bb76328", // ID of the matching Product
"matchingVariants": {
"allMatched": true,
"matchedVariants": [] // Empty because all variants matched
}
}
// ... other products in the response
matchingVariants
feature, you can pinpoint and present the most relevant variant information to your users based on their search.Complex filtering
query
object in the search request body. The main types are:and
: Requires all nested expressions to be true for a product to match.or
: Requires at least one of the nested expressions to be true.not
: Excludes products that match the nested expressions.filter
: Similar toand
, but expressions inside afilter
do not contribute to the relevance scoring of the results. Useful for applying conditions without affecting which results appear "more relevant". So far our examples have only used filters for getting products by category id.
{
"query": {
"COMPOUND_TYPE": [ // for example, "and", "or", "not", "filter"
// ... Simple or other Compound expressions go here ...
]
}
}
Example: Let's say we want products that are both in English named "T-Shirt" AND have a specific attribute. The and compound expression structures this logic:
{
"query": {
"and": [
{ /* Condition 1: Name is T-Shirt */ },
{ /* Condition 2: Has specific attribute */ }
]
}
}
The building blocks: simple expressions
name
, variants.attributes.color
, variants.prices.centAmount
).These are the actual tests you want to perform:
exact
: Is the field value exactly this? (Case-sensitive or insensitive)fullText
: Does the field contain these words? (Good for searching descriptions, names)prefix
: Does the field value start with this? (Good for auto-complete scenarios)range
: Is the field value (like price or date) within these boundaries?wildcard
: Does the field value match this pattern (using*
or?
)?exists
: Does this field simply have a value (is not null)?
query
object (if it's the only condition) or within the array []
of a compound expression.// Example: An 'exact' simple expression
{
"exact": {
"field": "name", // Which field to search
"language": "en", // Language for localized fields
"value": "T-Shirt", // The value to match exactly
"caseInsensitive": true // Optional: ignore case sensitivity
}
}
Putting it together: build complex queries
Now, let's combine them. Simple expressions slot into the arrays of compound expressions to build sophisticated logic.
Let’s look at an example where we want to find products where the English name contains "T-Shirt" (fullText) AND which belong to the shirt category (exact inside a filter):
{
"query": {
"and": [
{
"fullText": {
"field": "name",
"language": "en",
"value": "T-Shirt",
"caseInsensitive": true
}
},
{
"filter": [
{
"exact": {
"field": "categories",
"fieldType": "keyword",
"value": "7b23115a-3574-4098-9c32-33beb93aadf8"
}
}
]
}
]
},
"limit": 4
}
This full text search will reduce our results to 1:
{
"total": 1,
"offset": 0,
"limit": 20,
"facets": [],
"results": [
{
"id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27"
}
]
}
and
to or
, we should expect to get all products from the shirt category as well as products that are white in the name field.{
"total": 245,
"offset": 0,
"limit": 4,
"facets": [],
"results": [
{
"id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27"
},
{
"id": "8d040d38-e7b9-43f2-a5e9-9494e3916830"
},
{
"id": "26beb46b-7d09-4700-b3ce-e678ae1abc6c"
},
{
"id": "05318d76-eaf5-4a10-b662-007969266d22"
}
]
}