Skip to content

Add search

Out of the box, commercetools answers product search itself. To answer it from a search engine instead, you add two things: a query adapter that reads the index, and an indexer that keeps the index filled from the catalog.

Package What it gives
@evolve-framework/search The document model, the adapter interfaces, and SearchIndexerModule, which keeps an index in sync
@evolve-framework/search-algolia AlgoliaIndexAdapter and AlgoliaQueryAdapter
@evolve-framework/search-meilisearch MeilisearchIndexAdapter and MeilisearchQueryAdapter
@evolve-framework/commercetools DocumentSearchProvider, which plugs a query adapter into the catalog, and CommercetoolsCatalogSource (in /catalog), which reads products for the indexer

The storefront does not change. It still sends productSearch, and the catalog answers it from the index.

There is no evolve add search command. The steps below are the whole setup.

  1. Install @evolve-framework/search and the package for your engine, in the catalog service and in the indexer. They come from the Evolve registry, like the other framework packages.

  2. Create the two adapters. They deploy separately: the catalog service holds a key that can only search, and only the indexer holds a write key. Both must agree on the index names and the record shape. The defaults do; if you override one side, override the other the same way. The constructors make no calls, so one shared file is fine; the catalog service never sets the write key.

    import { AlgoliaIndexAdapter } from "@evolve-framework/search-algolia/indexing";
    import { AlgoliaQueryAdapter } from "@evolve-framework/search-algolia/query";
    const appId = process.env.ALGOLIA_APP_ID!;
    export const queryAdapter = new AlgoliaQueryAdapter({
    appId,
    searchApiKey: process.env.ALGOLIA_SEARCH_API_KEY!,
    });
    export const indexAdapter = new AlgoliaIndexAdapter({
    appId,
    writeApiKey: process.env.ALGOLIA_WRITE_API_KEY!,
    });
  3. Answer search from the index. Wrap the query adapter in a DocumentSearchProvider and pass it as search to CatalogModule. Without search, commercetools answers.

    import { CatalogModule, DocumentSearchProvider } from "@evolve-framework/commercetools";
    import { queryAdapter } from "./search.ts";
    new CatalogModule({
    clientFactory,
    search: new DocumentSearchProvider({ adapter: queryAdapter }),
    });
  4. Run an indexer as its own service. Give SearchIndexerModule the commercetools catalog source and the index adapter.

    import { ClientFactory, CommercetoolsConfig, getClientOptions } from "@evolve-framework/commercetools";
    import { CommercetoolsCatalogSource } from "@evolve-framework/commercetools/catalog";
    import { createApplication } from "@evolve-framework/core";
    import { SearchIndexerModule } from "@evolve-framework/search/indexing";
    import { indexAdapter } from "./search.ts";
    const config = await CommercetoolsConfig.load();
    const clientFactory = ClientFactory.create(await getClientOptions(config));
    export const indexer = new SearchIndexerModule({
    source: new CommercetoolsCatalogSource({ clientFactory }),
    adapter: indexAdapter,
    secret: process.env.INDEXER_SECRET,
    });
    const app = await createApplication({
    name: "search-indexer",
    module: indexer,
    http: { address: { host: "0.0.0.0", port: 4010 } },
    });
    await app.start();

    The module serves POST /sync/:sourceId and POST /reindex, both behind authorization: Bearer <secret>. Without secret the constructor throws, so an unset variable cannot leave /reindex open. For a queue-only indexer, pass routes: false instead.

  5. Prepare the index.

    The adapter does not configure indices. Create each one with its settings first, in Terraform or the dashboard: Algolia creates an unknown index on the first write, with no facets and no searchable attributes.

    • attributesForFaceting: categoryIds (category pages filter on it), variants.sku (the skus argument), filterOnly(sourceId) (the indexer deletes a product’s records by it), and what shoppers filter on.
    • Sort replicas: see Facets and sorting.
    • The write key also needs the settings permission. A full reindex reads the live index’s settings first, and fails with a 403 without it.
  6. Fill the index with a full reindex, then keep it current from catalog events.

A hit is rendered from the indexed document. A Product field the document does not carry is loaded from commercetools. Algolia’s default record holds the whole document; Meilisearch’s holds little more than the name, slug, image and prices, so more of a hit comes from commercetools.

A full reindex reads every product of every store and writes it in batches. Run one before you serve search, and after you change the record shape or the index names.

curl -X POST https://<indexer-host>/reindex \
-H "authorization: Bearer $INDEXER_SECRET"
  • It answers 202 at once and logs the result when done, or 409 while a reindex runs. Post { "scopes": [...] } to rebuild some stores only.
  • From a job, call await indexer.service.reindex() instead. It returns the document count per scope.
  • POST /sync/<productId> rebuilds one product in every store.

Both engines rebuild atomically, so shoppers see the old catalog until the new one is complete:

The adapter writes into <index>-tmp, then moves it over the live index. It copies the live settings, synonyms and rules to the temporary index first, so a rebuild keeps your configuration. Sort replicas survive the move. With atomicReindex: false the rebuild writes over the live index instead, and deleted products stay in it.

indexer.handler() takes catalog events off a queue. For each event it reads the product again from commercetools, so a stale or late event cannot write stale data. A product that has no documents in a store, because it is unpublished there or has no price, is removed from that store’s index.

