Preserving Facets During Keyword Search in Sitecore Search SDK

While implementing Sitecore Search with Search SDK we noticed an important UX issue. When a user enters a new keyword, all previously selected facets are reset.

This is not ideal for a real-world search experience – users expect filters to stay intact.

Use Case
A user applies a few filters such as:

  • Brand: Nike
  • Category: Running Shoes
  • Price Range: $50–$100

Then the user types a keyword like:

“Air Zoom”

Expected
Keep existing facets and apply the keyword on top.

Actual
All facets reset. The keyword search behaves like a fresh search.

Why This Happens
The Sitecore Search SDK provides an action:

onKeyphraseChange({ keyphrase: value })

This action is designed to restart the search, which includes clearing:

  • selected facets
  • pagination
  • sorting (depending on configuration)

So using onKeyphraseChange intentionally resets the search state, and this is expected behavior.

<button
  onClick={() => onKeyphraseChange({ keyphrase: searchFieldValue })}
>
  Search with onKeyphraseChange
</button>

Useful when you want a fresh search.
Not useful when you want to keep the existing search context (especially facets).

Solution: Use updateSearchQuery() to Preserve Facets

While exploring the available query methods, we found:

searchQuery.getRequest().updateSearchQuery(keyword)

This updates only the keyphrase, without resetting:

  • selected facets
  • sort
  • pagination (unless manually changed)

Example:

<button
  onClick={() => searchQuery.getRequest().updateSearchQuery(searchFieldValue)}
>
  Search with updateSearchQuery
</button>

This method provided exactly the UX behavior we needed.

Final Implementation
Sitecore Search initialization

const {
  widgetRef,
  actions: { onPageNumberChange, onSortChange },
  state: { page, itemsPerPage },
  query: searchQuery,
  queryResult: {
    isLoading,
    isFetching,
    data: {
      total_item: totalItems = 0,
      facet: facets = [],
      content: contentList = []
    } = {},
  },
} = useSearchResults<ComponentData, InitialState>({
  query: (query: SearchResultsWidgetQuery) => query,
  config: {
    defaultFacetType: "text",
  },
  state: {
    sortType: defaultSortType,
    page: defaultPage,
    itemsPerPage: defaultItemsPerPage,
    keyphrase: defaultKeyphrase,
  },
});

Search Input Field

<input
  type="text"
  name="search_query_field"
  id="search_query_field"
  onChange={(e) => setSearchFieldValue(e.target.value)}
/>

Option 1 – Using onKeyphraseChange() resets facets

<button
  onClick={() => onKeyphraseChange({ keyphrase: searchFieldValue })}
>
  Search with onKeyphraseChange
</button>

Option 2 — Using updateSearchQuery() preserves facets

<button
  onClick={() => searchQuery.getRequest().updateSearchQuery(searchFieldValue)}
>
  Search with updateSearchQuery
</button>

Confirmation from Sitecore Support

We raised this with Sitecore Support and received the following confirmations:

✔ onKeyphraseChange() does reset facets – by design.
✔ updateSearchQuery() is valid and correct for preserving facets.
✔ Documentation updates are in progress.
✔ A feature request has been opened: SEARCH-5440.

So the behavior and the solution are now officially acknowledged by Sitecore.

Note

The official Sitecore Search Storybook examples can be found here:
https://developers.sitecorecloud.io/search-sdk/react/latest/storybook/index.html?path=/story/widget-templates-introduction–page

The sample widgets provided in the Storybook use the onKeyphraseChange() method by default. These examples are meant to demonstrate baseline search behavior, which includes resetting facets when a new keyword is entered.

However, depending on the requirements of your application, you may need to adjust this behavior.
The approach described in this blog highlights an alternative method, updateSearchQuery() – which allows you to update the keyword without losing the currently selected facets.

This gives you more control over the overall search experience and enables a more intuitive UX, especially in scenarios where users first filter results and then refine them with keyword search.

Acknowledgment

Special thanks to Karan Raghwani for his valuable inputs and the deep research he conducted during this implementation. His analysis played a key role in identifying how updateSearchQuery() can be used effectively to preserve facets while updating keyword searches.

Keeping Search in Sync -Automating Sitecore Content Updates Using the Ingestion API

In the previous part of this series, we explored how to manually push and delete records in Sitecore Search using the Ingestion API.

That setup laid the foundation – helping developers to learn & understand how authenticated API calls, payload structures, and index endpoints work together behind the scenes.

Now, in this part, let’s connect the dots and explore how we automated this process to ensure Sitecore Search always stays synchronized with the latest content updates from Sitecore XM.

⚙️ The Goal

Our objective was simple yet critical – to automate synchronization between Sitecore CMS and Sitecore Search

“Every time an item is saved, published, or deleted in Sitecore CMS – the corresponding record in Sitecore Search should be automatically updated or removed.”

This guarantees that search results reflect the latest content authors see in Sitecore, ensuring data freshness and consistency across environments.

But beyond synchronization, data accuracy is the top priority.
Even a single mismatch between CMS content and Search Index can lead to broken experiences – outdated jobs, blogs, news or missing profiles.

Hence, accuracy and verification became a guiding principle in our approach.

🧩 The Foundation – Events and Pipelines

To achieve full automation, we leveraged Sitecore’s event-driven architecture and publishing pipelines, the two most powerful hooks for reacting to content changes.

  • Events – Detect and respond to item saves or deletions.
  • Publishing Pipelines – Detect when items are published to the Web database.

By combining both, we ensured all types of content actions (create, update, delete, publish) are captured in real-time and reflected in Sitecore Search using the Ingestion API.

🗂️ Configuration Setup – Registering Handlers and Processors

We began by defining a configuration patch file that registers custom handlers and custom processors – laying the groundwork for our automation hooks.

  • Item event handlers
  • Custom publish processors

🧠 Inside the Custom Publish Processor

The CustomPublishProcessor hooks into the publishing pipeline and inspects the ProcessedPublishingCandidates – the list of items successfully published.

For every item matching our target templates (e.g., Candidate, Job, Blog, News etc.), it calls the Ingestion API to update the corresponding record in Sitecore Search in a specific Source + Entity.

public override void Process(PublishContext context)
{
    if (context.Aborted)
        return;

    var addUpdateItems = context.ProcessedPublishingCandidates.Keys
        .Select(i => context.PublishOptions.TargetDatabase.GetItem(i.ItemId))
        .Where(j => j != null);

    foreach (var item in addUpdateItems)
    {               
        if (item.TemplateID == ID.Parse(SpecificTemplateId) && item.Paths.IsContentItem)
        {
            Log.Info($"CustomPublishProcessor: Processing {item.ID} - {item.Paths.FullPath}", this);
            _ = Task.Run(() => _sitecoreSearchService.IndexItemAsync(item));
        }

        //Similarly add different template based conditions.
        //All your required code to push a specific entity into search will come here.
    }
}

💡 Tip: Using Task.Run() ensures indexing happens asynchronously, avoiding delays during content publish operations.

