Use Product Search

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

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.

The Product Search has an interface of ProductSearchRequest, and contains the following parameters:
ParameterTypeDescription
querySearchQueryThe search query against searchable Product fields.
sortArray of SearchSortingControls how results of your query are sorted. If not provided, the results are sorted by relevance score in descending order.
limitIntMaximum number of search results to be returned in one page. Default: `20` Minimum: `0` Maximum: `100`
offsetIntThe number of search results to be skipped (offset) in the response for pagination. Default: `0` Minimum: `0` Maximum: `10000`
markMatchingVariantsBooleanIf 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 BETAProductSearchProjectionParamsControls data integration with Product Projection parameters. If not set, the result does not include the Product Projection.
facets BETAArray of ProductSearchFacetExpressionSet this field to request facets.
postFilterSearchQuerySpecify 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

Similar to Product Projections, we must get the Category ID from the Category 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:
Let’s check which fields are available on the endpoint for Categories; consult the keyword fields in the documentation. Here are two important options:
  • 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();


Before we continue and join the query for getting the category ID from its slug and executing the Product Search by category, you might have noticed the query structure is quite different to predicates. This is because the Product Search uses the new and more flexible Search query language.

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.
We will use the Product Projection parameters for the ease of developer exprience. To do this, we will add 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

Lets explore the productProjectionParameters more deeply and see what parameters can be passed:
  • 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

Sorting is very similar to Product Projections; you can sort by one or multiple fields. When using two fields, the second field acts as a tie breaker. If no sort option is provided, results are sorted by relevance score in descending order.
Again, we will sort by 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

When your search queries target fields specific to product variants (for example, 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.
To retrieve a list of SKUs for the variants that specifically matched your query criteria, include the matchingVariants parameter set to true in your ProductSearchRequest.

Enable matching variant identification

Set matchingVariants to true in the request body:
{
  "query": {
    /* your query criteria */
  },
  "matchingVariants": true
}
If 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.
Structure of the 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 is false, this array lists the specific variants that matched. Each object in the array contains the id and sku of a matching variant.
    • If allMatched is true, this array will be empty, as it's implied all variants are matches.

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
By using the matchingVariants feature, you can pinpoint and present the most relevant variant information to your users based on their search.

Complex filtering

Imagine you need to find products that meet multiple criteria simultaneously (like "red AND large") or meet at least one of several criteria (like "shirt OR blouse"). This is where compound expressions come in. They act as containers or logical operators that define how multiple search conditions relate to each other.
They are the outermost layer of your query object in the search request body. The main types are:
  1. and: Requires all nested expressions to be true for a product to match.
  2. or: Requires at least one of the nested expressions to be true.
  3. not: Excludes products that match the nested expressions.
  4. filter: Similar to and, but expressions inside a filter 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

Now for the building blocks themselves: simple expressions are the fundamental units that define a single search condition against a specific field on your product data (like 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)?
Structure: A simple expression sits inside the 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 if we change the 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"
    }
  ]
}

Test your knowledge