By default the handler routes Evolve’s internal product events, from defaultEventRouting:

Events Action
product-created, product-updated, product-published Rebuild the product’s documents
product-deleted, product-unpublished Remove them

commercetools publishes these through createSubscriptionHandler. Send them to a queue, and give the indexer’s handler to your cloud’s queue entry point. For example, on AWS:

import { createSQSHandler } from "@evolve-framework/cloud-adapter-aws";
import { indexer } from "./indexer.ts";
export const handler = createSQSHandler(indexer.handler());

Each cloud’s entry point is in React to events. SearchIndexerModule is not part of a CompositeModule’s event dispatch; you always pass handler() yourself.

To consume commercetools subscription messages directly, without the internal events, pass events with their types. The handler needs { type, id }, so take the id from the message. The B2B reference site does this; see below.

Do not catch errors around the handler. The adapters throw on failure, so the queue delivers the message again instead of losing the update.

The query asks the engine for every facet in the result, so the attributes you make facetable decide what a listing offers. Both adapters label a facet with its attribute key. Translate the labels in the storefront.

  • Facets are the attributesForFaceting of the index. Without renderingContent you get every facet, alphabetically, and a numeric one is a range. Set renderingContent to choose and order them. Declare attributes that only filter as filterOnly(...), so they do not show up.
  • Prices are stored in cents. A range facet whose key contains price is reported and filtered in whole currency units.
  • Sorting is per index. PRICE_ASCENDING and PRICE_DESCENDING read the replicas <index>-price-asc and <index>-price-desc. The package does not create them: add them to the index configuration, with a custom ranking on the price. Every other sort, including POPULARITY and the name sorts, reads the primary index, ranked by relevance.

The catalog source gives the indexer one scope per store, with the store’s languages (or the project’s, when the store has none) and the project’s currencies. Each search reads the store, locale and currency of the request.

Indices are named <type>-<store>[-<language>][-<sort>], for example products-evolve-nl and products-evolve-nl-price-asc. The source’s scopeGranularity decides how many indices a store gets:

scopeGranularity Indices Also set
"language" (default) One per store and language: nl-NL and nl-BE share products-evolve-nl Nothing
"locale" One per store and locale: products-evolve-nl-be resolveIndexName: localeIndexName on both adapters
"store" One per store, every language in it: products-evolve scopeGranularity: "store" on the query adapter

Per language is the default because Algolia’s language processing (indexLanguages, compound splitting) is set per index.

To name indices your own way, pass the same resolveIndexName to both adapters. Otherwise the indexer fills an index the query side never reads.

To change what is indexed, give CommercetoolsCatalogSource an enrich function to adjust every document, or your own mappers per product type. A field you put on document.extra passes through the index untouched.

The B2B site serves search from Algolia when DISCOVER_SEARCH_ENGINE is algolia, and from commercetools when it is commercetools, the default. All of it is in backend/discover:

  • src/catalog/search.ts builds both halves. The source uses VariantDocumentMapper with a groupBy axis per product type, so a record is one variant group, such as one color of a bike. A facet filter then applies to that group: color: green AND size: 40 cannot match a bike that is green in 38 only. The index adapter writes with toRecord: toVariantRecord; the query adapter reads with toDocument: fromVariantRecord and distinct: true, one row per product.
  • IndexSyncService directly, without SearchIndexerModule, so there are no indexer routes. One instance serves the event consumer and the rebuild.
  • Events. A commercetools subscription sends ProductPublished, ProductPriceDiscountsSet, ProductUnpublished and ProductDeleted as CloudEvents to a Service Bus queue. src/functions/azure/catalog-events consumes it. Because these are raw commercetools types, search.ts passes its own events routing.
  • Full rebuild. src/catalog/jobs/sync-algolia calls indexSyncService().reindex(). The service’s own CLI (src/commands.ts) runs it as sync algolia, locally with pnpm --filter @evolve-platform/discover sync algolia, and deployed as a container job.
  • Index settings are Terraform, in configuration/algolia: one index per store and language, filterOnly(sourceId) and attribute_for_distinct set to sourceId, and virtual replicas for the two price sorts.
  1. Run a reindex and open the index in your engine’s dashboard. You should see one index per store and language (Algolia) or per store and locale (Meilisearch), with your products in it.

  2. Query the catalog service directly, with the store headers the gateway would send:

    curl http://localhost:4002/graphql \
    -H "content-type: application/json" \
    -H "X-StoreContext-StoreKey: evolve" \
    -H "X-StoreContext-Locale: nl-NL" \
    -H "X-StoreContext-Currency: EUR" \
    -d '{"query":"{ productSearch(searchTerm: \"iPhone\", sort: PRICE_ASCENDING, page: 1, pageSize: 5) { pagination { totalCount } items { id name } facets { __typename } } }"}'

    With Meilisearch, a missing index fails here with an error that names it.

  3. Open the storefront’s search page, /nl/zoeken?q=iPhone on the B2B site. Check that results, facets and the price sorts work, and that a category page still lists its products.

  4. Run the e2e specs. In the B2B site, testing/tests/catalog/search.spec.ts searches from the header and checks that a nonsense term shows no results; product-listing.spec.ts covers category pages. Run pnpm test:e2e catalog/ in frontend/site.

  5. Publish a change to a product in commercetools. Once the event is handled, search shows it without a reindex.