Index your published catalog into an external search engine and keep that index correct as the catalog changes.
Data flows in one direction. commercetools is the source of truth for the catalog, and the search engine holds a denormalized copy optimized for querying. commercetools never reads back from the engine.
Before you start, you need the following:
- a commercetools Project with published Products, and an API Client that can read them
- an account and API credentials for the search engine you intend to use
- somewhere to run the integration, such as Connect, a cloud function, or your own infrastructure
The following topics are out of scope: relevance tuning inside the engine, storefront rendering, and inventory as a live stock ledger. Each is covered as a boundary rather than as an implementation step.
Decide what you need
When an external search engine is justified
commercetools ships its own search. Rule it out before you commit to an indexing pipeline, because an external engine is a standing system to own, secure, monitor, and pay for.
| Built-in capability | Covers |
|---|---|
| Product Search | Full-text, fuzzy, prefix, and wildcard matching, faceting, sorting, and scoping by price, Store, and Product Selection. The API reached general availability in June 2024, and Facets followed in October 2025. |
| Scoping | Price selection resolves currency, country, Customer Group, and Channel, so a buyer's prices and catalog resolve in the same query. |
- visual or AI-driven merchandising and manual curation
- synonym, redirect, or query-rule sets that merchandisers maintain themselves
- recommendations, such as "customers also bought"
- search analytics dashboards
- A/B testing of ranking
- learned or personalized ranking
- a discovery engine your storefront already uses
The reverse case matters too. Suppose your organization already licenses an engine and the merchandising team works in it daily. Staying with the built-in search is then rarely a real option, even when the query requirements look simple. The requirement is merchandising in the engine, which puts you past this gate.
Some requirements sit between the two. The built-in search combined with an API Extension or a thin storefront layer covers more ground than people expect. Test the specific gap before you conclude that you need an engine.
Requirements that shape the design
The architecture follows from a handful of answers. Settle them before you write code or configure a Connector, because each one changes the shape of the search document or the applications around it.
| Question | Design impact |
|---|---|
| Which engine, and what can built-in search not do? | Names the destination and justifies the pipeline. Record the specific capability gap rather than a general need for search. |
| Whole-catalog or Store-specific? | Decides whether you read /product-projections or the in-store projections, and whether you run one index or one per Store. |
| Which locales? | Decides index-per-locale against per-locale fields, and limits what localeProjection returns. |
| Which price contexts? | A flat record cannot hold every currency, country, Customer Group, and Channel combination. You must choose. |
| Product-level or variant-level records? | Decides whether a search hit is a Product or a single Variant, which shapes the whole document. |
| Does availability belong in the index? | Usually a deliberate no, or a coarse flag. Never a live stock ledger. |
| What is the catalog volume and change cadence? | Volume drives batching and pagination. Cadence decides event-driven against scheduled updates. |
| Do you use Product Tailoring? | Store-specific names, descriptions, images, and assets must reach the index that serves that Store. |
| Anything else? | B2B assortments, multi-currency budgets, deep category trees, prices that are tiered or discounted, aggregated reviews, and personal data in Product Attributes each become their own requirement. |
Write the answers down and confirm them with whoever owns the storefront. Most later rework traces back to a price context or a record granularity that was assumed rather than decided.
Choose how to build it
Use, configure, fork, or build
Stop at the first of the following options that fits:
- The built-in search is enough. Build nothing. See When an external search engine is justified.
- A public Connector for your engine covers everything. Install and configure it. For installation, see the Connect getting started guide.
- The right engine, but a gap that looks like a missing capability. Prove it isn't configuration first. Index name, indexed fields, locale, price context, and Store selection are usually settings rather than code. If configuration closes the gap, you are back at the previous option.
- A genuine gap that configuration cannot close, and source is available. Fork the existing Connector, add only the difference, and deploy it as an Organization Connector.
- No usable Connector for your engine. Build one, scaffolding from the Product export template.
The last two options are materially more work than the others. Decide explicitly rather than defaulting to a build.
connect.yaml and nothing for Connect to deploy. They can be a good functional fit, but you cannot build, fork, or operate them through Connect, and you do not own the mapping. Confirm which kind a listing is before you plan around it.Available Connectors and templates
| Artifact | What it is | Where it fits |
|---|---|---|
| Product export template | The official Connect scaffold for outbound catalog export, positioned for external services such as search. Provides a full-export service application and an incremental-updater event application. | The starting point for a build |
| Launchpad Algolia sync | An open-source worked Algolia example built on the same two-application shape. Designed for use with the B2C sample data. | A reference to read, or a fork candidate for Algolia |
| Engine dashboard integrations | Vendor-hosted and configured in the engine. | Outside Connect |
The method in this guide is the same for any engine. Algolia, Constructor, Bloomreach, Coveo, Elasticsearch, Typesense, Meilisearch, and an in-house engine all share the same requirements and the same gate. They also share the same mapping method and the same two-application architecture. Only the client library and the index vocabulary change.
What the engine owns
Searchable-field weighting, ranking, tie-breaking, synonyms, redirects, query rules, merchandising, and A/B tests live in the search engine and are configured by merchandisers. They belong neither in commercetools nor in the mapping.
The integration has one job: keep each record equal to the current published Product. The engine decides relevance.
Design the search document
This is where a search integration succeeds or degrades over time. Moving the data is mechanical. The mapping decides whether the index stays correct, queryable, and affordable.
The tension is structural. The commercetools model is normalized and reference-based, so Products reference Categories, Prices, and Channels by identifier. A search engine queries most efficiently against a flat, self-contained record built for one query. The mapping is a transform and a filter, not a copy. Index only what the storefront searches, filters, sorts, or displays.
Read the published projection
staged=false, which returns the current projection. Do not read the Product resource itself.current projection, and a storefront index must never contain staged edits or unpublished Products. For the difference between the two representations, see Current and staged.This single choice prevents the most common leak in search integrations: unreleased names, prices, and draft Products appearing in customer-facing results.
Key every record for idempotent writes
Give each record a deterministic identifier derived from a stable commercetools identifier:
- the Product
idfor product-level records - the Product
idcombined with the Variantid, or the SKU, for variant-level records
objectID, and other engines call it the primary key or the document identifier.If you cannot reconstruct a record's key from a later message, you cannot update or delete that record. Settle the key before you write any synchronization code.
Choose product-level or variant-level records
This is a storefront design decision, and it shapes the entire document. Choose one of two granularities:
- Product-level, one record per Product: variant-specific values such as size and color become sets aggregated across Variants. A hit links to the product detail page. Fewer records, and the simpler default for most catalogs.
- Variant-level, one record per Variant: each color or size is its own hit with its own image and price. Needed when the results grid shows "red shirt" and "blue shirt" as separate tiles. Many more records, so check the engine's record-count pricing.
Match the choice to how the storefront displays results, and keep it consistent. Do not mix granularities in one index.
Resolve the price context
- Index one context, for example euros in Germany. The simplest option, and correct only for a single-market storefront. Select the context with the price-selection query parameters at map time.
- Index one field per context, for example
price_EUR_DEandprice_USD_US. One record with several price fields, and the storefront reads the field matching the shopper. This scales to a handful of contexts. - Emit one record per context, with a context attribute you filter on. Use this when contexts are numerous, or when B2B Customer Group pricing must be searchable. It multiplies the record count.
State the choice explicitly. A mismatch here produces the classic wrong-price-in-search defect. The record exists and looks healthy, but it shows a price no shopper is entitled to.
Handle locales
en-US and a de-DE value. A search engine applies stemming and synonyms per language, so each searchable field must hold one language. Two shapes work:- One index per locale, for example
products_enandproducts_de. The cleanest option for language-specific relevance configuration, and the usual choice for multi-market and Store-specific setups. - One index with per-locale fields, for example
name_enandname_de. Fewer indices, and the storefront queries the fields for the shopper's language. Fine for a few locales.
localeProjection query parameter to reduce translations at the source, so you carry only the locales in scope. Map locale codes explicitly, because commercetools uses en-US rather than en_US.Denormalize categories
id or key, and carry the localized name as a display field. A rename then changes what shoppers read without invalidating the facet values the storefront filters on.Scope by Store, Product Selection, and Product Tailoring
- One index with a Store filter field. Each record carries the Stores or Product Selections it belongs to, and the storefront filters by the current Store. Simple, and a good fit when tailored content per Store is minimal.
- One index per Store. Driven by each Store's Product Selection, reading
/in-store/key={storeKey}/product-projections. The in-store projection also resolves the Store's languages and distribution channels, so tailored names, descriptions, images, and assets arrive already applied. This is the shape the Product export template implements, with one Deployment per Store.
One Deployment per Store does not scale to a large Store fleet. For many Stores, build one application that resolves the Store from the incoming message and writes to the matching index. Deploying a copy of the Connector for each Store does not hold up at that size.
Retrieve Product Tailoring efficiently
If you need tailored data separately from the in-store projection, query the tailorings rather than probing for them.
/product-tailoring with a list of Product IDs looks natural, but it only performs well when most of the Products in that list are actually tailored. In a large catalog where a small share of Products carry tailoring, most of those lookups return nothing.Instead, paginate through the published tailorings themselves. Every request then returns data, regardless of catalog size:
GET /{projectKey}/product-tailoring?where=store(key="your-store-key") and published = true&sort=id asc&limit=30
Use the identifier of the last item from the previous response to fetch the next page:
GET /{projectKey}/product-tailoring?where=store(key="your-store-key") and published = true and id > "{lastId}"&sort=id asc&limit=30
product field rather than a flat identifier, so filter a single Product with product(id = "{productId}"). To process a Product's tailorings as a group, page the full set with the identifier cursor above and group them in your own code.store filter is optional. Omit it to page through every published tailoring in the Project.Decide where availability belongs
availability field on a ProductVariant is eventually consistent and can lag real stock levels by a few seconds. For the guarantees, see Inventory checks and consistency.Keep the search index out of the role of live stock ledger, and keep per-unit inventory events out of it. That write volume overwhelms the engine and costs money without improving the shopper's experience.
Keep the mapping a pure function
Write the projection-to-document transform as a pure function with no network calls. Everything the engine needs to rank and display should already be in the record. The applications around the mapping should do nothing but move data.
Worked example
An apparel catalog with product-level records, two locales, one price context, and one global index.
| Element | Decision |
|---|---|
| Record key | The Product id |
| Source | /product-projections?staged=false for the full load, and the productProjection field of the ProductPublished Message for updates |
| Text fields | name_en, name_de, description_en, and description_de, with localeProjection limited to en-US and de-DE |
| Facet fields | brand, color and sizes as sets across Variants, and categoryIds keyed on the stable identifier |
| Display fields | categories as denormalized breadcrumb names per locale, imageUrl, slug_en, and slug_de |
| Price | One selected context, euros in Germany, indexed as a numeric field for sorting and faceting |
| Availability | A coarse in-stock boolean refreshed nightly |
| Left out | Staged data, out-of-scope locales, per-unit inventory, internal-only Attributes, and every non-euro price |
That record schema is the deliverable. It is the same artifact whether a public Connector consumes it as configuration or a Connector you build implements it in code.
Build the two applications
A search integration is two jobs, so build two applications. Keep them separate rather than putting a mode switch in one application.
| Job | Default shape | Alternative |
|---|---|---|
| Full ingestion, which rebuilds the whole index | A service application with an on-demand trigger | A scheduled job, when periodic rebuilds are enough |
| Incremental updates, which keep the index fresh | An event application driven by Subscriptions | A job that polls Product Projections on lastModifiedAt |
One rule spans both. The index is a derived copy of the published catalog. Every record must be reproducible from the current Product Projection. Every write is an upsert on the record key, and every removal targets that same key. Both full loads and Subscription deliveries are at-least-once. Running either application twice must therefore converge rather than duplicate or double-delete.
Full ingestion
Full ingestion rebuilds the entire index from commercetools. It is your initial load, your disaster-recovery path, and the periodic backstop that repairs whatever the incremental path missed.
Page with a cursor, not an offset
/product-projections?staged=false&withTotal=false sorted by id, and page by filtering on the last identifier you saw:GET /{projectKey}/product-projections?staged=false&withTotal=false&sort=id asc&limit=100
GET /{projectKey}/product-projections?staged=false&withTotal=false&sort=id asc&limit=100&where=id > "{lastId}"
limit. The maximum limit is 500.10 000, so a growing catalog silently outgrows it, and a Product inserted mid-run shifts every later page. The identifier cursor is stable and resumable. For the full pattern, see Iterate over all elements.Read the Store assortment for a Store-specific index
For one index per Store, the assortment comes from the Store's Product Selections rather than from the whole catalog:
- Iterate over all Product Selection Assignments in the Store, sorting and filtering on
product.id. When more than one active Product Selection includes the same Product, the response contains that Product more than once. Deduplicate the identifiers before you continue. - Fetch the Product Projection in the Store for each Product. A ResourceNotFoundError means the Product is not available in that Store, so exclude it from the index.
Rebuild atomically
Build the new content into a temporary or secondary index, or tag every record with a build identifier. Then swap it in and drop the stale set. Many engines provide this directly, such as a replace-all-records operation or an index alias you repoint.
Clearing the live index and refilling it leaves a half-empty index serving few or no results for the entire length of the rebuild. That is the most visible failure this integration can produce, and it happens during a routine maintenance run rather than during an incident.
Write in batches and check the count
Send documents to the engine in batches. One HTTP call per record exhausts your runtime budget and hits the engine's rate limits on any real catalog.
After the swap, confirm that the engine's record count matches the number of published Products, multiplied by your granularity factor for variant-level records. A large mismatch means the mapping dropped or duplicated records. Fail the run and raise an alert rather than leaving a truncated index live.
Respect the runtime
service application request times out after 5 minutes, and a job application request after 30 minutes. For a large catalog, chunk the load and checkpoint your progress. Run the rebuild as a job when a single pass cannot finish inside the service budget. Add overlap locking so a scheduled run cannot start on top of a run that is still going. Keep the initial migration and the recurring rebuild on the same code path. The path you depend on in an emergency is then the one you exercise routinely.Incremental updates
Incremental updates keep the index in step with catalog changes between full loads. Choose the trigger first.
Choose Messages or resource changes
Two Subscription styles apply, and they suit different scopes.
| Approach | What you receive | Use it when |
|---|---|---|
A MessageSubscription on ProductPublished and ProductUnpublished | Only published state. The ProductPublished payload carries the full productProjection, so you can map it without a re-fetch. | You index the whole catalog and only customer-visible state matters. |
A ChangeSubscription on product, combined with Message Subscriptions on store and product-selection | A notification for any change to the resource, carrying the resource reference. | The index is Store-scoped, so you must react to changes that alter Store membership as well as Product content. |
A Change Subscription also fires for staged edits that no shopper can see. If you use one, filter those out or accept the extra reindex work.
notificationType is ResourceCreated, ResourceUpdated, or ResourceDeleted, so your handler branches on that value and then reads the projection by resource.id.Handle each trigger
Define an action for each trigger you subscribe to:
- ProductPublished: upsert the record. The payload carries the
productProjectionas it was just published, so map it directly. - ProductUnpublished: remove the record by its key. This payload carries only a resource reference, with no projection.
- ProductDeleted: remove the record. Its projection field is named
currentProjectionand is optional, so key the removal onresource.idrather than on the payload. A Product must be unpublished before it can be deleted, so in practice the unpublish already removed the record and this is a safety net. - Product Selection and Store Messages, for Store-scoped indices:
- ProductSelectionProductAdded adds the Product to that Store's index.
- ProductSelectionProductRemoved removes it from that index.
- ProductSelectionVariantSelectionChanged triggers a reindex of that Product.
- StoreCreated and StoreDeleted provision and retire a Store's index.
For a Store-scoped index, resolve which Stores an incoming change affects by querying Stores that hold the relevant active Product Selections:
productSelections(active=true and productSelection(id in :productSelectionIds))
Subscribe once per message type
50 Subscriptions. This is a soft limit that can be raised after a performance impact review, but a Store fleet reaches it. Fanning out in the handler avoids the request entirely.Register Subscriptions idempotently by reading first and creating only what is missing. A registration routine that deletes and recreates the Subscription drops every notification generated during the gap, and those changes never reach the index.
Creating, updating, or deleting a Subscription can take up to one minute to take effect. commercetools also sends a test notification when a Subscription is created. If that test notification cannot be delivered, the Subscription is not created at all.
Decode the envelope and acknowledge correctly
message.data, so decode it before you inspect the message type.Acknowledge positively for messages you handled and for messages you deliberately ignored, including the platform's test notification. How you signal that depends on where the application runs:
- A Connect event application receives each message as an HTTP request. Connect treats
102,200,201,202, and204as positive acknowledgments, and retries every other status. - Your own subscriber, such as the Pub/Sub pull consumer in the following example, acknowledges through the client library instead. In
@google-cloud/pubsubthat ismessage.ack()for success andmessage.nack()for redelivery.
Either way, a handler that signals failure for an unrecognized message type receives that message forever. Reserve failure signals for transient errors you actually want redelivered.
Keep the acknowledgment fast. A Connect event application has an acknowledgment timeout of 10 seconds, and its application request times out after 5 minutes. Re-fetching a projection and writing to the engine inside the handler can exceed the acknowledgment window. Keep the synchronous path short, and measure it against that budget.
import { PubSub } from "@google-cloud/pubsub";
import type {
ProductProjection,
ProductPublishedMessage,
ProductUnpublishedMessage,
} from "@commercetools/platform-sdk";
//...
async updateSearchIndex(product: ProductProjection) {
console.log(`Updated/added product in search engine: ${product.id}`);
// TODO: Format and send to search index
}
async removeFromSearchIndex(productId: string) {
console.log(`Removed product from search engine: ${productId}`);
// TODO: Remove by objectID from search index
}
async fetchMessages(): Promise<void> {
const subscriptionName = "ct-product-consumer"; //the Subscription ID value provided in step 4
// Initialize the Pub/Sub client with explicit credentials
const pubSubClient = new PubSub({
keyFilename: "./ctapisubscription.json", // Path to the location of the service account JSON key
projectId: "ct-search-sync", // the GCP project ID set in step 1
});
const subscription = pubSubClient.subscription(subscriptionName);
// Start listening to incoming messages from the subscription
subscription.on("message", async (message) => {
try {
const ctMessage = JSON.parse(message.data.toString()) as
| ProductPublishedMessage
| ProductUnpublishedMessage;
switch (ctMessage.type) {
case "ProductPublished": {
// The productProjection is embedded in the message — no extra API call needed
await this.updateSearchIndex(ctMessage.productProjection);
break;
}
case "ProductUnpublished": {
// Remove the product from the search index
await this.removeFromSearchIndex(ctMessage.resource.id);
break;
}
default:
console.log(` Unhandled message type: ${ctMessage.type as string}`);
}
// Acknowledge successful handling of the message
message.ack();
} catch (err) {
console.error(" Error handling message:", err);
// Optionally requeue the message for later processing
message.nack();
}
});
// Error handler
subscription.on("error", (err) => {
console.error("Subscription error:", err);
});
}
Guard against stale writes
Subscriptions are delivered at least once, and there is no guarantee on delivery order. An older notification can arrive after a newer one and overwrite current data with stale data.
ProductPublished payload is the state at publication, so map it directly. For every other trigger, re-fetch the projection by resource.id so the index converges on current state instead of replaying an old delta. Where the engine supports it, also store an ordering value on the record and skip the write when the incoming data is older. For Message notifications, use resource.id with sequenceNumber, which increases in order per resource. For resource-change notifications, use resource.id with version, which is not sequential and can therefore only be compared, not gap-checked.Poll instead, if you cannot receive a push
lastModifiedAt and advance a stored checkpoint after each successful run:GET /{projectKey}/product-projections?staged=false&where=lastModifiedAt > "2026-01-01T00:00:00.000Z"&sort=lastModifiedAt asc&sort=id asc&withTotal=false&limit=100
lastModifiedAt and id so Products sharing a timestamp are not skipped. A Product modified during the run is processed again, so your handler must tolerate repeats.import type { ProductProjectionPagedQueryResponse } from '@commercetools/platform-sdk';
import { apiRoot } from './ctpClient.js';
// The number of results to fetch per API call (page size)
const PAGE_SIZE = 20;
/**
* Fetches a single page of product projection changes.
* @param lastTimestamp - The lastModifiedAt from the previous page.
* @param lastId - The ID from the last item of the previous page.
*/
const fetchNextPageOfChanges = async (
lastTimestamp: string,
lastId: string | null
): Promise<ProductProjectionPagedQueryResponse> => {
let whereClause: string;
// Use an if/else block to construct the WHERE clause.
if (lastId) {
// This is for page 2 and onwards. We have a previous product's ID.
// We query for anything modified AFTER the last timestamp,
// OR anything modified at the EXACT same timestamp but with a greater ID.
whereClause = `(lastModifiedAt = "${lastTimestamp}" and id > "${lastId}") or (lastModifiedAt > "${lastTimestamp}")`;
} else {
// This is for the very first page of the sync, where lastId is null.
// The query is simpler.
whereClause = `lastModifiedAt > "${lastTimestamp}"`;
}
const response = await apiRoot
.productProjections()
.get({
queryArgs: {
where: whereClause,
staged: false, // Only query for current (published) projections
sort: ['lastModifiedAt asc', 'id asc'], // Sort by timestamp, then ID
limit: PAGE_SIZE,
withTotal: false,
},
})
.execute();
return response.body;
};
/**
* Main function to run the delta sync process.
*/
const runDeltaSync = async (): Promise<void> => {
try {
// 1. Define the starting point for the sync
const oneHourAgo = new Date(
new Date().getTime() - 60 * 60 * 1000
).toISOString();
console.log(`--- Starting sync for changes since ${oneHourAgo} ---`);
// 2. Initialize state variables for pagination
let lastSyncTimestamp = oneHourAgo;
let lastId: string | null = null;
let hasMoreResults = true;
let totalProductsProcessed = 0;
let page = 1;
// 3. Loop to fetch all pages of results
while (hasMoreResults) {
console.log(`\nFetching page ${page}...`);
const pageData = await fetchNextPageOfChanges(lastSyncTimestamp, lastId);
const results = pageData.results;
if (results.length > 0) {
console.log(`Found ${results.length} products on this page.`);
totalProductsProcessed += results.length;
// Process each product
results.forEach((product) => {
console.log(
` -> Processing ID: ${product.id}, Modified: ${product.lastModifiedAt}`
);
});
// 4. Update the "cursor" with the details of the LAST item in the page
const lastProduct = results[results.length - 1];
lastSyncTimestamp = lastProduct.lastModifiedAt;
lastId = lastProduct.id;
// If we received fewer results than we asked for, we're on the last page.
if (results.length < PAGE_SIZE) {
hasMoreResults = false;
}
} else {
// No more results found, exit the loop.
hasMoreResults = false;
}
page++;
}
console.log('\n--- Sync Complete ---');
console.log(`Total products processed: ${totalProductsProcessed}`);
console.log(
`For the next run, start with timestamp: "${lastSyncTimestamp}" and ID: "${lastId}"`
);
} catch (error) {
console.error('An error occurred during the sync process:', error);
}
};
// Run the sync
runDeltaSync();
ProductUnpublished and ProductDeleted for removals.The two approaches trade off as follows.
| Consideration | Scheduled polling | Event-driven Subscriptions |
|---|---|---|
| Latency | The polling interval, so minutes to hours. | Seconds. |
| API usage | Every poll calls the API even when nothing changed. | Notifications arrive only when something changes. |
| Complexity | Lower to start, but you own the checkpoint state. | Requires a messaging service and message handlers. |
| Reliability | If the checkpoint is lost, you need a full resynchronization. | The queue provides retry and delivery guarantees. |
| Deletions | Invisible to the query. | Delivered as Messages. |
Propagate deletions
Deletion is a first-class path, not an afterthought. Every trigger that should remove a record needs a defined action:
- a Product is unpublished or deleted
- a Product is removed from a Product Selection that scopes a Store's index
- a Product is no longer available in a Store, which the in-store projection reports as a ResourceNotFoundError
- a Store is deleted, which retires that Store's index
Skipping any of these leaves ghost records: results that still appear in search and link to a product detail page that no longer exists.
On the Store-scoped path, a Not Found response has two possible causes. The Product is genuinely outside the Store's assortment, or the Store configuration cache has not caught up. Wait out the cache before you treat a Not Found as a removal. Otherwise a routine assortment change removes Products that should stay indexed.
Repair drift
Even a correct incremental path drifts. A Category rename fans out to Products that generated no Product Message. A notification is lost during a deployment gap. A Store configuration change reshapes an assortment. Run a periodic full rebuild as the backstop, and reconcile record counts between the engine and the published catalog.
TemporaryError, after which notifications may be dropped. A ConfigurationError stops delivery after 24 hours on production Projects, or 1 hour on development and staging Projects. Monitor the health status of each Subscription and alert on it. A misconfigured Destination otherwise leaves the index stale with no obvious signal.To rebuild without disturbing live traffic, follow these steps:
- Create a second index for the same content.
- Apply incremental updates to both the live index and the new one.
- Run full ingestion into the new index.
- Move live traffic to the new index once it is complete and its count checks out.
- Delete the previous index.
To avoid unnecessary writes, store the Product version alongside each record. Skip the update when the version you already hold is not older than the incoming one.
Configure scopes, secrets, and the trigger
A search integration reads from commercetools and writes to the engine, so it needs no write scope on the catalog. Grant the following scopes:
- Grant
view_productsto both applications, and addview_product_selectionsandview_storesfor a Store-scoped index. - Grant
manage_subscriptionsonly to the application whose deployment step registers the Subscription. - Grant scopes per application rather than per Connector, and check the current list in Scopes. A search integration that holds
manage_productsis over-privileged. - The engine needs no commercetools scope. It is reached with the engine's own API key.
Store the engine's API key as secured configuration rather than as plain configuration, and keep it out of logs and error responses. Index name, region, locale list, price context, and behavioral toggles are ordinary configuration.
Authenticate the full-ingestion trigger. A publicly reachable trigger endpoint lets anyone start a complete rebuild of your index. That is both a cost attack and a way to disturb live search. Validate a shared secret or a request signature before the rebuild starts.
What the templates leave you to build
| Area | What you must add or verify |
|---|---|
| Engine client | The save and remove functions in the Product export template are deliberately empty. The engine client and the mapping are yours to write. |
| Atomic rebuild | Both repositories clear records before refilling, rather than building into a second index and swapping. Implement build-and-swap before production. |
| Trigger authentication | Neither validates the caller of the full-ingestion endpoint. Add a shared secret or a signature check. |
| Subscription registration | The deployment step in the Product export template deletes the Subscription and recreates it, so notifications generated in that window are lost. Change it to read first and create only what is missing. |
| Credentials | Both supply commercetools client credentials by hand through configuration. Prefer a Connect-provisioned client with least-privilege scopes. |
| Memory and volume | The Product export template loads a Store's whole assortment into memory before writing. Stream or chunk it for a large assortment. |
| Dependencies | Both pin older commercetools SDK versions. Check and upgrade before you rely on either one. |
Read the current source of any repository before you fork it, rather than relying on this table or on a previous reading. These are starting points that reduce the boilerplate, not Connectors you can deploy as they are.
Test the contracts you cannot see
Most of what breaks a search integration is invisible in a code review, so cover it with automated tests. Mock both boundaries, the engine and the commercetools APIs, so the suite runs with no deployment and no secrets.
For full ingestion, assert that:
- a trigger without authentication, or with an invalid signature, is rejected
- pagination uses the identifier cursor rather than an offset
- the rebuild is atomic, so the live index is never empty or partial mid-run
- the count check compares engine records against published Products and fails the run on a mismatch
- a Store-scoped load reads in-store projections and deduplicates Products held in more than one Selection
For incremental updates, assert that:
ProductPublishedupserts from the payload projection, and a second delivery of the same notification changes nothingProductUnpublishedandProductDeletedremove the record by its key- Store and Product Selection Messages add to and remove from the correct Store's index
- an out-of-order notification does not overwrite newer data
- the envelope is decoded, and handled, ignored, and retryable messages each return the intended status
- for the polling variant, the checkpoint advances and removals are covered elsewhere
Test the mapping directly as a pure function. Given a Product Projection, it should produce the exact record you expect, including the selected price context, the locale fields, and the denormalized categories.
Verify and operate
Treat the integration as unfinished until a change appears, a removal disappears, and a rebuild matches the catalog. Observe each of those in the engine's index rather than in your own logs.
Verify that a publish reaches the index
Publish a Product, or change and republish one, then confirm that:
- a record with the expected key exists. It carries the mapped fields: names and descriptions in each in-scope locale, the selected-context price, the denormalized categories, and the image
- searching for a word from the Product name returns it, and its facets are populated
- only published data is present, with no staged edits and no unpublished Products
- redelivering the same notification changes nothing, because the write is an upsert on a stable key
localeProjection mapping rather than at the indexing pipeline.Verify that a removal disappears
Unpublish a Product, and separately delete one, then confirm the record is gone from the index and no longer returned by search. This is the check people skip, and skipping it is how ghost records reach production.
If you poll rather than subscribe, confirm that removals are covered by removal Messages or by the periodic rebuild.
Verify that a rebuild matches the catalog
Trigger full ingestion against a known catalog, then confirm that:
- the engine's record count equals the published Product count, multiplied by your granularity factor
- the index never went empty or partial during the rebuild, checked by querying it while a rebuild is running
- re-running full ingestion produces an identical index, with the same count and the same records
Verify that Store scoping holds
For a Store-scoped index, add a Product to one Store's Product Selection and confirm it appears in that Store's index only. Remove it and confirm it disappears from that index while remaining in the other Stores that still list it. Confirm that the in-store projection resolved the Store's languages and prices rather than the Project defaults. Where Product Tailoring applies, confirm that the tailored content arrived.
Signals that look like failures
Three correct behaviors read as defects, and one defect reads as flakiness. Recognize them before you escalate.
availability field is itself eventually consistent and lags real stock levels by a few seconds. This is the design working as intended. Verify that the flag refreshes on schedule, and have the storefront confirm live quantity at render or at add-to-cart.Symptom, cause, and resolution
| Symptom | Likely cause | Resolution |
|---|---|---|
| Unpublished Products still appear, and hits link to dead pages | Deletion is not propagated | Handle ProductUnpublished and ProductDeleted, and remove by record key |
| Search returns zero or partial results during a rebuild | The rebuild clears the live index before refilling | Build into a second index and swap atomically |
| Unreleased names or prices are visible to shoppers | The integration reads staged data | Read the current projection with staged=false |
| A Product reverts to older data after a later edit | An out-of-order notification overwrote newer state | Re-fetch by resource.id, and guard on sequenceNumber or version |
| The record exists, but the price is missing or wrong | The price context does not match the storefront's context | Select one context at map time, or index per-context fields or records |
| The record exists, but a language field is empty | The locale is outside localeProjection, or the code is mapped wrongly | Include the locale, and map codes as en-US rather than en_US |
| Category names in facets and breadcrumbs are out of date | A Category rename was not fanned out | Reindex affected Products on Category Messages, with the rebuild as backstop |
| The full load misses or duplicates Products past a few thousand | Offset pagination | Cursor on sort=id asc with where=id > "{lastId}" |
| A truncated index goes live unnoticed | No count check after the rebuild | Compare the engine count against published Products, and fail on a mismatch |
| A message is redelivered forever, or a real failure vanishes | The acknowledgment status is wrong | Acknowledge with 102, 200, 201, 202, or 204 when handled or deliberately ignored, and any other status only when retryable |
| The index silently stops updating | The Subscription Destination is failing and delivery has stopped | Check the Subscription health status, repair the Destination, then run a full rebuild |
| The handler receives unreadable data | The transport envelope was not decoded | Decode message.data from base64, then validate the message type |
| Creating a Subscription fails | The Project has reached the Subscription limit | Use one Subscription per message type, and fan out in the handler |
| An in-store projection returns Not Found right after a Selection change | Store configuration is cached for up to one minute, or the Product genuinely left the Store | Wait out the cache and retry before treating the response as a removal |
| The full load exhausts memory or times out | The whole assortment is held in memory, or the run exceeds the runtime limit | Stream or chunk the load, checkpoint, and add overlap locking |
| The engine bill spikes and writes queue up | Per-unit inventory events are wired into the index | Index a coarse availability flag on a cadence instead |
Query the index from your storefront
Reading the index is your storefront's job, and it sits outside this integration. The engine's own client library handles querying, faceting, and pagination.
If you build on commercetools Frontend, two pieces connect a storefront to an external engine:
- Frontend components render the search experience, such as type-ahead search and a filterable product listing page. The Store Launchpad for B2C Retail ships Algolia components you can use as a reference. See Algolia integration.
- Extensions run in the API hub and orchestrate calls to the engine, to commercetools, and to any other service.
Some pricing rules cannot be expressed in the engine, such as price hints or Customer Group resolution. Keep those in your storefront or backend-for-frontend layer rather than encoding them in the index.