🗑️ Handling Deletions Automatically

When an item is deleted, the OnItemDeleting event handler ensures that the record is also removed from Sitecore Search – keeping the index clean and consistent.

public void OnItemDeleting(object sender, EventArgs args)
{
    var item = Event.ExtractParameter(args, 0) as Item;
    if (item == null || !item.Paths.IsContentItem)
        return;

    if (SitecoreSearchHelperHI.IsValidItemForSitecoreSearchIndex(item))
    {
        if (item.TemplateID == ID.Parse(TalentBoardCandidateTemplateId))
        {
            _ = Task.Run(() => _sitecoreSearchService.DeleteItemAsync(item.ID.ToString()));
        }
       
        //Similarly add different template based conditions.
        //All your required code to push a specific entity into search will come here.
    }
}

By delegating deletions through the Ingestion API, we guarantee data parity between Sitecore and Sitecore Search — no orphaned records, no stale data.


💬 Why This Approach Works

This design bridges Sitecore’s event-driven architecture with the modern, API-first design of Sitecore Search.

It achieves real-time synchronization without compromising stability by:
Encapsulating API logic within dedicated services
Leveraging native Sitecore pipelines and events
Executing non-blocking background tasks

🏁 Final Thoughts

Implementing the Sitecore Search Ingestion API establishes a direct, code-driven connection between your Sitecore content and the search index. While crawler-based indexing remains effective for many scenarios, the ingestion approach provides enhanced control over what gets indexed and when. By embedding validation and automation within the ingestion process, teams can ensure search results remain consistent, reliable, and always aligned with the most recent content updates.

Pushing Records to Sitecore Search Using the Ingestion API

Continuing our journey of mastering Sitecore Search backend operations – after exploring how to clear all records from a source and map ingestion schemas during migration it’s time to move to the next essential step: pushing / removing data from Sitecore Search using the Ingestion API.

🧭 Understanding the Ingestion API

The Sitecore Search Ingestion API allows developers to create, update, or delete individual documents (records) in a specific source.
Unlike legacy crawlers or SDKs, this API gives you programmatic control over how and when content is indexed.

The endpoint format is:

https://<search-base-url>/ingestion/v1/domains/{domainId}/sources/{sourceId}/entities/{entityId}/documents/{documentId}?locale={locale}
  • domainId → Your Sitecore Search domain identifier
  • sourceId → The source you’ve configured in the CEC admin portal
  • entityId → Represents the logical entities that you have configured in CEC admin portal
  • documentId → Unique identifier for the record
  • locale → Language/region of the content (e.g., en-us)

🟢 Note:
The Ingestion API supports single-record operations only.
If you have multiple records to push, you must perform multiple PUT calls – ideally using parallelization or queued batch processing in your own implementation for efficiency.

You can explore and test the API directly using Postman, following the official documentation here:
🔗 Sitecore Search Ingestion API Documentation

💡 Tip: You can download the Postman collection from the official Sitecore documentation and experiment with it. This was really helpful in our case.

⚙️ Helper Method – Build the Ingestion URL

Let’s start with a small helper that constructs the ingestion endpoint dynamically.

/// <summary>
/// Builds the Sitecore Search Ingestion API endpoint.
/// </summary>
private string GetIngestionApiUrl(string sourceId, string entityId, string documentId = null)
{
    if (string.IsNullOrEmpty(_sitecoreSearchBaseUrl) || string.IsNullOrEmpty(_sitecoreSearchDomainId))
        return string.Empty;

    string baseUrl = $"{_sitecoreSearchBaseUrl}ingestion/v1/domains/{_sitecoreSearchDomainId}/sources/{sourceId}/entities/{entityId}/documents";
    if (!string.IsNullOrEmpty(documentId))
        baseUrl += $"/{documentId}";
    baseUrl += $"?locale={_defaultLocale}";

    Log.Info("GetIngestionApiUrl Url :: " + baseUrl, this);
    return baseUrl;
}

📝Explanation of Key Variables

  • _sitecoreSearchBaseUrl → Base Url which will normally be – https://discover.sitecorecloud.io
  • _sitecoreSearchDomainId → The domain identifier for your Sitecore Search setup. This is the ID of the search domain configured in the Sitecore Search Admin Portal
  • _defaultLocale → Specifies the language/region of the content, e.g., en-us. This ensures the ingestion request indexes the content correctly according to the locale configuration in Sitecore Search.
  • sourceId → The source ID you’ve configured in the admin portal where your data is ingested.
  • entityId → Defines the logical grouping or schema of records within that source.
  • documentId → The unique identifier for the specific record you’re creating or updating. This typically maps to the Sitecore item’s GUID (e.g., item.ID.ToString()).

    💡 Tip: These values can be typically stored in:
  • Environment variables
  • Or a Sitecore configuration item if you want it manageable from the CMS itself (so you can change it without redeploying code).

🔗 Helper Method – Send the Request

This method handles the HTTP call and logs both success and error scenarios.

private async Task<bool> SendIngestionApiRequest(HttpMethod method, string requestUri, HttpContent content = null)
{
    try
    {
        using (var httpClient = new HttpClient())
        {
            httpClient.DefaultRequestHeaders.Add("Authorization", _sitecoreSearchIngestionApiKey);

            using (var request = new HttpRequestMessage(method, requestUri))
            {
                if (content != null)
                {
                    request.Content = content;
                }

                var response = await httpClient.SendAsync(request);
                string responseContent = await response.Content.ReadAsStringAsync();

                if (response.IsSuccessStatusCode)
                {
                    // Try to capture incrementalUpdateId
                    try
                    {
                        var jsonResponse = JObject.Parse(responseContent);
                        var incrementalUpdateId = jsonResponse["incrementalUpdateId"]?.ToString();

                        if (!string.IsNullOrEmpty(incrementalUpdateId))
                        {
                            Log.Info($"Ingestion API Success - IncrementalUpdateId: {incrementalUpdateId}", this);
                        }
                    }
                    catch (Exception parseEx)
                    {
                        Log.Warn(
                            $"Ingestion API Success - Unable to parse incrementalUpdateId. Raw response: {responseContent}",
                            parseEx,
                            this
                        );
                    }

                    Log.Info($"Ingestion API Request Successful | Method: {method} | URI: {requestUri}", this);
                    return true;
                }
                else
                {
                    Log.Error(
                        $"Ingestion API Error | URI: {requestUri} | StatusCode: {(int)response.StatusCode} | Response: {responseContent}",
                        this
                    );
                    return false;
                }
            }
        }
    }
    catch (Exception ex)
    {
        Log.Error("Error sending request to Sitecore Search Ingestion API.", ex, this);
        return false;
    }
}

