# Use Product Search Now let's take a look at the more flexible and efficient Products 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. 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. ### 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 Note: GraphQL Product Search facets remain in beta. For details on API stability levels, see the [API compatibility policy](/offering/compatibility.md). The Product Search has an interface of [`ProductSearchRequest`](/api/projects/product-search.md#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](/api/projects/product-search.md#matching-variants). | | `facets` | `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 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 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](/api/projects/product-search.md#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: ```typescript // 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](/api/search-query-language.md). 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: ```typescript async function findProductsByCategorySlug( slugLocale: string, slugValue: string ): Promise | undefined> { try { // Step 1 & 2: Find the category by its slug const categoryResponse: ClientResponse = 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 = { filter: [`categories.id:"${categoryId}"`], limit: 20, offset: 0, withTotal: true, }; 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: ProductProjection) => ({ 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. ```json { "total": 2, "offset": 0, "limit": 10, "facets": [], "results": [ { "id": "e8c24bc0-eedd-4331-b65e-9b18e663dc27" }, { "id": "8d040d38-e7b9-43f2-a5e9-9494e3916830" } ] } ``` ## Get full product data To get the full product data, we have the following options: - Use [Product Search with GraphQL](/api/projects/product-search.md#with-graphql-query). - Use the [Product Projections API](/api/projects/product-search.md#with-product-projection-api). We will use the query endpoint of the Product Projection API with the list of returned product IDs we use as filter. Additionally, we provide query parameters for locale projection and the current projection: ```typescript title="Using Product Projections API to get full product data" const productProjectionResponse: ClientResponse = 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: ```json { "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. We used most of them already for 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. ## 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](/api/projects/product-search.md#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. ```json { "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. ```json { "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: ```json { "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](/api/projects/product-search.md#matching-variants). - 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: ```json { "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: ```json { "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. ```json { "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: ```json { "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. ```json // 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): ```json { "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: ```json { "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. ```json { "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" } ] } ``` ## Related pages - [Area overview page with navigation](/learning-implement-product-discovery-and-presentation.md) - [Previous page: Use Product Projections](/learning-implement-product-discovery-and-presentation/build-product-listing-pages/use-product-projections.md) - [Next page: Control facet calculations](/learning-implement-product-discovery-and-presentation/build-product-listing-pages/control-facet-calculations.md)