The Future of Search in Umbraco
The final release from the Examine provider is here!
Why a new search?
The current search for Umbraco is powerful, but it has some limitations, mainly not being able to choose what search provider you want to use. Worry no longer, now you get to choose!
Pick your search provider - Want to stick with Examine? Great, you just add the Umbraco.Cms.Search.Provider.Examine package to your project. Need something like Elasticsearch or Azure Cognitive Search? Swap it in.
Switching is easy - With the new abstraction layer implemented, changing your search provider no longer requires rewriting your search logic. Swapping is as simple as installing a different provider and adjusting your configuration.
Different providers have different features - Different search providers bring different features. Examine might not have everything you need, like geo-search or AI-powered relevance. By switching providers, you can now get the features you want for your project.
Let’s get started! I’ll start with what we’ve built, how it works, and what features are coming in the future.
What is Umbraco Search?
A search abstraction - a set of interfaces (ISearcher, IIndexer, etc.) that let you query, filter, facet, and sort without coupling to a specific search engine.
A provider implementation - the first supported provider from Umbraco is for Examine, shipped as Umbraco.Cms.Search.Provider.Examine. Community or third-party providers can follow the same pattern.
A backoffice integration - a new dashboard under Settings > Advanced where you can inspect indexes, browse documents, and trigger rebuilds.
Using the search
Let's start with the most important part, actually searching! The core interface is ISearcher, and its SearchAsync method, where you can provide query, filtering, faceting, sorting and pagination, like so:
var result = await searcher.SearchAsync(
indexAlias: Constants.IndexAliases.PublishedContent,
query: "great expectations",
filters: filters,
facets: facets,
sorters: sorters,
culture: "en-US",
skip: 0,
take: 12
);
Let's take a look at an example from the Test site in the repository. This is a book search page that can demonstrate these new features.
Facets
Facets are the things that give you counts, so maybe you noticed on the image before that next to the Century filters, there was a little number, these are the facets in play, telling you how many results are within that range! You apply facets with something like this.
var facets = new Facet[]
{
new KeywordFacet("length"),
new KeywordFacet("authorNationality"),
new IntegerRangeFacet("publishYear", [
new("16th Century", 1500, 1600),
new("17th Century", 1600, 1700),
new("18th Century", 1700, 1800),
new("19th Century", 1800, 1900),
new("20th Century", 1900, 2000),
new("21st Century", 2000, 2100),
]),
};
Filters
Filters narrow down search results. They use AND logic between filters, and or logic between the values within a filter. One important note here is that the filter type must match the field type, which means using a “KeywordFilter” on a “Text” field will give you zero results.
Filters can also be negated, so if you want to exclude values that is also possible.
Lets apply some filters, by taking a look at the facets, and selecting some of them to use as filters, Here we'll search only for books in the 18th & 19th century.
Underneath the hood we apply it like so:
var filters = new Filters[]
{ new IntegerRangeFilter("publishYear", [
new(MinValue: 1800, MaxValue: 1900),
new(MinValue: 1900, MaxValue: 2000)
],
false);
};
Sorting
Sorting supports multiple fields (first sorter wins, the rest are tiebreakers), and there's a ScoreSorter for relevance ranking, here we can see how we resolve what sorter to use.
Sorter sorter = request.SortBy switch
{
"title" => new TextSorter(Constants.FieldNames.Name, direction),
"publishYear" => new IntegerSorter("publishYear", direction),
_ => new ScoreSorter(direction) // relevance
};
What providers are available now?
Umbraco Search is just the abstraction layer, which means it doesn’t actually ship with an included provider for now. When it becomes part of the CMS, it will ship with a replaceable provider out of the box.
The officially supported provider from HQ is an Examine provider (Umbraco.Cms.Search.Provider.Examine), available as a separate package.
Because the abstraction is provider agnostic, anyone can make their own search implementations. Kenn Jacobsen, Principal Engineer at Umbraco HQ, has already been building some packages in a personal capacity to see what’s possible.
If you’re interested , you can check them out here: Kjac.SearchProvider.Elasticsearch & Kjac.Searchprovider.Typesense.
Adding AI to the mix
Umbraco Search is also supported as part of Umbraco's AI offering. The Umbraco.AI.Search package is an officially supported semantic search provider that plugs into this same abstraction. Rather than replacing a keyword engine like Examine, it's designed to run alongside it, adding the ability to find content by meaning rather than exact keywords — so a search for "audio shows" can surface your Podcasts. It indexes both content and media on publish and just needs an embedding-capable AI provider configured. It's a neat example of the abstraction in action: bringing something that Examine doesn't have, with no changes to your search code, and both working together seamlessly.
Backoffice part
Extending the backoffice
The search dashboard is built with extensibility in mind. So if you want to extend the dashboard, you can! The details on the different extension point and how to implement them is in our documentation here: https://github.com/umbraco/Umbraco.Cms.Search/blob/main/docs/backoffice-extensions.md
You can add your own boxes to the index detail view using the “searchIndexDetailBox” extension type.
The detail view uses a two-column layout with extension slots, so you register your box, pick a column, and it shows up alongside the stats and search boxes. For example, theour Eexamine package is a good example of this, it adds a little clickable icon.
And if you click the icon, it shows you a view where you can inspect the index fields and values.
Here's how it's wired up:
1. Register the extensions in the manifest:
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'entityAction',
kind: 'default',
alias: 'Umbraco.Cms.Search.Provider.Examine.EntityAction.ShowFields',
name: 'Umbraco Search Provider Examine - Show Fields',
weight: 100,
api: () => import('./show-fields.entity-action.js'),
forEntityTypes: ['search-document'],
meta: {
icon: 'icon-search',
label: '#searchExamine_showFields',
additionalOptions: false,
},
},
{
type: 'modal',
alias: 'Umbraco.Cms.Search.Modal.DocumentFields',
name: 'Umbraco Search Provider Examine - Fields Modal',
element: () => import('./show-fields.modal.js'),
},
{
type: 'searchIndexDetailBox',
alias: 'Umbraco.Cms.Search.Provider.Examine.FieldsRouteProvider',
name: 'Umbraco Search Examine Fields Route Provider',
weight: 0,
element: () => import('./fields-route-provider.element.js'),
},
];
There are three pieces here: an entity action that adds the "Show Fields" button to search result items, a modal that displays the indexed fields, and a route provider, a non-visual searchIndexDetailBox that registers the modal route. The route provider renders nothing visually; it just hooks into the workspace to set up routing.
2. The entity action reads from the workspace context:
export class UmbSearchExamineShowFieldsEntityAction extends UmbEntityActionBase<never> {
override async getHref() {
const unique = this.args.unique ?? null;
if (!unique) return '#';
const workspaceContext = await this.getContext(UMB_SEARCH_WORKSPACE_CONTEXT);
const culture = workspaceContext?.getSelectedCulture() ?? 'none';
return fieldsRouteBuilder?.({ documentUnique: unique, culture }) ?? '#';
}
}
The key thing here is consuming:
UMB_SEARCH_WORKSPACE_CONTEXT
This gives you access to the current index alias, document count, health status, and selected culture. Any extension can tap into this.
What can you do to customize your data
One of the key design goals of Umbraco Search is extensibility. Out of the box, the package indexes all your content properties using built-in property value handlers, but there are several ways to take control of what gets indexed and how it gets searched.
Custom Property Value Handlers
The simplest way to customize indexing is by creating your own property value handler. Each handler tells the system which property editor it supports and how to transform the value into indexed fields. They're auto-discovered, so you just create the class and it works.
For example, if you had a custom property editor for storing color values and wanted them indexed as keywords for filtering:
public class ColorPropertyValueHandler : IPropertyValueHandler
{
public bool CanHandle(string propertyEditorAlias)
=> propertyEditorAlias is "My.ColorPicker";
public IEnumerable<IndexField> GetIndexFields(
IProperty property, string? culture, string? segment,
bool published, IContentBase contentContext)
=> property.GetValue(culture, segment, published) is string colorValue
&& !string.IsNullOrWhiteSpace(colorValue)
? [new IndexField(property.Alias, new IndexValue { Keywords = [colorValue] }, culture, segment)]
: [];
}
That's it. No registration needed - the system picks it up automatically.
Custom Content Indexers
Need to index data that doesn't come from properties? Maybe data from an external service, or computed values based on relationships? You can implement IContentIndexer to hook into the indexing pipeline and add whatever fields you need:
public class PopularityContentIndexer(IAnalyticsService analyticsService) : IContentIndexer
{
public async Task<IEnumerable<IndexField>> GetIndexFieldsAsync(
IContentBase content, string?[] cultures,
bool published, CancellationToken cancellationToken)
{
if (content is not IContent document) return [];
var score = await analyticsService.GetPopularityScoreAsync(document.Key, cancellationToken);
return [new IndexField("popularityScore", new IndexValue { Integers = [score] }, null, null)];
}
}
Now you can sort or filter by popularity score, a value that lives nowhere in Umbraco's content tree.
Going deeper: Custom Index Values, Indexers, Filters, and Searchers
For truly custom scenarios, you can go further:
Custom IndexValue - Extend the IndexValue record to support new data types (like GUIDs or geo-coordinates)
Custom Indexer - Override how values are written to the underlying search engine
Custom Filters - Create new filter types for your custom data
Custom Searcher - Teach the search engine how to query your custom fields
These all plug together. You define the data shape, how it gets indexed, and how it gets queried. Then wire it up in a composer:
public sealed class CustomSearchComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.Services.AddTransient<IContentIndexer, PopularityContentIndexer>();
builder.Services.AddTransient<IExamineIndexer, CustomIndexer>();
builder.Services.AddTransient<IIndexer, CustomIndexer>();
builder.Services.AddTransient<IExamineSearcher, CustomSearcher>();
builder.Services.AddTransient<ISearcher, CustomSearcher>();
}
}
The full details with complete code examples are in our custom extensibility documentation. And if you want to see a real-world example of tailored indexing in action, check out Kenn's blogpost on tailored indexing.
Zero-downtime reindexing
If you've ever had to rebuild a search index on a live site, you know the pain: either your search goes down while it rebuilds, or you get incomplete results. We wanted to solve that properly.
The Examine provider ships with zero-downtime reindexing. The idea is simple: for each logical index (like Umb_PublishedContent), there are actually two physical Lucene indexes under the hood, suffixed a and b. One is the active index serving all your search queries, and the other is the shadow sitting idle.
When you hit rebuild, this is what happens:
The shadow index gets cleared and the system flips a flag: "we're rebuilding"
All new writes now route to the shadow index, while the active index keeps serving reads as normal — your users notice nothing
The full content tree gets re-indexed into the shadow
Once complete, the system validates that the shadow index is healthy (has documents, isn't corrupted)
If healthy: the active and shadow swap — the shadow becomes the new active. This is just a flag flip, no files get moved
If unhealthy: the swap is cancelled, the old active stays in place, and nothing breaks
The trade-off is disk space — you need room for two copies of each index. But for most sites that's a non-issue, and the upside is that your search never goes down during a rebuild.
It's configurable too. If you don't need it (say, for a dev environment), you can disable it in configuration under Umbraco:CMS:Search:Examine:ZeroDowntimeIndexing and it falls back to single-index rebuilds.
Getting started
If you want to read how to install the package, browse the code, raise issues or contribute, the package is open source, and the repository is at https://github.com/umbraco/Umbraco.Cms.Search.
Kenn has some blog posts using the new search like trying out the new search, or tailored indexing.
What’s next?
The Umbraco Search abstraction and Examine provider are available for Umbraco 17 and 18, so you'll be able to rely on them in your projects just the same as you can with any other feature of the CMS.
Lastly, we plan to move the abstraction into the core in Umbraco 19. This means it will come out of the box, and you won’t need to install anything further. Provider implementations will remain as packages, so you can replace the default implementation with another provider as best fits the needs of your project.