📝Explanation of Key Variables

  • _sitecoreSearchIngestionApiKey → API key for authenticating requests. Stored in environment variables (recommended) or Sitecore settings.
  • HttpMethod method → HTTP method (PUT for create/update, DELETE for removal).
  • requestUri → Full ingestion endpoint, typically generated via GetIngestionApiUrl.
  • content → JSON-serialized payload representing the document.
  • IsSuccessStatusCode → Validates request success.
  • true → Document successfully created or updated.
  • false → Error occurred; response content is logged.
  • incrementalUpdateId → A unique ID returned by the API to track the status of incremental updates. This ID can be stored (e.g., in a database) to monitor the progress of the request later. By using the incrementalUpdateId, you can query the API to check whether the update (create, update, delete) has been processed successfully or if there were any issues.
  • Logging → Logs successes, API errors, and exceptions for debugging.

💡 Tip: Wrapping HttpClient in a using block ensures proper disposal.

🧩 Constructing the Payload

Each record is represented as a document object containing an id and a set of fields.
Field names must match exactly with the ones defined in your Sitecore Search schema (case-sensitive and lowercase).

Here’s an example with multiple field types:

var payload = new
{
    document = new
    {
        id = item.ID.ToString(),
        fields = new
        {
            // Text fields
            title = item["Title"],
            description = item["Description"],

            // Boolean field
            isactive = item["IsValid"] == "1" ? true : false,

            // Date field
            posteddate = DateUtil.IsoDateToDateTime(item["Posted Date"]),

            // Array field
            //This is just for example, you can write you custom logic to values from Sitecore item(s)
            jobcategory = new[] { "Engineering", "Remote" },

            // Geolocation field (latitude,longitude)
            location = "44.977753,-93.2650108"
        }
    }
};

var jsonContent = JsonConvert.SerializeObject(payload, Formatting.Indented);
var httpContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");

📦 Indexing a Record to Sitecore Search / Pushing the Record

Now that we have our helper methods ready, let’s see how to use them to index a Sitecore item into Sitecore Search via the Ingestion API.

Below is a generic example that can work for any entity type (e.g., job, product, article, etc.):

/// <summary>
/// Indexes a Sitecore item into Sitecore Search using the Ingestion API.
/// </summary>
public async Task IndexItemAsync(Item item)
{
    try
    {
        // Initialize all Sitecore Search configuration settings
        InitializeSettings();

        // Build the ingestion endpoint dynamically
        string url = GetIngestionApiUrl(_sourceId, _entityId, item.ID.ToString());

        // Prepare the JSON payload representing the document
        var payload = new
        {
            document = new
            {
                id = item.ID.ToString(),

                // These fields are just examples.
                // Replace them with the actual fields and any custom logic
                // required to extract values for your specific Sitecore item.
                fields = new
                {
                    title = item["Title"],
                    description = item["Description"],
                    isactive = item["IsValid"] == "1" ? true : false,
                    posteddate = DateUtil.IsoDateToDateTime(item["Posted Date"]),
                    category = new[] { "General", "Public" },
                    location = "44.977753,-93.2650108"
                }
            }
        };

        // Serialize payload to JSON and prepare request content
        var jsonContent = JsonConvert.SerializeObject(payload, Formatting.Indented);
        var httpContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");

        // Send the request to Sitecore Search
        await SendIngestionApiRequest(HttpMethod.Put, url, httpContent);
    }
    catch (Exception ex)
    {
        Log.Error($"Error indexing item to Sitecore Search.", ex, this);
    }
}
  • InitializeSettings() → Ensures that all configuration values like _sourceId, _entityId, _sitecoreSearchBaseUrl, and _sitecoreSearchDomainId are properly loaded before making the API call.
  • GetIngestionApiUrl() → Dynamically builds the ingestion URL for the specific record using the item’s ID.
  • payload → Represents the document structure expected by Sitecore Search.
  • id → Unique identifier for the record (mapped from the Sitecore item ID).
  • fields → The actual searchable/filterable fields defined in your Sitecore Search schema.
  • title, description, isactive, posteddate, category, location → Example fields showing different data types – boolean, date, array, and geo, respectively. These should match your Sitecore Search schema configuration.
  • SendIngestionApiRequest() → Uses the helper method defined earlier to send the PUT request to the API.
  • Error handling → Logs any exception that occurs while preparing or sending the request.

💡 Tip: Ensure that the field names and types match exactly with your Sitecore Search schema field names, which are always stored in lowercase in the admin portal.

🧹 Deleting a Record from Sitecore Search

After successfully pushing records, there might be cases where you need to delete them. The following method handles that cleanup process via the Ingestion API.

/// <summary>
/// Deletes a document from Sitecore Search if it exists.
/// </summary>
public async Task DeleteItemAsync(string documentId)
{
    InitializeSettings();

    if (string.IsNullOrEmpty(_jobsSourceId) || string.IsNullOrEmpty(_jobsEntityId))
        return;

    string url = GetIngestionApiUrl(_jobsSourceId, _jobsEntityId, documentId);

    await SendIngestionApiRequest(HttpMethod.Delete, url);
}

This ensures that the Sitecore Search index remains in sync with your content lifecycle – maintaining clean, relevant data and preventing stale records from appearing in search results.

Verifying the Record

Once the request is sent, the response is validated using the built-in property IsSuccessStatusCode.

If this check returns true, it means the record was successfully pushed/deleted to Sitecore Search – whether it was newly created or updated.
You can confirm the result by navigating to your Sitecore Search Admin Portal.
Note: It may take a few seconds/minutes for the data to appear in the content collection after ingestion.

If IsSuccessStatusCode returns false, the error details are automatically logged by our helper method:

string errorContent = await response.Content.ReadAsStringAsync();
Log.Error($"Ingestion API Error: {errorContent}", this);

This ensures that any ingestion issues (like schema mismatches or invalid API keys) are captured in your Sitecore logs for quick debugging.

🧠 Best Practices

  • Always ensure field names and data types exactly match your schema.
  • Keep locale consistent with your source configuration.
  • Use PUT for both insert and update; DELETE for removal.
  • When ingesting many records, queue requests and send them asynchronously in controlled parallel batches to avoid rate limits.
  • Log every ingestion attempt with document ID and API response for traceability.

🏁 Wrapping Up

With this, we have completed the foundational part of the ingestion flow – pushing content from your application to Sitecore Search using the Ingestion API.

This post is focused on API-first ingestion flow – helping us to understand and test the process independently of Sitecore CMS.

In the next part of this series, we’ll take it a step further – integrating this ingestion logic directly into Sitecore CMS using custom pipeline processors and event handlers to automatically sync Search with publishing and deletion actions.

That’s where we’ll connect the dots between theory and real-world implementation.

Until then — happy indexing! ✨

Clearing All Records in a Sitecore Search Source with Ingestion API

One of the features we had in our old search provider – but is currently missing in Sitecore Search – is the ability to clear all records in a source with a single action. It’s a useful capability that I hope Sitecore will eventually add to the platform.

👉 Note: This approach applies to implementations where records are maintained and operated via the Ingestion (Push) APIs. For sites using the crawler-based setup, the process of clearing and repopulating data would be handled differently by Search itself.

Let’s walk through how we solved this problem using the Ingestion API.

Why would you ever need to clear all records?

