Use Product Search

Leverage the Product Search endpoint for flexible and efficient product listings, including advanced querying, sorting, and tailored product data retrieval.

Ask about this Page
Copy for AI
View as Markdown

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 and retrieve the full Product and Variant data with the Product Projections and Variant Projections APIs, according to the Project's catalog model.

Now let's take a look at the more flexible and efficient Product Search endpoint. The Product Search API achieved general availability in June 2024, with facets achieving GA in October 2025, making the complete feature set production-ready and fully supported in Classic Projects.
BETA Product Search support for Projects with the ProductCatalogModel set to Modular is in public beta.

Product 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.

Production readiness

For implementation in production systems, the API offers:

  • Guaranteed backward compatibility for GA features
  • Full support and SLA coverage
  • Stable feature set suitable for long-term implementations
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.
facetsArray 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 Product Search for electronicstech.com.au/en-au/c/headphones-speakers-audio.
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 Projections endpoint.

We will build the request body using the Categories. The below query assumes that we have the category ID:

URLs and Categories
// 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:

URLs and Categories
async function findProductsByCategorySlug(
  slugLocale: string,
  slugValue: string
): Promise<Array<{ id: string }> | undefined> {
  try {
    // Step 1 & 2: Find the category by its slug
    const categoryResponse: ClientResponse<CategoryPagedQueryResponse> =
      await apiRoot
        .categories()
        .get({
          queryArgs: {
            withTotal: false,
            where: `slug(${slugLocale} = "${slugValue}")`,
          },
        })
        .execute();

    if (!categoryResponse.body || categoryResponse.body.results.length === 0) {
      console.error(
        `Category with slug ${slugLocale}="${slugValue}" not found.`
      );
      return undefined;
    }
    const category: Category = categoryResponse.body.results[0];
    const categoryId: string = category.id;
    const categoryName: string = category.name[slugLocale] ?? 'N/A';
    console.log(`Found category "${categoryName}" with ID: ${categoryId}`);

    // --- Step 3: Query Products using the Product Search API ---
    console.log(
      `Fetching product IDs via Product Search for category ID: ${categoryId}...`
    );

    const searchRequestBody = {
      query: {
        filter: [
          {
            exact: {
              field: 'categories',
              value: categoryId,
            },
          },
        ],
      },
      limit: 20,
      offset: 0,
    };

    const productSearchResponse = await apiRoot
      .products()
      .search()
      .post({
        body: searchRequestBody,
      })
      .execute();

    // --- Step 4: Process Product Search Results ---
    if (
      !productSearchResponse.body ||
      productSearchResponse.body.results.length === 0
    ) {
      console.log(`No products found for category "${categoryName}".`);
      return [];
    }

    const productsResult: Array<{ id: string }> =
      productSearchResponse.body.results.map((result) => ({
        id: result.id,
      }));

    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;
  } 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 ---
findProductsByCategorySlug('en-au', 'mobile-phones')
  .then((productIds) => {
    if (productIds) {
      console.log(
        `\nFunction execution finished successfully. Received ${productIds.length} product IDs.`
      );
      console.log(productIds);
    } 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

As you can see, the response structure is similar to the Product Projections response, but the results array contains only the IDs of the products matching the query.
{
  "total": 2,
  "offset": 0,
  "limit": 10,
  "facets": [],
  "results": [
    {
      "id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27"
    },
    {
      "id": "8d040d38-e7b9-43f2-a5e9-9494e3916830"
    }
  ]
}

Get full product and variant data

To get the full product and variant data, we have the following options:

The fields that hold Variant data depend on the ProductCatalogModel of the Project.

Classic

Modular

In Classic Projects, the Product Projection contains the Variants in masterVariant and variants. Query the Product Projections endpoint with the list of product IDs returned by Product Search as a filter, and provide query parameters for locale projection and the current projection:
Using Product Projections API to get full product data
const productProjectionResponse: ClientResponse<ProductProjectionPagedQueryResponse> =
  await apiRoot
    .productProjections()
    .get({
      queryArgs: {
        withTotal: false,
        // Filter product projections by the product IDs obtained from Product Search
        where: `id in ("${productIds.join('","')}")`,
        offset: 0,
        // query current projection
        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',
      },
    })
    .execute();

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."
}

You can add query parameters for locale projection, store projection and price selection according to your needs. You already used most of them in the Product Projections endpoint exercise.

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.
In Modular ProjectsBETA, masterVariant and variants in a Product Projection are empty. Query the Variant Projections endpoint instead to get Variant-level data.

Because this category query filters on a Product-level field, all Variants of each matching Product match, so filter by the returned Product IDs:

Query Variant Projections for the matching Product IDshttp
GET /{projectKey}/variant-projections?where=product(id in ("id1","id2"))&staged=false
If you filter on a Variant-specific field instead (for example, variants.attributes.* or variants.prices.*), only some Variants of a Product match, and a Product ID predicate would return the Variants that didn't. Set markMatchingVariants to true so the search response identifies them in matchedVariants.
Filter by the sku of each matched entry. A SKU is unique across all Variants in a Project, so one predicate targets exactly the Variants that matched:
Query Variant Projections for the matching Variant SKUshttp
GET /{projectKey}/variant-projections?where=sku in ("sku1","sku2","sku3")&staged=false
Because sku is optional on a Variant, matched Variants without one need the entry's id, which is the Variant's numeric id rather than its UUID. A variantId is only unique within its parent Product, so combine it with the Product IDs:
Query Variant Projections for the matching Variant IDshttp
GET /{projectKey}/variant-projections?where=product(id in ("id1","id2")) and variantId in (1,2,3)&staged=false
Prefer the SKU predicate where you can. Because a variantId repeats across Products, the second predicate can return a Variant whose ID matched on a different Product, so reconcile the response against matchedVariants before rendering. For details, see Identify matching Product Variants.

The following examples cover this partial-match case. They build the SKU predicate from the matched Variants, assume every matched Variant has a SKU, and add query parameters for locale projection, price selection, and the current projection. The category query earlier on this page doesn't request matching-variant marking, so it uses the Product ID predicate instead:

Using Variant Projections API to get full variant data
// Variant-specific search: only some variants of a product match, and the
// search request sets markMatchingVariants to true. matchedSkus holds the sku
// of each entry in matchingVariants.matchedVariants; a SKU is unique across all
// variants in a project, so one predicate targets exactly what matched.
// Assumes every matched variant has a SKU. Because sku is optional, a catalog
// that allows variants without one needs the variantId fallback instead.
// A Product-level query such as a category listing matches every variant, so
// filter those by product id rather than building this predicate.
const where = `sku in ("${matchedSkus.join('","')}")`;

const variantProjectionResponse: ClientResponse<VariantProjectionPagedQueryResponse> =
  await apiRoot
    .variantProjections()
    .get({
      queryArgs: {
        withTotal: false,
        where,
        offset: 0,
        // query current projection
        staged: false,
        // Ensure that we only get the locale matching the value in the URL
        localeProjection: 'en-au',
        // Apply price selection to populate the price field
        priceCurrency: 'EUR',
        limit: 20, // Adjust limit as needed
        // You might want to add other filters or sorting here, for example:
        // sort: 'name.en asc',
      },
    })
    .execute();

Example response showing the same Variant as a Variant Projection:

{
  "limit": 20,
  "offset": 0,
  "count": 1,
  "results": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "version": 1,
      "createdAt": "2023-07-26T01:16:50.451Z",
      "staged": false,
      "default": false,
      "variantId": 2,
      "product": {
        "typeId": "product",
        "id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27"
      },
      "name": {
        "en": "Shirt Aspesi white",
        "de": "Bluse Aspesi weiß"
      },
      "slug": {
        "en": "aspesi-shirt-h805-white",
        "de": "aspesi-bluse-h805-weiss"
      },
      "key": "M0E20000000ED0X",
      "sku": "M0E20000000ED0X",
      "images": [
        {
          "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/081223_1_large.jpg",
          "dimensions": {
            "w": 0,
            "h": 0
          }
        }
      ],
      "assets": [],
      "attributes": [
        {
          "name": "articleNumberManufacturer",
          "value": "H805 C195 85072"
        },
        {
          "name": "size",
          "value": "36"
        }
      ],
      "categories": [
        {
          "typeId": "category",
          "id": "9fa7e767-cd4e-4d43-9ac1-9d3bbfd21734"
        }
      ],
      "price": {
        "id": "73ae093e-a23b-4bd1-88b0-f63f84945ae9",
        "value": {
          "type": "centPrecision",
          "centAmount": 16625,
          "currencyCode": "EUR",
          "fractionDigits": 2
        }
      }
    }
  ]
}
Unlike the Product Projection response, results contains one entry per Variant, so the Variant Projections endpoint returns a separate entry for each Variant of the matching Products instead of a single Product with nested Variants. The price field is only populated because the request includes price selection parameters; without them, it's absent from the response.
Because each Variant counts as one result, limit and offset page over Variants rather than Products. A limit of 20 covers 20 Variants, which can be fewer than 20 Products, so size the limit to the number of Variants you expect and page through the remaining results as described in Pagination.

