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.
Set it up
Section titled “Set it up”-
Install
@evolve-framework/searchand the package for your engine, in the catalog service and in the indexer. They come from the Evolve registry, like the other framework packages. -
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!,});import { MeilisearchIndexAdapter } from "@evolve-framework/search-meilisearch/indexing";import { MeilisearchQueryAdapter } from "@evolve-framework/search-meilisearch/query";const host = process.env.MEILISEARCH_URL!;export const queryAdapter = new MeilisearchQueryAdapter({host,apiKey: process.env.MEILISEARCH_SEARCH_KEY!,});export const indexAdapter = new MeilisearchIndexAdapter({host,apiKey: process.env.MEILISEARCH_ADMIN_KEY!,// Your own attribute names; only these become attribute facets.facetableAttributes: ["color", "size"],}); -
Answer search from the index. Wrap the query adapter in a
DocumentSearchProviderand pass it assearchtoCatalogModule. Withoutsearch, commercetools answers.import { CatalogModule, DocumentSearchProvider } from "@evolve-framework/commercetools";import { queryAdapter } from "./search.ts";new CatalogModule({clientFactory,search: new DocumentSearchProvider({ adapter: queryAdapter }),}); -
Run an indexer as its own service. Give
SearchIndexerModulethe 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/:sourceIdandPOST /reindex, both behindauthorization: Bearer <secret>. Withoutsecretthe constructor throws, so an unset variable cannot leave/reindexopen. For a queue-only indexer, passroutes: falseinstead. -
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(theskusargument),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
settingspermission. A full reindex reads the live index’s settings first, and fails with a403without it.
The adapter configures each index itself the first time a process writes to it: filterable, sortable and searchable attributes, and facet values sorted by count. Add your own with
filterableAttributes,facetableAttributesandsortableAttributes. -
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.
Rebuild the index
Section titled “Rebuild the index”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
202at once and logs the result when done, or409while 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.
The adapter builds a temporary index per locale and swaps all of them in one
request, so every language goes live at once. On a first run it creates the
live index before the swap. The swap waits up to two minutes; raise
swapTimeoutMs for a large catalog.
Until the first rebuild, a search fails with SearchIndexNotFoundError, which
names the missing index.
Keep the index current
Section titled “Keep the index current”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.
Facets and sorting
Section titled “Facets and sorting”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
attributesForFacetingof the index. WithoutrenderingContentyou get every facet, alphabetically, and a numeric one is a range. SetrenderingContentto choose and order them. Declare attributes that only filter asfilterOnly(...), so they do not show up. - Prices are stored in cents. A range facet whose key contains
priceis reported and filtered in whole currency units. - Sorting is per index.
PRICE_ASCENDINGandPRICE_DESCENDINGread the replicas<index>-price-ascand<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, includingPOPULARITYand the name sorts, reads the primary index, ranked by relevance.
- Facets are the attributes in
facetableAttributes. The adapter stores them asattr_<name>, so that is the facet key.brand,categoriesand the price come on top. A facet with one value is left out, unless the shopper selected it. Twotrue/falsevalues make a toggle, a numeric attribute makes a range. - Prices are stored per currency, in cents, as
price_<currency>(net) andprice_<currency>_gross. The price facet is the gross price in the request’s currency, and its range is in cents. - Sorting is a query parameter, so there are no replicas. The price sorts
use the net price in the request’s currency, and the name sorts use
name.POPULARITYsorts on an attribute calledpopularity. Meilisearch refuses a sort on an attribute that is not sortable, so write apopularityfield with your owntoRecordand add it tosortableAttributes.
Stores, locales and index names
Section titled “Stores, locales and index names”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.
The adapter writes each locale of a scope into its own index, named
products-<store>-<locale>, for example products-evolve-nl-nl. Meilisearch
tokenizes and stems per index, so one index per language keeps Dutch search
Dutch. The source’s scopeGranularity does not change these names.
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.
How the B2B reference site wires Algolia
Section titled “How the B2B reference site wires Algolia”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.tsbuilds both halves. The source usesVariantDocumentMapperwith agroupByaxis 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: 40cannot match a bike that is green in 38 only. The index adapter writes withtoRecord: toVariantRecord; the query adapter reads withtoDocument: fromVariantRecordanddistinct: true, one row per product.IndexSyncServicedirectly, withoutSearchIndexerModule, so there are no indexer routes. One instance serves the event consumer and the rebuild.- Events. A commercetools subscription sends
ProductPublished,ProductPriceDiscountsSet,ProductUnpublishedandProductDeletedas CloudEvents to a Service Bus queue.src/functions/azure/catalog-eventsconsumes it. Because these are raw commercetools types,search.tspasses its owneventsrouting. - Full rebuild.
src/catalog/jobs/sync-algoliacallsindexSyncService().reindex(). The service’s own CLI (src/commands.ts) runs it assync algolia, locally withpnpm --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)andattribute_for_distinctset tosourceId, and virtual replicas for the two price sorts.
Check it
Section titled “Check it”-
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.
-
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.
-
Open the storefront’s search page,
/nl/zoeken?q=iPhoneon the B2B site. Check that results, facets and the price sorts work, and that a category page still lists its products. -
Run the e2e specs. In the B2B site,
testing/tests/catalog/search.spec.tssearches from the header and checks that a nonsense term shows no results;product-listing.spec.tscovers category pages. Runpnpm test:e2e catalog/infrontend/site. -
Publish a change to a product in commercetools. Once the event is handled, search shows it without a reindex.