There are several real-world scenarios where you might want to wipe out everything from a source before pushing new data:

  • Upstream data updates
    Your main data provider has made significant updates, and you need to replicate those changes in your index.
  • Business logic changes
    You’ve modified how certain attributes are generated or changed their data types. This requires pushing fresh records so that Search reflects the new logic.
  • Data mismatches
    Sometimes what Search shows doesn’t match what it should. A clean delete and re-publish ensures the index is in sync with your business rules.

In short: whenever you want to start with a clean slate in your Sitecore Search source, this feature is extremely handy.

🛠️ Our Approach

Since Sitecore Search doesn’t (yet) provide a “delete all” option from the UI, we built our own mechanism.

The idea is simple:

  1. Fetch all document IDs from a source using the Search Query API.
  2. Iterate and Delete them using the Ingestion API.
  3. Re-publish records.

👉 In our case, the application runs on Sitecore 10.3 XM, so we had the flexibility to add this logic inside the solution. We basically create Command Buttons where we have used the following methods. You can use these methods as it suit you and your requirements.
👉 If we were on XM Cloud, we’d likely implement this as a PowerShell script or a standalone utility.

📌 Step 1: Fetching All Document IDs

The method FetchAllDocumentIdsAsync queries the Sitecore Search Query API in a loop until it retrieves all document IDs from the given source.

🔎 What it does in short:

  • Paginates results using limit and offset until all items are retrieved.
  • Builds a request payload with widget, source, entity, and locale details.
  • Sends requests to the Query API (discover/v2) and parses the response.
  • Collects all document IDs and ensures uniqueness.

👉 Outcome: A list of all documents currently stored in the source.

/// <summary>
/// Fetches all document IDs from a Sitecore Search source using the Query API.
/// The method:
/// 1. Paginates results with limit/offset until all items are retrieved.
/// 2. Sends requests to the Search API and parses responses.
/// 3. Collects document IDs and ensures uniqueness.
/// Returns a clean list of all unique document IDs from the specified source.
/// </summary>
private async Task<List<string>> FetchAllDocumentIdsAsync(string sourceId, string entityId, string widgetId)
{
   const int maxLimit = 100;
   var ids = new List<string>();
   int offset = 0;
   int total = int.MaxValue;

   while (offset < total)
   {
     var payload = new
     {
        widget = new
        {
           items = new[]
           {
              new
              {
                 rfk_id = widgetId,
                 entity = entityId,
                 search = new
                 {
                    content = new { },
                    limit = maxLimit,
                    offset = offset
                 },
                 sources = new[] { sourceId }
              }
           }
       },
       context = new
       {
          locale = new { country = "us", language = "en" }
       }
   };

   var jsonContent = JsonConvert.SerializeObject(payload, Formatting.Indented);
   var httpContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");

   //Send request to Sitecore Search
   var body = await SendQueryApiRequest(GetSearchApiUrl(), httpContent);
   if (string.IsNullOrEmpty(body)) break;

   //Parse response
   var json = JObject.Parse(body);
   var contentArray = json["widgets"]?[0]?["content"] as JArray;
   if (contentArray == null || contentArray.Count == 0) break;

   //Collect IDs
   ids.AddRange(contentArray.Select(c => (string)c["id"]).Where(s => !string.IsNullOrEmpty(s)));

   //Determine total items
   if (total == int.MaxValue)
      total = (int?)json["widgets"]?[0]?["total_item"] ?? ids.Count;

   //Increment offset
   offset += contentArray.Count;
   if (ids.Count >= total) break;
 }
 return ids.Distinct().ToList();
}
📌 Step 2: Deleting All Documents

The method DeleteAllDocumentsAsync takes the list of IDs and deletes them using the Ingestion API.

🗑️ What it does in short:

  • Calls FetchAllDocumentIdsAsync to get all document IDs.
  • Logs and exits if no documents exist.
  • Iterates over each ID and:
    • Builds a delete URL using the Ingestion API.
    • Sends a delete request.
    • Logs success or failure.
  • Logs a final confirmation once all documents are removed.

👉 Outcome: The source is now empty and ready for a fresh push.

/// <summary>
/// It will first fetch all the documents from a particular source and then delete them.
/// </summary>
/// <param name="sourceId">Source Id</param>
/// <param name="entityId">Entity Id</param>
/// <param name="widgetId">Widget Id</param>
/// <returns></returns>
public async Task DeleteAllDocumentsAsync(string sourceId, string entityId, string widgetId)
{
      if (string.IsNullOrEmpty(sourceId) || string.IsNullOrEmpty(entityId)) return;

      var documentIds = await FetchAllDocumentIdsAsync(sourceId, entityId, widgetId);

      if (!documentIds.Any())
      {
          Log.Info("No documents found to delete for sourceId: " + sourceId + " EntityId: " + entityId + " WidgetId: " + widgetId, this);
          return;
      }

      Log.Info($"Found {documentIds.Count} documents to delete for sourceId: " + sourceId + " EntityId: " + entityId + " WidgetId: " + widgetId, this);

      foreach (var docId in documentIds)
      {
          var deleteUrl = GetIngestionApiUrl(sourceId, entityId, docId);
          var success = await SendIngestionApiRequest(HttpMethod.Delete, deleteUrl);
          if (success) Log.Info($"Deleted document: {docId}", this);
          else Log.Warn($"Failed to delete document: {docId}", this);
      }

      Log.Info("All documents deleted for sourceId: " + sourceId + " EntityId: " + entityId + " WidgetId: " + widgetId, this);
}

📌 Step 3: Supporting Methods

🔗 GetIngestionApiUrl

  • Builds the Ingestion API endpoint for deleting documents.
  • Includes domain ID, source ID, entity ID, and optionally the document ID.
  • Appends the locale.
  • Logs the URL for traceability.

👉 Usage: Creates the exact endpoint used for delete requests.

/// <summary>
/// Get Data Ingestion API Url
/// </summary>
/// <param name="sourceId">Source ID</param>
/// <param name="entityId">Entity</param>
/// <param name="documentId">Document ID</param>
/// <returns></returns>
private string GetIngestionApiUrl(string sourceId, string entityId, string documentId = null)
{
    if (string.IsNullOrEmpty(_sitecoreSearchBaseUrl) || string.IsNullOrEmpty(_sitecoreSearchDomainId))
        return string.Empty;

    string baseUrl = $"{_sitecoreSearchBaseUrl}ingestion/v1/domains/{_sitecoreSearchDomainId}/sources/{sourceId}/entities/{entityId}/documents";

    if (!string.IsNullOrEmpty(documentId))
            baseUrl += $"/{documentId}";

    baseUrl += $"?locale={_defaultLocale}";
    Log.Info("GetIngestionApiUrl Url :: " + baseUrl, this);
    return baseUrl;
}

🔗 GetSearchApiUrl

  • Builds the Query API endpoint for fetching documents.
  • Uses the domain ID to point to the right search index.
  • Logs the constructed URL.

