# Migrate to Product Search Since the [Product Projection Search](/api/projects/product-projection-search.md) API is deprecated, we recommend using the [Product Search](/api/projects/product-search.md) API as its replacement. Both APIs use separate search indexes and can be active in the same Project if it has been created before 1 September 2026. You can therefore migrate incrementally and compare results before you switch traffic. Use this guide to migrate an existing Product Projection Search implementation to Product Search. For a feature-by-feature comparison of both APIs, see [Product Search versus Product Projection Search](/api/storefront-search-overview.md#product-search-versus-product-projection-search). This guide focuses on use cases related to storefront search, especially for the Product Listing Page and search results page. Find tips about implementing Product Detail pages in our learning module [Implement product discovery and presentation](/learning-implement-product-discovery-and-presentation). If your use cases require features not covered by Product Search, for example the staged representation of the Products, contact [commercetools support](https://support.commercetools.com/) to discuss possible migration paths. The migration consists of the following steps: 1. [Inventory your current usage](/tutorials/migration-guides/product-search-migration-guide.md#inventory-your-current-usage) 2. [Activate the Product Search API](/tutorials/migration-guides/product-search-migration-guide.md#activate-the-product-search-api) 3. [Migrate the query](/tutorials/migration-guides/product-search-migration-guide.md#migrate-the-query) 4. [Migrate full-text search](/tutorials/migration-guides/product-search-migration-guide.md#migrate-full-text-search) 5. [Migrate fuzzy search](/tutorials/migration-guides/product-search-migration-guide.md#migrate-fuzzy-search) 6. [Migrate filters](/tutorials/migration-guides/product-search-migration-guide.md#migrate-filters) 7. [Migrate facets](/tutorials/migration-guides/product-search-migration-guide.md#migrate-facets) 8. [Migrate sorting](/tutorials/migration-guides/product-search-migration-guide.md#migrate-sorting) 9. [Migrate pagination](/tutorials/migration-guides/product-search-migration-guide.md#migrate-pagination) 10. [Retrieve full Product data](/tutorials/migration-guides/product-search-migration-guide.md#retrieve-full-product-data) 11. [Verify and roll out](/tutorials/migration-guides/product-search-migration-guide.md#verify-and-roll-out) 12. [Decommission Product Projection Search](/tutorials/migration-guides/product-search-migration-guide.md#decommission-product-projection-search) ### Key differences | Feature | Product Projection Search | Product Search | | --- | --- | --- | | Query interface | GET or POST with URL-encoded query parameters | POST with a JSON body using the [search query language](/api/search-query-language.md) | | Response format | Full [ProductProjection](/search.md?urn=ctp:api:type:ProductProjection) objects | Product IDs only, with matching Product Variants on request | | Indexed projection | Current and staged | Current only | | Full-text search | Fixed set of fields through one `text.{language}` parameter | Explicit per field, with optional boosting | | Price selection | During the search phase through query parameters | During data retrieval (see [Retrieve full Product data](/tutorials/migration-guides/product-search-migration-guide.md#retrieve-full-product-data)) | | Prices considered for filtering and sorting | First [Embedded Price](/api/pricing-and-discounts-overview.md#embedded-prices) of each Product Variant | One valid Price per scope of the Product Variant (Embedded or Standalone according to the Product's `priceMode`) | | Maximum results per page (`limit`) | 500 | 100 | | Default facet counting | Product Variants | Products | | Facet results | Object keyed by attribute path or alias | Array of named objects | Five of these changes require design decisions rather than a mechanical rewrite. Read them before you start. - **The response contains IDs, not Products.** You need a second step to retrieve the data you render. See [Retrieve full Product data](/tutorials/migration-guides/product-search-migration-guide.md#retrieve-full-product-data). - **Full-text search is explicit per field.** The `text.{language}` parameter searched a fixed set of fields with `name` weighted heavier. You now compose the fields and their weights yourself. See [Migrate full-text search](/tutorials/migration-guides/product-search-migration-guide.md#migrate-full-text-search). - **Only the current Product representation is indexed.** No equivalent of `staged=true` exists. For staged data, use the [Query ProductProjections](/search.md?urn=ctp:api:endpoint:/{projectKey}/product-projections:GET) endpoint with `staged=true`. - **Price selection happens after the search.** Use the `variants.prices.*` fields to filter and sort, and apply price selection during data retrieval. - **Product Search has no GET method.** All requests go to [Search Products](/search.md?urn=ctp:api:endpoint:/{projectKey}/products/search:POST). If you cache Product Listing Page responses on a CDN keyed by URL, that strategy needs to change. ### Inventory your current usage List every Product Projection Search call in your integration and record the following for each. This list is both your migration checklist and your test matrix. - The query parameters used: `text.{locale}`, `fuzzy`, `fuzzyLevel`, `filter.query`, `filter`, `filter.facets`, `facet`, `sort`, `expand`, `staged`, `markMatchingVariants`, the price selection parameters, `localeProjection`, and `storeProjection`. - The fields your storefront reads from each response. - Whether the caller needs staged data. - Whether the caller relies on facet counts being Product Variant counts. This default changes; see [Set the facet counting level](/tutorials/migration-guides/product-search-migration-guide.md#set-the-facet-counting-level). Then check your [Product Types](/api/projects/productTypes.md) against the [indexing limits](/tutorials/migration-guides/product-search-migration-guide.md#indexing-limits-and-validation). Also confirm that every Attribute you filter, facet, or sort on has `isSearchable` set to `true` in its [AttributeDefinition](/search.md?urn=ctp:api:type:AttributeDefinition). ### Activate the Product Search API Product Search is not active by default, and activating it triggers indexing. Activate it early so the index is ready before you start testing. Use the Merchant Center under **Settings** > **Project settings** > **Storefront Search**, or the [Change Product Search Indexing Enabled](/api/projects/project.md#change-product-search-indexing-enabled) update action with `mode: ProductsSearch`. ```bash title="Activate Product Search" curl -sH "Authorization: Bearer {access_token}" -X POST -H "Content-Type: application/json" -d '{ "version": {version_number}, "actions": [ { "action": "changeProductSearchIndexingEnabled", "enabled": true, "mode": "ProductsSearch" } ] }' https://api.{region}.commercetools.com/{projectKey} ``` Activating `ProductsSearch` does not deactivate `ProductProjectionsSearch`. Both indexes are maintained independently while you migrate. While indexing is in progress, or if Product Search is inactive, the API returns an `ObjectNotFound` error. Both APIs are deactivated automatically after **30** consecutive days without calls. Keep calling Product Projection Search until you have fully switched over, otherwise it deactivates and rejects requests with a [SearchDeactivated](/search.md?urn=ctp:api:type:SearchDeactivatedError) error. This matters if you keep a fallback path that receives no traffic. Check your API Client scopes. Product Search requires `view_published_products:{projectKey}`. If you access it with an external OAuth token, include `view_products:{projectKey}` explicitly in the token scope instead of relying on implied permissions. ### Migrate the query Move each Product Projection Search parameter to its Product Search equivalent. | Product Projection Search | Product Search | Notes | | --- | --- | --- | | `filter.query` | `query` in [ProductSearchRequest](/search.md?urn=ctp:api:type:ProductSearchRequest) | Applied before facets are calculated. Affects results and facet counts. | | `filter` | `postFilter` in [ProductSearchRequest](/search.md?urn=ctp:api:type:ProductSearchRequest) | Applied after facets are calculated. Affects results only. | | `filter.facets` | `filter` inside each facet expression | Scope changes from global to per-facet. See [Rebuild multi-select faceting](/tutorials/migration-guides/product-search-migration-guide.md#rebuild-multi-select-faceting). | | `text.{language}` | [fullText](/api/search-query-language.md#fulltext) expression per field | See [Migrate full-text search](/tutorials/migration-guides/product-search-migration-guide.md#migrate-full-text-search). | | `fuzzy`, `fuzzyLevel` | [fuzzy](/api/search-query-language.md#fuzzy) expression with `level` | Per field instead of global. | | `facet` | `facets` array | See [Migrate facets](/tutorials/migration-guides/product-search-migration-guide.md#migrate-facets). | | `sort` | `sort` array | Structured objects instead of strings. | | `limit`, `offset` | `limit`, `offset` | Maximum `limit` drops from 500 to 100. | | `markMatchingVariants` | `markMatchingVariants` | Response shape differs. See [Read matching variants](/tutorials/migration-guides/product-search-migration-guide.md#read-matching-variants). | | `expand` | - | Use the data retrieval step. | | `staged` | - | Only current Products are indexed. | | `priceCurrency`, `priceCountry`, `priceCustomerGroup`, `priceCustomerGroupAssignments`, `priceChannel`, `priceRecurrencePolicy` | `variants.prices.*` fields for filtering and sorting | Price selection moves to the data retrieval step. | | `localeProjection` | - | Use `localeProjection` in the data retrieval step. | | `storeProjection` | `stores` field for filtering | Use `storeProjection` in the data retrieval step for Store-specific data. | Do not use the `productProjectionParameters` option on Product Search. It is deprecated and will be removed in a future release. #### Choose between and, or, and filter Product Projection Search combined repeated `filter` parameters with `AND`, and comma-separated values within one parameter with `OR`. Product Search makes this explicit with [compound expressions](/api/search-query-language.md#compound-expressions): - `and`: all sub-expressions must match, and all contribute to the relevance score. - `or`: at least one sub-expression must match. - `not`: none of the sub-expressions match. - `filter`: behaves like `and`, but calculates no relevance score. Use it for anything that should not influence ranking, such as Category and Attribute selections. It performs faster. A [SearchQuery](/api/search-query-language.md#searchquery) can contain up to **50** simple or compound expressions, and string values are limited to **256** characters. To match several values on the same field, use the `values` array of a single [exact](/api/search-query-language.md#exact) expression rather than several `or`-combined expressions. This keeps you within the expression limit. ```json title="Match multiple values on one field" { "query": { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "values": ["black", "grey"] } } } ``` #### Respect field-level validation Product Search organizes searchable fields into three levels and validates compound expressions against them. Product Projection Search had no such rule, so a query that worked before can be rejected with a `400` [InvalidInput](/search.md?urn=ctp:api:type:InvalidInputError) error. | Level | Searchable Product fields | Rank | | --- | --- | --- | | Context | `stores`, `productSelections` | 1 | | Product | all Product and Product Variant fields except prices | 2 | | Price | `variants.prices.*` | 3 | Keep each compound expression on a single level, and combine levels at the top of the query, ordered from context to price. ```json title="Query combining context, product, and price criteria" { "query": { "and": [ { "exact": { "field": "stores", "value": "{{store-id}}" } }, { "filter": [ { "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } }, { "exact": { "field": "variants.attributes.color", "fieldType": "text", "value": "red" } } ] }, { "and": [ { "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } }, { "range": { "field": "variants.prices.currentCentAmount", "gte": 1000, "lte": 5000 } } ] } ] } } ``` If you need to nest mixed levels more deeply, see [validation logic details](/api/projects/product-search.md#validation-logic-details). Two rules apply. The rank of a multi-level expression must be higher than or equal to the rank of any single-level expression it is combined with. Two multi-level expressions cannot be combined. ### Migrate full-text search This step has the largest effect on which results your storefront returns, so treat it as a redesign rather than a translation. In Product Projection Search, the `text.{language}` parameter searched a fixed set of fields in one call. Those fields were `name`, `description`, `slug`, `sku`, and `searchKeywords`, plus searchable [Attributes](/api/projects/products.md#attribute). The `name` field was weighted heavier than the others. The `metaTitle` and `metaKeywords` fields were not indexed for full-text search. ```text title="Product Projection Search full-text query" GET /{projectKey}/product-projections/search?text.en=red+shoes ``` Product Search has no single equivalent parameter. Compose the fields you want to search with [fullText](/api/search-query-language.md#fulltext) expressions inside an `or` compound expression. Use [boosting](/api/search-query-language.md#boost-query-results) to reproduce the heavier weighting of `name`. ```json title="Product Search full-text query across several fields" { "query": { "or": [ { "fullText": { "field": "name", "language": "en", "value": "red shoes", "boost": 3 } }, { "fullText": { "field": "searchKeywords", "language": "en", "value": "red shoes", "boost": 2 } }, { "fullText": { "field": "description", "language": "en", "value": "red shoes" } }, { "fullText": { "field": "slug", "language": "en", "value": "red shoes" } }, { "exact": { "field": "variants.sku", "value": "red shoes" } } ] } } ``` Note the following differences in behavior: - `fullText` requires all provided terms to match by default. Set `mustMatch` to `any` to match any term, which is closer to the behavior of a broad storefront search box. - `boost` values above `1` raise relevance, values between `0` and `1` lower it. - `variants.sku` is a keyword field, so it supports `exact`, `prefix`, `wildcard`, and `fuzzy` expressions, but not `fullText`. - Product Search only analyzes Locales configured in the Project's `languages`. A query for an unconfigured Locale returns no results. - A [prefix](/api/search-query-language.md#prefix) expression on `name` is usually the better choice for type-ahead than a [wildcard](/api/search-query-language.md#wildcard) expression, which performs less efficiently. ### Migrate fuzzy search In Product Projection Search, fuzziness applies to the whole search text through the `fuzzy` and `fuzzyLevel` query parameters. ```text title="Product Projection Search fuzzy query" GET /{projectKey}/product-projections/search?text.en=shoes&fuzzy=true&fuzzyLevel=1 ``` In Product Search, fuzziness applies per field through a [fuzzy](/api/search-query-language.md#fuzzy) expression. Combining it with a boosted `fullText` expression in an `or` gives you precision plus tolerance for typos. ```json title="Product Search fuzzy query" { "query": { "or": [ { "fullText": { "field": "name", "language": "en", "value": "shoes", "boost": 3 } }, { "fuzzy": { "field": "name", "language": "en", "value": "shoes", "level": 1 } } ] } } ``` The `level` field replaces `fuzzyLevel`. As before, the API caps the level by term length: - `0` for terms of 1-2 characters. - `1` for terms of 3-5 characters. - `2` for longer terms. Product Projection Search returned an [InvalidInput](/search.md?urn=ctp:api:type:InvalidInputError) error when the requested level exceeded the maximum. Product Search adjusts the level downward instead. Apply `fuzzy` only to short fields that users type into directly, such as `name` or a color Attribute. Use no more than around 10 `fuzzy` expressions per query. ### Migrate filters Filter expression strings become typed expressions, and several field paths change. Check every path against [searchable Product fields](/api/projects/product-search.md#searchable-product-fields). #### Map the field paths | Product Projection Search | Product Search | | --- | --- | | `categories.id` | `categories` | | `categories.id: subtree("{id}")` | `categoriesSubTree` | | `key` | `key` | | `productType.id` | `productType` | | `taxCategory.id` | `taxCategory` | | `state.id` | `state` | | `variants.sku` | `variants.sku` | | `variants.key` | `variants.key` | | `variants.price.centAmount` | `variants.prices.centAmount` | | `variants.scopedPrice.value.centAmount` | `variants.prices.centAmount` | | `variants.scopedPrice.currentValue.centAmount` | `variants.prices.currentCentAmount` | | `variants.scopedPriceDiscounted` | `variants.prices.discounted` | | `variants.attributes.{name}` | `variants.attributes.{name}`, with `fieldType` | | `variants.availability.isOnStock` | `variants.availability.isOnStock` | | `variants.availability.channels.{id}.isOnStock` | `variants.availability.isOnStockForChannel` | | `variants.availability.availableQuantity` | `variants.availability.availableQuantity` | | `searchKeywords.{language}.text` | `searchKeywords`, with `language` | | `reviewRatingStatistics.*` | `reviewRatingStatistics.*` | | `createdAt`, `lastModifiedAt` | `createdAt`, `lastModifiedAt` | | - | `attributes.{name}` for Product-level Attributes | | - | `stores`, `variants.stores` | | - | `productSelections`, `variants.productSelections` | | - | `variants.prices.currencyCode`, `.country`, `.customerGroup`, `.channel`, `.id` | Reference-type Attributes keep the `.id` suffix, enum Attributes keep `.key`, and Money Attributes keep `.centAmount` or `.currencyCode`, as before. #### Map the filter operators | Product Projection Search | Product Search | | --- | --- | | `field:"{value}"` | [exact](/api/search-query-language.md#exact) with `value` | | `field:"{value-1}","{value-2}"` | `exact` with `values` | | `field:range ({from} to {to})` | [range](/api/search-query-language.md#range) with `gte` and `lte` | | `field:range (* to {to})` | `range` with `lte` only | | `field:exists` | [exists](/api/search-query-language.md#exists) | | `field:missing` | `not` wrapping `exists` | | `field:true` | `exact` with a boolean `value` | | Repeated `filter` parameters | `and` or `filter` compound expression | Three behavior changes to plan for: - **Range bounds are now explicit.** The `:range ({from} to {to})` filter included both bounds. Use `gte` and `lte` to keep that behavior; `gt` and `lt` exclude the bound. Note that range facet buckets include the lower bound and exclude the upper bound in both APIs. - **Boolean Attribute values are no longer quoted.** Custom Boolean Attributes required `variants.attributes.{name}:"true"`. Pass a real boolean with `"fieldType": "boolean"` instead. - **`not` combined with `exists` is not supported** on the `variants`, `variants.prices`, and `variants.sku` fields; such a query always returns an empty result. If you filter on `variants.prices:missing` or `variants.sku:missing`, you need a different approach, such as maintaining a Boolean Attribute for the condition. For details, see [evaluation of 'not' expressions](/api/projects/product-search.md#evaluation-of-not-expressions). #### Example: filter by Category and Attribute ```text title="Product Projection Search" GET /{projectKey}/product-projections/search ?filter.query=categories.id:subtree("{{category-id}}") &filter=variants.attributes.color.key:"black","grey" &filter=variants.availability.isOnStock:true ``` ```json title="Product Search" { "query": { "filter": [ { "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } } ] }, "postFilter": { "filter": [ { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "values": ["black", "grey"] } }, { "exact": { "field": "variants.availability.isOnStock", "value": true } } ] } } ``` #### Example: filter by Price Prices are where results are most likely to change. Product Projection Search filtered on the **first** [Embedded Price](/api/pricing-and-discounts-overview.md#embedded-prices) of each Product Variant and did not support [Standalone Prices](/api/projects/standalone-prices.md) at all. Product Search indexes one valid Price per scope of a Product Variant (Embedded or Standalone according to the Product's `priceMode`). The same intent can therefore match more Products. Verify affected Product Listing Pages explicitly. ```text title="Product Projection Search" GET /{projectKey}/product-projections/search ?priceCurrency=EUR&priceCountry=DE &filter=variants.scopedPrice.currentValue.centAmount:range (1000 to 5000) ``` ```json title="Product Search" { "query": { "and": [ { "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } }, { "exact": { "field": "variants.prices.country", "value": "DE" } }, { "range": { "field": "variants.prices.currentCentAmount", "gte": 1000, "lte": 5000 } } ] } } ``` Use `variants.prices.currentCentAmount` when discounted Prices should be taken into account, and `variants.prices.centAmount` when only the original Prices should count. ### Migrate facets Facet expressions move from strings in repeated `facet` parameters to objects in the `facets` array. | Product Projection Search | Product Search | | --- | --- | | [Term facet](/api/projects/product-projection-search.md#term-facets), such as `variants.attributes.color.key` | [distinct facet](/api/projects/product-search.md#distinct-facets) | | [Range facet](/api/projects/product-projection-search.md#range-facets), such as `variants.price.centAmount:range (...)` | [ranges facet](/api/projects/product-search.md#ranges-facets) | | [Filtered facet](/api/projects/product-projection-search.md#filtered-facets), such as `variants.attributes.color.key:"red"` | `distinct` facet with `includes`, or a [count facet](/api/projects/product-search.md#count-facets) with a `filter` | | `counting products` extension | `level: "products"`, which is the default | | `as {alias}` | `name` | | `filter.facets` parameter | `filter` inside the facet expression | | `categories.id: subtree("{id}")` facet | `distinct` facet on `categoriesSubTree` | | - | [stats facets](/api/projects/product-search.md#stats-facets) | | - | `missing` for a bucket of Products without a value | | - | `scope: "all"` for facets that ignore the query | | - | `sort` and `limit` on buckets | Unlike in Product Projection Search, subtree keys and plain Category keys can be combined in one facet through `includes`. #### Set the facet counting level This is the most easily missed change in the migration. Product Projection Search counted **Product Variants** by default and counted Products only when you added the `counting products` extension. Product Search counts **Products** by default and counts Product Variants when you set `level` to `variants`. If your storefront displays facet counts, decide per facet which entity the number represents and set `level` explicitly rather than relying on the default. Otherwise the numbers next to your filter options change silently. #### Rebuild multi-select faceting In Product Projection Search, `filter.facets` was a single global parameter: it narrowed every facet's aggregation except the facet it applied to. In Product Search, each facet expression carries its own `filter`. To reproduce multi-select faceting on a Product Listing Page: 1. Put non-facet criteria, such as the Category and the search text, in `query`. Both the results and every facet inherit them. 2. Put the union of all selected facet values in `postFilter`. This narrows the results only. 3. For each facet, set its `filter` to the selections of **all other** facets, excluding its own. The following example has a color facet and a size facet, with `black` and `m` selected. The size facet is filtered by color, and the color facet is filtered by size. ```json title="Multi-select faceting on a Product Listing Page" { "query": { "filter": [ { "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } } ] }, "postFilter": { "filter": [ { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "value": "black" } }, { "exact": { "field": "variants.attributes.size.key", "fieldType": "enum", "value": "m" } } ] }, "facets": [ { "distinct": { "name": "color", "field": "variants.attributes.color.key", "fieldType": "enum", "level": "products", "limit": 50, "filter": { "exact": { "field": "variants.attributes.size.key", "fieldType": "enum", "value": "m" } } } }, { "distinct": { "name": "size", "field": "variants.attributes.size.key", "fieldType": "enum", "level": "products", "limit": 50, "filter": { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "value": "black" } } } } ], "limit": 20, "offset": 0 } ``` #### Read the facet results Product Search returns facets as an array of objects, each identified by the `name` you assigned. Product Projection Search returned an object keyed by attribute path or alias. ```json title="ProductPagedSearchResponse" { "total": 148, "offset": 0, "limit": 20, "facets": [ { "name": "color", "buckets": [{ "key": "black", "count": 37 }] }, { "name": "size", "buckets": [{ "key": "m", "count": 42 }] } ], "results": [{ "id": "8fde2af0-6a2f-4633-9ba4-83566f769a7f" }] } ``` Rework any client code that reads facets by attribute path, such as `facets["variants.attributes.size"]`. The `dataType`, `total`, `other`, and `missing` fields of the Product Projection Search term facet result do not exist in Product Search. Use the `missing` option to get a bucket for Products without a value. Use `limit` (up to **200**) to control how many buckets are returned. ### Migrate sorting Sort strings become objects, and the sort mode moves out of the sort direction. | Product Projection Search | Product Search | | --- | --- | | `name.en asc` | `{ "field": "name", "language": "en", "order": "asc" }` | | `price desc` | `{ "field": "variants.prices.centAmount", "order": "desc", "mode": "max" }` | | `variants.scopedPrice.currentValue.centAmount asc` | `{ "field": "variants.prices.currentCentAmount", "order": "asc", "mode": "min", "filter": { ... } }` | | `variants.attributes.color.label.en asc.max` | `{ "field": "variants.attributes.color.label", "language": "en", "fieldType": "lenum", "order": "asc", "mode": "max" }` | | `categoryOrderHints.{id} asc` | `{ "field": "categoryOrderHints.{id}", "order": "asc" }` | | `score desc` | `{ "field": "score", "order": "desc" }` | | `createdAt`, `lastModifiedAt`, `id` | unchanged as `field` values | Translate the compound direction suffixes as follows: `asc.min` becomes `order: "asc"` with `mode: "min"`, `desc.max` becomes `order: "desc"` with `mode: "max"`, and so on. Product Search adds the `avg` and `sum` [sort modes](/api/search-query-language.md#sort-mode). Two behaviors carry over unchanged: Products with no value for the sort field are placed last, and an unstable sort produces non-deterministic paging. End every `sort` array with a field that has distinct values across all Products, usually `id`. ```json title="Deterministic sorting with a tie-breaker" { "sort": [ { "field": "name", "language": "en", "order": "asc" }, { "field": "id", "order": "asc" } ] } ``` #### Replace scoped price sorting Sorting by `variants.scopedPrice.*` relied on the price selection parameters of the search request. In Product Search, express the scope in the sort's own [sort filter](/api/search-query-language.md#sort-filter). ```json title="Sort by Price scoped to a Channel and currency" { "sort": [ { "field": "variants.prices.centAmount", "order": "asc", "mode": "min", "filter": { "and": [ { "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } }, { "exact": { "field": "variants.prices.channel", "value": "{{channel-id}}" } } ] } } ] } ``` Sorting runs independently of `query` and `postFilter`. The sort filter must therefore repeat any scoping the ranking depends on. A sort filter must also stay on the same field level as the sort field. A price sort therefore cannot be scoped by a product-level field such as an Attribute value. If your current implementation sorts by scoped price while filtering by an Attribute, the ranking scope becomes wider than before. Products whose non-matching Variants carry lower Prices can then appear earlier than expected. This is a known constraint, not a paging problem. ### Migrate pagination The `limit` and `offset` fields keep their names and meaning, and you can still retrieve the first **10 000** results. Three changes apply: - The maximum `limit` drops from **500** to **100**. If you request pages of more than 100 results, reduce the page size or fetch several pages. - The maximum usable `offset` depends on the limit, because the 10 000-result ceiling still applies. With a `limit` of 100 the maximum `offset` is 9 900; with the default `limit` of 20 it is 9 980. - Exceeding the ceiling returns an [InvalidInput](/search.md?urn=ctp:api:type:InvalidInputError) error instead of a `SearchExecutionFailure` error. Update any error handling that matches on the old error code. Setting `limit` to `0` to retrieve facets only works in both APIs. ### Retrieve full Product data Product Search returns Product IDs. Choose one of the following approaches to retrieve the data you render. This is also where price selection, locale projection, and Store projection now happen. #### With GraphQL Use the `product` field inside the `productsSearch` query to fetch Product data and apply price selection in the same request. For details, see [use Product Search with GraphQL](/api/graphql.md#use-product-search). ```graphql title="Fetch Product data for matching Products" { productsSearch( query: { fullText: { field: "name", value: "skirt", language: "en" } } limit: 20 ) { total offset limit results { product { masterData { current { name(locale: "en") slug(locale: "en") masterVariant { sku images { url } price(currency: "EUR", country: "DE") { value { centAmount } } attributesRaw(includeNames: "designer") { name value } } } } } } } } ``` This is the closest equivalent to the single-call model of Product Projection Search and usually the smallest change to a storefront. #### With the Product Projections API Pass the returned IDs to the [Query ProductProjections](/search.md?urn=ctp:api:endpoint:/{projectKey}/product-projections:GET) endpoint in a single request. ```text title="Query ProductProjections for the matching IDs" GET /{projectKey}/product-projections ?where=id in ("{{id-1}}","{{id-2}}","{{id-3}}") &staged=false &priceCurrency=EUR&priceCountry=DE&priceCustomerGroup={{customer-group-id}} &localeProjection=en &storeProjection={{store-key}} ``` Every price selection, locale, and Store parameter that you previously passed to Product Projection Search is available on this endpoint. This is where the projection behavior of your current implementation is preserved. Use `filter[attributes]` to reduce the response to the Attributes you render. The returned Product Projections are **not** guaranteed to be in the same order as the Product IDs from the search response. Either apply the same sort criteria on this request, or reorder the results client-side against the ID list. Reordering client-side is the more reliable of the two. You can also fetch each Product Projection individually with [Get ProductProjection by ID](/search.md?urn=ctp:api:endpoint:/{projectKey}/product-projections/{id}:GET). This issues one request per result and is not recommended for Product Listing Pages. To improve performance, add a caching layer to persist frequently requested product data between sessions. #### Read matching variants The `markMatchingVariants` field exists in both APIs, but the response differs. Product Projection Search added an `isMatchingVariant` boolean to each returned Product Variant. Product Search returns a separate [ProductSearchMatchingVariants](/search.md?urn=ctp:api:type:ProductSearchMatchingVariants) object per result. ```json title="Result with matching variants" { "id": "{{product-id}}", "matchingVariants": { "allMatched": false, "matchedVariants": [ { "id": 1, "sku": "CSKW-093" }, { "id": 5, "sku": "CSKP-0932" } ] } } ``` When `allMatched` is `true`, all Variants match and `matchedVariants` is empty. Handle that case explicitly; it is a common cause of empty variant lists after migration. ### New capabilities Product Search offers capabilities that Product Projection Search does not. Consider whether they let you simplify your implementation, or remove workarounds you built for their absence. - **Prefix matching**: match the beginning of a field value with [prefix](/api/search-query-language.md#prefix) expressions. - **Wildcard matching**: match patterns within field values with [wildcard](/api/search-query-language.md#wildcard) expressions. - **Field-level boosting**: control relevance scoring per field with [boosting](/api/search-query-language.md#boost-query-results). - **Product-level Attributes**: search by [Attributes](/api/projects/products.md#attribute) defined at the Product level with the `attributes.{name}` field, not only at the Variant level. You can remove `SameForAll` workarounds built for Product Projection Search. - **Standalone Prices**: filter, sort, and facet on [Standalone Prices](/api/projects/standalone-prices.md). - **Stores and Product Selections**: filter on the `stores` and `productSelections` fields. - **Stats facets**: calculate minimum, maximum, mean, sum, and count for number and date fields with [stats facets](/api/projects/product-search.md#stats-facets). ### Indexing limits and validation Product Search enforces the following [indexing limits](/api/projects/product-search.md#attributes) per Product Variant: - Up to **50** Variant Attributes. - Up to **50** Product Attributes. The limits apply separately, so a Product Variant can have both. Attributes are counted in the order they are defined on the [ProductType](/search.md?urn=ctp:api:type:ProductType). The indexer skips any Attribute that is not present on the Product Variant or not marked as searchable. Once the limit is reached, the remaining Attributes are not indexed. If a Product Variant carries more searchable Attributes than the limit, you have two options. Reorder the AttributeDefinitions on the Product Type. Alternatively, set `isSearchable` to `false` on Attributes you never query. Either way, make sure the Attributes you do query fall within the limit. Product Search also applies the field-level validation described in [Respect field-level validation](/tutorials/migration-guides/product-search-migration-guide.md#respect-field-level-validation). Compound expressions that combine fields from different levels incorrectly are rejected with an `InvalidInput` error. The following soft limits also apply to the search index: - **15 000** Stores per Product. - **15 000** Product Selections per Product. - **10 000** Standalone Prices per Product. Exceeding a soft limit produces non-deterministic results for expressions on the affected fields. ### Verify and roll out Migrate one entry point at a time. Product Listing Pages, search result pages, Category pages, and internal tools have different requirements. Running both APIs in parallel lets you compare them safely. 1. **Run both APIs in shadow mode.** For a sample of live traffic, issue the old and the new request and log both result sets. Do not change what customers see yet. 2. **Compare result sets, not just counts.** Check `total`, the ID sets, their order, and the facet counts. Account for the [indexing delay](/api/general-concepts.md#eventual-consistency). Both indexes are eventually consistent and update independently. Small differences in the minutes after a catalog change are expected, so compare against a stable catalog. 3. **Expect deliberate differences.** Price filters now consider all Prices. Facet counts changed from Variants to Products. Relevance ranking depends on your new full-text composition. Confirm that each difference is one you intended. 4. **Tune relevance.** Adjust `boost` values, `mustMatch`, and the set of searched fields against real queries from your search logs before you switch traffic. 5. **Roll out behind a feature toggle.** Route a small share of traffic to Product Search, monitor response times and conversion, then increase the share. For the broader pattern, see [Migrate checkout](/tutorials/strangler-pattern.md). 6. **Keep the fallback alive.** Keep the Product Projection Search path working until Product Search carries all traffic. Keep calling it as well, so its index is not deactivated automatically. ### Decommission Product Projection Search Once all traffic uses Product Search and you have validated the results: 1. Remove the Product Projection Search calls from your integration, including any fallback path. 2. Remove client code that reads `isMatchingVariant`, keyed facet objects, or the `dataType`, `total`, and `other` facet result fields. 3. Review `isSearchable` on your AttributeDefinitions. Attributes that remained searchable only for Product Projection Search can be set to `false`, which also frees capacity within the Attribute indexing limits. 4. Deactivate Product Projection Search, either in the Merchant Center or with the [Change Product Search Indexing Enabled](/api/projects/project.md#change-product-search-indexing-enabled) update action. ```bash title="Deactivate Product Projection Search" curl -sH "Authorization: Bearer {access_token}" -X POST -H "Content-Type: application/json" -d '{ "version": {version_number}, "actions": [ { "action": "changeProductSearchIndexingEnabled", "enabled": false, "mode": "ProductProjectionsSearch" } ] }' https://api.{region}.commercetools.com/{projectKey} ``` The [Search Term Suggestions](/api/projects/search-term-suggestions.md) API uses the same index as Product Projection Search. If you use it for auto-complete, keep `ProductProjectionsSearch` active, or replace the auto-complete with a `prefix` expression on `name` in Product Search. ### Troubleshooting | Symptom | Likely cause | | --- | --- | | `ObjectNotFound`: Product Search API is not enabled | Product Search is not activated, or the initial indexing has not finished. | | `400 InvalidInput` with "Expressions nesting level are incompatible" | A compound expression combines field levels incorrectly. See [Respect field-level validation](/tutorials/migration-guides/product-search-migration-guide.md#respect-field-level-validation). | | `400 MalformedQuery` | An `or` expression with a single sub-expression, or a `fieldType` that does not match the Attribute's type. | | Empty results for an Attribute query | `isSearchable` is `false`, the Attribute is beyond the indexing limit, or the `fieldType` is wrong. | | Empty results for a localized query | The Locale is not configured in the Project's `languages`. | | Empty results from `not` combined with `exists` | Not supported on `variants`, `variants.prices`, and `variants.sku`. | | Facet counts changed after migration | The default counting level changed from Product Variants to Products. Set `level` explicitly. | | More Products match a price filter than before | Product Search indexes all Prices, not only the first Embedded Price. | | `matchedVariants` is empty | `allMatched` is `true`, meaning all Variants match. | | Products appear in an unexpected order across pages | The `sort` array has no tie-breaker, or a sort filter does not match the query. | | `403 Forbidden` | The token lacks `view_published_products`, or an external OAuth token does not list `view_products` explicitly. | ### Related resources - [Storefront search overview](/api/storefront-search-overview.md): feature-by-feature comparison of both APIs. - [Search query language](/api/search-query-language.md): syntax reference for expressions, sorting, and pagination. - [Product Search](/api/projects/product-search.md): API reference, searchable fields, and indexing behavior. - [Performance tips](/api/performance-tips.md): how to keep search response times low. ## Related pages - [Area overview page with navigation](/tutorials.md) - [Previous page: Migrate checkout](/tutorials/strangler-pattern.md) - [Search documentation and API specs](/search.md)