Pagination

The offset parameter offsets the starting point of the returned data set. It can be used to fetch data by page number by calculating the required offset. The formula is offset = (page_number - 1) * results_per_page.

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.
The Product Search API supports sorting by categoryOrderHints, enabling you to display products in their merchandised order.
We will sort by categoryOrderHints as the default sort, so that customers see results in the merchandised order.
{
    "query": {
        "filter": [
            {
                "exact": {
                    "field": "categories",
                    "value": "7b23115a-3574-4098-9c32-33beb93aadf8"
                }
            }
        ]
    },
    "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 markMatchingVariants parameter set to true in your ProductSearchRequest.

Enable matching variant identification

Set markMatchingVariants to true in the request body:
{
  "query": {
    /* your query criteria */
  },
  "markMatchingVariants": true
}
If markMatchingVariants is true in the request, each product in the search response includes 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": 1,
        "sku": "CSKW-093"
      },
      {
        "id": 2,
        "sku": "CSKP-0932"
      },
      {
        "id": 3,
        "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?
  • fuzzy: Does the field value approximately match the given value, allowing for minor typos or misspellings?
  • 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-au",
          "value": "T-Shirt",
          "caseInsensitive": true
        }
      },
      {
        "filter": [
          {
            "exact": {
              "field": "categories",
              "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