👉 Usage: Provides the endpoint used by FetchAllDocumentIdsAsync.

/// <summary>
/// Get Sitecore Search API Url
/// </summary>
/// <returns></returns>
private string GetSearchApiUrl()
{
    if (string.IsNullOrEmpty(_sitecoreSearchBaseUrl) || string.IsNullOrEmpty(_sitecoreSearchDomainId))
       return string.Empty;

    string url = $"{_sitecoreSearchBaseUrl}discover/v2/{_sitecoreSearchDomainId}";
    Log.Info("GetDiscoverApiUrl :: " + url, this);
    return url;
}

End-to-End Flow

Here’s how everything works together:

  1. GetSearchApiUrl → used to query documents.
  2. FetchAllDocumentIdsAsync → retrieves all document IDs in batches.
  3. GetIngestionApiUrl → builds delete endpoints for each document.
  4. DeleteAllDocumentsAsync → deletes them one by one and logs results.
  5. Re-publish new records → push fresh data into the clean source.

Final Thoughts

Until Sitecore adds a native “delete all from source” feature in the dashboard, this custom approach is a reliable way to keep your sources clean when using the Ingestion API.

It ensures you can:

  • Start fresh whenever data changes upstream,
  • Re-apply new business rules, and
  • Keep your Search results consistent.

Note: In upcoming blogs we will also try to implement a batch delete operation.

This pattern has been working well for us, and I believe many teams using Sitecore Search with Ingestion API will find it handy. 💡

Handling DLL Updates in Sitecore XM Docker Deployments

When working with Sitecore XM on Docker, you’ll often see a folder structure like this:

(folders – build, data, deploy, tools, traefik, and multiple docker-compose files)

This setup is quite standard across Sitecore XM Docker solutions. One folder worth highlighting is the deploy folder.

In a typical development workflow, when you publish code from Visual Studio, your output gets placed in the deploy folder. Docker then mounts this folder into the CM/CD containers, and Sitecore’s Deployment.ps1 script copies the files into the actual website path. This enables fast inner-loop development without rebuilding images.

Issue

When making code changes, everything works fine – until you rename or remove a DLL. For example:

  • You remove an old DLL version.
  • Or you rename/update an existing DLL.

After publishing from Visual Studio, the updated DLL is added to the deploy folder. But the old DLL isn’t removed automatically.

As a result, the container ends up with both the old and new DLLs, which can cause strange behavior.

Why This Happens

  • Visual Studio’s publish process copies and updates files – it doesn’t delete what’s already there.
  • dotnet publish makes sure new/changed files are written.
  • Some publish profiles (like MSDeploy) can remove extra files, but most Sitecore Docker setups rely on simple folder publishing, so that option doesn’t apply.
  • Since deploy is mounted into the container and Sitecore copies it as-is into the IIS site, stale files hang around unless you clear them.

Where Does the deploy Folder Come From?

The deploy folder isn’t a Sitecore artifact. It’s simply a publish target folder configured in your Visual Studio publish profile. In the Sitecore Docker setup, this folder is then mounted into the CM/CD containers via Docker Compose.

  • Visual Studio publishes your project output into deploy.
  • Docker mounts deploy into the container.
  • Sitecore’s Deployment.ps1 copies the contents into the IIS web root.

That’s why keeping the deploy folder clean before publish is so important — whatever is inside gets picked up and pushed into your container.

The Fix: Automate Cleanup in MSBuild

The best solution is to automate cleaning the deploy folder before every publish. You can add a BeforePublish target in your .Website.wpp.targets file like this:

<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">

  <!-- Existing targets -->
  <Target Name="DotNetToolRestore" AfterTargets="Build">
    <Exec Condition="'$(BuildingInsideVisualStudio)' == 'true'" WorkingDirectory="$(SolutionDir)" Command="dotnet tool restore"/>
  </Target>

  <Target Name="Leprechaun" AfterTargets="DotNetToolRestore">
    <Exec Condition="'$(BuildingInsideVisualStudio)' == 'true'" Command="dotnet leprechaun /c &quot;$(SolutionDir)Leprechaun.config&quot;"/>
  </Target>

  <!-- NEW: Clean deploy folder before publish -->
  <Target Name="CleanDeployFolder" BeforeTargets="BeforePublish">
    <RemoveDir Directories="$(SolutionDir)deploy" />
    <MakeDir Directories="$(SolutionDir)deploy" />
  </Target>

</Project>

This ensures the deploy folder is wiped and recreated automatically before every publish, so only the correct DLLs are deployed.

Tips

  • During large refactors with many file renames/deletions, this automated clean step prevents old DLLs from sticking around.
  • For local development, publishing to deploy is fast and convenient.
  • For production deployments, it’s better practice to bake your code into a custom Docker image for a clean, repeatable build.

Bottom line: If a DLL rename or deletion doesn’t “take,” the root cause is that old files remain in deploy. Automating the cleanup with an MSBuild target ensures every publish starts fresh, keeping your Sitecore XM containers free of stale artifacts.

Sitecore Search Analyzers – What They Are and When to Use Them

In Sitecore Search, an analyzer decides how text is broken down into searchable tokens. It works both when content is indexed and when someone types in a search query. Picking the right analyzer for each attribute can make a noticeable difference in result relevance, exact matches, and autocomplete quality.

Available Analyzers

Multi locale standard (rfk_standard_multi_locale)
This is the one you’ll probably use most. It works across multiple languages, removes punctuation and common words, handles synonyms, and makes everything lowercase. It also reduces words to their root form — “results” becomes “result.” A safe default for most attributes.

Standard (rfk_standard)
Similar to Multi locale, but only for English. No locale-specific processing, so use it only if your site is completely in English.

Alphanumeric only (rfk_alphanumeric_only_analyzer)
Removes everything that’s not a letter or number, then keeps the result as one token.
Example: 1235-abhe-3f34s becomes 1235abhe3f34s. Works well for IDs, SKUs, or codes.

Keyword (rfk_keyword)
Keeps the whole value exactly as it is, treating it as a single token.
Example: “Sitecore Search” stays as “Sitecore Search.” Good for exact phrase matches or filters.

Lowercase (rfk_lowercase)
Stores the value as a single lowercase token. Helpful for case-insensitive comparisons or sorting when you don’t want any tokenizing or stemming.

Prefix match (rfk_prefix_match)
Generates lowercase prefixes from the text, ignoring punctuation.
Example: 978-3-16 turns into 978, 9783, 97831, 978316, and so on. Perfect for matching code prefixes or building type-ahead searches for things like ISBNs.

N-gram based matching (rfk_ngram_analyzer)
Splits text into smaller character chunks (n-grams). Useful for languages without spaces, long compound words, and for making autocomplete or suggestions more forgiving.

Partial match (rfk_partial_match)
Generates both split and joined versions of a term, all in lowercase, and removes stop-words.
Example: “up-to-date” produces up, date, and uptodate. Great for catching both separated and combined versions of a word.

Shingle generator (rfk_shingle_analyzer)
Creates sequences of words (word n-grams).
Example: “how to improve search” produces how to, to improve, and improve search. Useful for phrase suggestions and proximity-based results.

Standard no stemmer (rfk_no_stemmer_analyzer)
Same as Standard, but it doesn’t trim words to their root form. Keeps “improve” as “improve.” A good choice when stemming would cause mismatches, such as with brand names or technical terms.

Summary

Analyzer NameWhat It DoesWhen to Use It
Multi locale standardLocale-aware stemming, stop-word removal, synonymsGeneral search across multiple languages
StandardEnglish-only stemming and tokenizingEnglish-only sites
Alphanumeric onlyRemoves punctuation, keeps as one tokenIDs, SKUs, codes
KeywordKeeps input as a single tokenExact matches, filters
LowercaseSingle lowercase tokenSorting, case-insensitive matching
Prefix matchCreates lowercase prefixesAutocomplete for codes/IDs
N-gram based matchingCharacter n-gramsFuzzy search, non-spaced languages
Partial matchSplit and joined formsHyphenated or compound words
Shingle generatorWord n-gramsPhrase suggestions
Standard no stemmerStandard without stemmingBrand names, technical terms

Use Cases + Best Practices

  • Start with Multi locale standard for most attributes as it is reliable and works in multilingual scenarios.
  • Use Keyword when you need exact matches, such as quoted phrases or IDs that must not be broken apart.
  • Pick Alphanumeric only or Prefix match for codes, SKUs, and IDs where formatting varies or prefix search is needed.
  • Choose Partial match to handle hyphenated or compound words smoothly.
  • Use N-gram or Shingle generator in autocomplete or suggestions to match more variations of user input.
  • Go for Standard no stemmer when stemming causes incorrect matches, like in product or brand names.

Where to Configure Analyzers

In Sitecore Search UI:
Administration → Domain Settings → Feature Configuration → Textual Relevance

  • Each attribute shows its assigned analyzer(s).
  • When you first enable textual relevance for an attribute, Sitecore assigns Multi locale standard by default.
  • You can add more analyzers to the same attribute and adjust their weights to influence scoring.

Setup Steps

  1. Enable the attribute for textual relevance
    Go to Administration → Domain Settings → Attributes, select your attribute, check Use for Features: Textual relevance, and publish.
  2. Assign analyzers
    Go to Feature Configuration → Textual Relevance, select the attribute, add analyzer(s), and set weights.
  3. Reindex content
    Always reindex after changing analyzers so the updated tokens are available in search.

By setting this up carefully and aligning analyzers with your content structure, you ensure that Sitecore Search interprets your data accurately, improving your query results and search accuracy.

Migrating from Algolia to Sitecore Search: A Backend-Focused Guide For Ingestion & Schema Mapping

In many projects, technology decisions evolve due to licensing, platform consolidation, or cost alignment. One such shift that we faced is the move from Algolia to Sitecore Search.

This blog focuses on real-world conceptual, API differences, Portal Configurations between the two platforms – especially for teams tasked with migrating search indices, data structures, and ingestion processes. The goal is not to compare features competitively, but to ensure technical readiness and accuracy when transitioning. Please note that this blog does not focus on front-end changes; instead, it covers configuration aspects and the different acronyms used by each platform.

🔁Concept Mapping: Algolia vs Sitecore Search

High-Level Terminology Shift
The first adjustment is understanding the terminology and data model shift.
Here’s a side-by-side comparison of the concepts:

ConceptAlgoliaSitecore SearchNotes / Migration Tips
IndexIndexSource + EntityMap each Algolia index to a (Source, Entity) pairing.
DocumentObject with objectIDdocument: {
id,
fields
}
Sitecore requires top-level id and then nested fields. Examples of document formats for each platform are provided later in this blog.
AttributesDynamic per objectSchema-driven, case-sensitiveDefine all attributes up-front in Sitecore Portal.
SchemaImplicit / flexibleNo dynamic attributes.
Define types, required flag, and other configuration
No dynamic attributes.
Define types, required flag and other configuration
Geolocation_geoloc reservedCustom attribute of type GEOFormat must match
{ lat, lng };
Examples given below
Batch IngestionArray of objectsdocuments arrayIn Sitecore:
{ "documents":
[ { id, fields } ] }.
Examples given below
API Key HeaderX-Algolia-API-Key + X-Algolia-Application-IdAuthorization: <raw-api-key>Sitecore expects raw key in headers.
Case SensitivityNot case-sensitiveSource/Entity/Attributes are case-sensitiveWatch for naming mismatches.
ValidationMinimalStrict: Type checks, Required fields, Valid attributes name.Empty batches or invalid data will cause rejection.

🔁How Documents Differ

In Algolia, documents are flexible JSON objects with a required objectID.
For example:

{
"objectID": "123",
"name": "Example",
"type": "Article",

"industry": [
"Healthcare",
"Education",
"Manufacturing"
],
"_geoloc": {
"lat": 44.2949636,
"lng": -93.268827
}
}

In Sitecore Search, documents must follow a strict format:

{
"document": {
"id": "{D8667DE3-A449-497C-9208-BB3402FA1D23}",
"fields": {
"name": "Example",
"type": "Article",

"industry": [
"Healthcare",
"Education",
"Manufacturing"
],
"location": {
"lat": 40.7128,
"lng": -74.0060
}
}
}

A few important notes:

  • The id is mandatory and should be unique across the entity.
  • All fields must match the schema defined in the Sitecore Search UI.
  • Geolocation fields must be of type GEO and must exactly match the name defined in the schema.

    🔁Schema Configuration

Unlike Algolia, which lets you index dynamic attributes, Sitecore Search enforces schema validation which makes sure that what ever we are passing in payload is pre-configured

Every attribute (field) must be defined with:

  • A name (case-sensitive)
  • A data type (string, double, integer, boolean, object, timestamp, geo, array of string, array of object, timestamp)
  • Whether it is required, searchable, filterable, facetable, and sortable (all defined in the schema UI).
  • Whether it supports faceting or full-text search

Missing or extra fields in your ingestion payload will result in an error, and the entire batch may be rejected.

🔁Common Migration Challenges

  1. Case Sensitivity: Sitecore is case-sensitive across the board (Sources, Entities, Fields). Ensure your naming conventions are consistent.
  2. Geolocation: Algolia supports _geoloc natively, while Sitecore requires you to define a geo field and match the lat/lng format exactly.
  3. Validation Errors: Sitecore’s validation is strict. Even a missing required field or misnamed attribute can cause the full ingestion to fail. Please refer to this blog for more details if you want to learn how to identify errors.
  4. Batch Structure: Ensure payloads follow this structure:
{
"document": {
"id": "{D8667DE3-A449-497C-9208-BB3402FA1D23}",
"fields": {
"name": "Example",
"type": "Article",

"industry": [
"Healthcare",
"Education",
"Manufacturing"
]
}
}

Final Thoughts

Migrating from Algolia to Sitecore Search introduces structural and schema validation considerations but allows for tight control and centralized schema governance. While Sitecore Search’s strictness may feel limiting initially, it leads to a more consistent and predictable search experience over time.

If you’re moving due to licensing or platform unification, we recommend:

  • Defining your schema before ingestion (required step).
  • Validating payloads manually or via script before posting.
  • Using tools like Postman or C# HttpClient to test and debug ingestion.
  • Creating a fallback log mechanism for failed ingestions. Please refer to this blog for more details.

Debugging Sitecore Search Ingestion API Errors Using the Network Tab

Working with ingestion APIs often involves troubleshooting indexing issues, especially when the system doesn’t provide complete error details in the UI. To ensure smooth document ingestion in Sitecore Search, it’s important to know how to dig deeper into API responses and identify the root cause of failures.

While working on indexing documents with Sitecore Search and its Ingestion API, we recently came across a scenario where documents were not indexing as expected, and the “View Details” option in the error log wasn’t providing much insight.

After contacting Sitecore Support, we found an effective way to get detailed error information – by checking the API responses through the Network tab in the browser’s Developer Tools and thus wanted to share it with everyone.

This blog explains the situation we faced, the guidance we received from Sitecore Support, and the steps you can follow to debug indexing errors.

Situation

We are currently working on indexing documents into Sitecore Search using the Ingestion API. All required attributes are configured, and the payload we send in the API request includes these attributes. However, we notice that certain documents are not getting indexed.

When we check the Error Log in Sitecore Search, the message suggests:

“Click on ‘View Details’ to see more.”

But the “View Details” button is not functioning, which makes it difficult to understand the root cause of the issue.

Sitecore Support informed us that this is a known issue (SEARCH-2409). As a workaround, they suggested checking the Network tab to directly view the API error response.

Note: After we reported this issue, the “View Details” button is no longer visible. If you are following along with the screenshots in this blog, you may notice its absence. This is expected as Sitecore has updated the UI to temporarily hide the button while addressing the issue.

Note: The issue with the non-functional “View Details” button in the error log has been registered with Sitecore under the reference number SEARCH-2409. You can check the status of this bug on Sitecore’s public bug reference page. If it’s not listed yet, it may still be under internal review.

How to Check Errors Using the Network Tab

Here’s the quick approach to finding detailed errors when using Sitecore Search:

  1. Open your source in Sitecore Search where you are verifying document ingestion.
  2. Open Developer Tools (press F12 or right-click → Inspect).
  3. Navigate to the Network tab and select the “Fetch/XHR” filter.
  4. Use the search bar to filter requests using the keyword “document.”
  5. Select the relevant request.
  6. In the Response tab, you will find the detailed error message from the API, which may include schema mismatches, invalid field types or something that will guide you to find the root cause of a document not getting index.

mapper_parsing_exception failed to parse field [willingtorelocate] of type [boolean]...

Why This Approach is Helpful

  • Real-time debugging: View the exact error response from the Sitecore Search API.
  • Pinpoint failures: Understand why the ingestion API request is failing to index a document.
  • Identify misconfigurations: Detect schema mismatches or incorrect field types in the payload.
  • Quick resolution: Access detailed error context and understand the root cause.

    Key Takeaways
  • Use the Network tab with Fetch/XHR filtering to directly inspect API responses.
  • Validate your payload against the source schema to ensure all required attributes and data types are correct.
  • Utilize detailed API responses to quickly resolve indexing issues.
  • If everything appears correct but the issue persists, raise a Sitecore Support ticket after thoroughly verifying the payload and configurations.

Final Thoughts

Debugging ingestion issues or why a document is not indexing in Sitecore Search becomes much easier when you use the browser’s Network tab to examine API responses. This approach provides direct insights into why a document might not be indexing and helps resolve potential misconfigurations quickly.

Fixing Sitecore XM Cloud Traefik Errors with Docker Desktop v4.41+ and Compose v2.35

Setting up the Sitecore XM Cloud local containers recently, we ran into a confusing error with Docker Compose. If you’ve seen this:

services.traefik.volumes.0.type must be one of the following: “bind”, “volume”, “tmpfs”, “cluster”, “image” – you’re not alone.

This post dives into what causes this issue, why it appeared in recent versions, and how you can resolve it. It’s especially important and helpful if you’re just starting with Docker or setting up a new XM Cloud or XM setup locally.
Note: If you’re not updating Docker and have completed the setup earlier, you likely won’t face this issue.

🐳 The Problem

In many Sitecore Docker setups — especially when using Traefik on Windows — you’ll mount the Docker Engine socket using type: npipe.

In this setup, Traefik runs as a Docker container and acts as the reverse proxy, routing traffic to the appropriate Sitecore services.

This worked perfectly fine up to Docker Compose v2.34.0. But when I and my team mates tried running the same setup on latest Docker Desktop v4.42.1 (which includes Docker Compose v2.35.1), I hit this error:

type: npipe is no longer recognized as a valid volume type.

Of course at first, it seemed like a regression or misconfiguration but digging deeper revealed a layered dependency issue.

🔍 What Changed?

In Docker Compose v2.35.0 and v2.35.1, configurations using type: npipe fail validation. While npipe still works on Windows for named pipe mounts, the validation logic in the underlying compose-go library does not recognize npipe as a valid volume type.

The issue is officially tracked here: docker/compose#12778
And more precisely in this PR: compose-go/pull/771

📦Which version includes what?

To clarify the confusion, here’s a table mapping Docker Desktop versions with Docker Compose and Compose-Go versions – and whether they support npipe:

Docker DesktopDocker ComposeCompose-Gotype: npipe Support
v4.40.0v2.34.xv2.4.x✅ Yes
v4.41.0 – v4.42.1v2.35.xv2.4.9❌ No (validation dropped)

Note: Although some earlier documentation claimed v4.41.0 was safe, multiple confirmations — including this comment – confirm that v4.41.0 is also affected.

How to Fix It

Option 1: Downgrade Docker Desktop

Downgrade Docker Desktop to v4.40.0 — the last known version where type: npipe validation worked correctly.

You can download it from the official Docker Desktop release archive.

Option 2: Use bind Instead of npipe

You can update your docker-compose.yml to use type: bind instead of type: npipe. For example:

Before

volumes:
  - type: npipe
    source: \\.\pipe\docker_engine\
    target: \\.\pipe\docker_engine\

After

volumes:
  - type: bind
    source: \\.\pipe\docker_engine\
    target: \\.\pipe\docker_engine\

This configuration passes validation and works properly on Windows for mounting the Docker Engine socket.
Note: This issue is expected to be fixed in the upcoming version v4.43.0+ according to the latest comments.

🔄 Why Is This Important for Sitecore?

The official XM Cloud containerized repo includes this volume mount as part of the traefik configuration. Anyone doing a fresh setup on a newer Docker Desktop version will face this issue unless they:

  • Downgrade
  • Modify the Compose file

It may seem like a small issue, but for anyone setting this up for the first time or getting onboard or updating Docker, it’s important to be aware of.

🙌 Acknowledgments

Special thanks to the open-source contributors of the Docker Compose and Compose-Go repositories for actively maintaining these tools and supporting the community.

Thanks to Garima Thakore for surfacing this issue during her ongoing Sitecore XM setup after updating Docker – which led to this deep dive and solution.

I hope this guide helps you understand the root cause and fix the issue quickly if you encounter it.

Why Sitecore Dictionary Items Fail in Pipelines/Processors – And How to Fix It

When working with Sitecore, the Dictionary domain is a reliable way to store translatable text. Most of us using Sitecore would use Sitecore.Globalization.Translate.TextByDomain() to fetch phrases at runtime.

While this works perfectly in most controller rendering contexts, issues begin to arise when you try to use it in background processes like custom pipelines, processors, or scheduled tasks. Strangely, what happens is — Sitecore.Globalization.Translate.TextByDomain() works fine in normal rendering contexts, but returns null or the key itself when used in background processes.

For this blog, we’ll take the example of the publishing pipeline, as in our case, that’s where we had done our custom code. After digging into Sitecore’s translation APIs and how context behaves during publishing, I discovered two reliable ways to resolve dictionary values without failure.

🧭 In this blog we will have a walk through of the following

  • Why Translate.TextByDomain() (and Translate.Text()) fail in custom pipelines or few background processes.
  • What’s different in that case
  • Two alternate working solutions
  • How to apply each

🔍 The Problem

In our Sitecore solution, we used: Sitecore.Globalization.Translate.TextByDomain(“Dictionary”, “<<Dictionary Key>>”);
This worked well inside renderings or controllers. But when used inside our custom PublishProcessor, the value returned is null or just the key and not the value of it.

Here’s a snippet from our custom publishing pipeline where this was happening:

public override void Process(Sitecore.Publishing.Pipelines.Publish.PublishContext context)
{
    if (context.Aborted)
        return;

    foreach (var processedItem in context.ProcessedPublishingCandidates.Keys
        .Select(i => context.PublishOptions.TargetDatabase.GetItem(i.ItemId))
        .Where(j => j != null))
    {
        string year = Sitecore.Globalization.Translate.TextByDomain("Dictionary", "<<Dictionary Key>>"); // Fails here
    }
}

💡 Note: This is not the exact code we used, as we had a few calls to Algolia where we were sending the data to be indexed whenever a specific set of items were published. This snippet is just for reference.

🤔 Why This Fails In Pipelines

1. Sitecore.Context.Language is not set during publishing
The Translate API depends on Sitecore.Context.Language. If this is missing (which it usually is in a publish pipeline), the lookup fails.
2. Sitecore.Context.Site may also be null
If you’re using Translate.Text() instead of TextByDomain(), it depends on the DictionaryDomain set in the current site context. Again, Sitecore.Sites.SiteContext is not initialized during publishing.
3. Dictionary domain cache is not populated
The Translate APIs rely on internal domain and phrase caches. These may not be fully loaded or refreshed at the time your publish processor runs.
4. Dictionary item may not yet be available in the web database
During publishing, dictionary items themselves might not be published yet or might not have propagated to the target DB (web), especially if running on the same publish cycle.

🧪 Attempts That Didn’t Work

Before landing on the right solutions, I tried several common approaches:

  • Clearing Sitecore cache using PowerShell or code like Sitecore.Globalization.Translate.ResetCache();
  • Republishing all dictionary items
  • Reordering publish processors
  • Run a Full Index Rebuild

None of these reliably fixed the issue – especially when working in the real-time publish pipeline. That’s when I realized the issue wasn’t a bug or timing problem – it was a missing context problem.

Working Solution 1: Use Direct Database Access

The first approach is to bypass the Translate API and directly fetch the dictionary item from the content tree in the target database and language.

using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Globalization;

namespace Demo.Foundation.Utility
{
    public static class DictionaryHelper
    {
        public static string GetPhrase(string key, string language = "en", string databaseName = "web")
        {
            if (string.IsNullOrWhiteSpace(key))
                return "[MissingKey]";

            Sitecore.Data.Database db = Sitecore.Configuration.Factory.GetDatabase(databaseName);
            if (db == null)
                return $"[InvalidDb:{databaseName}]";

            Sitecore.Globalization.Language lang = Sitecore.Globalization.Language.Parse(language);
            Item dictItem = db.GetItem($"/sitecore/content/Dictionary/{key}", lang);

            return dictItem?["Phrase"] ?? $"[{key}]";
        }
    }
}

Usage

DictionaryHelper.GetPhrase("<<Dictionary Key>>");

💡 Note: You can also create Item ID constants, and instead of fetching items by path, fetch them by ID — especially useful if you have multiple folders under your main Dictionary folder.

Working Solution 2: Use Context Switchers with Translate API

Anyhow if you still want to use Sitecore’s Translate.Text() or Translate.TextByDomain(), you can – but in that case you will need to explicitly set the site context and language as and when needed.

🌐Language Switcher

using (new Sitecore.Globalization.LanguageSwitcher("en"))
{
    string text = Sitecore.Globalization.Translate.TextByDomain("Dictionary", "<<Dictionary Key>>");
}

🏷️ Site Context Switcher

Sitecore.Sites.SiteContext site = Sitecore.Sites.SiteContextFactory.GetSite("<<WebSite Name>>");
using (new Sitecore.Sites.SiteContextSwitcher(site))
{
    string text = Sitecore.Globalization.Translate.Text("<<Dictionary Key>>");
}

🔁 Combine Both

Sitecore.Sites.SiteContext site = Sitecore.Sites.SiteContextFactory.GetSiteContext("<<WebSite Name>>");
using (new Sitecore.Sites.SiteContextSwitcher(site))
using (new Sitecore.Globalization.LanguageSwitcher("en"))
{
    string text = Sitecore.Globalization.Translate.TextByDomain("Dictionary", "<<Dictionary Key>>");
}

With these in place, the translation works exactly as expected, even inside in your custom processors or backend services.

Thoughts

The Sitecore Dictionary system is very usefull – but it’s also context-dependent. During controller rendering, the context is available OOTB, but in publishing processors, background services, or scheduled jobs, you may need to manually supply that context.

You have two reliable solutions to choose from:

Use Direct Database Access

  • Ideal when you want full control over how dictionary values are retrieved
  • No dependency on runtime context (Sitecore.Context.Site, Sitecore.Context.Language, etc.)
  • No risk of translation failure due to missing site or language context
  • ⚠️ Be cautious when relying on item paths or IDs – if a dictionary item is moved or recreated, its path or ID will change, which can break lookups.

Use Context Switcher

  • Keeps you within Sitecore’s built-in translation framework.
  • Enables the use of Translate.Text() and Translate.TextByDomain() even in non-rendering controller contexts.
  • It is useful when you want to preserve the same translation behavior seen in normal page controller renderings. This can be applied even in your background services.

    Thanks for reading! If you’ve faced a similar issue or have your own findings or workarounds – let’s connect and exchange ideas!