· 26 min read

How I used the new Umbraco Search on my website

Discover how I added powerful search to my blog with category filters and smart sorting. Real-world lessons from building search with Umbraco's new platform.

In March 2026, I attended Umbraco Spark, with my ClerksWell colleague Sarah Nicholson. I always enjoy going to Spark and this year didn't disappoint.

What I like best about Spark is they find what works well and they don't change it too much. There was the biggest turnout ever and the busiest hackathon ever too. Skip to the end to see my pictures from Umbraco Spark.

The inspiration for this post

The first talk of the day was from Kenn Jacobsen who was showing us how to use the new Umbraco Search with filtering etc. He had some great tips for using it. Check out the repo for the talk here.

I'd been wanting to use it on my website codeshare.co.uk for a while and after seeing Kenn's talk I was inspired to implement it.

This post shows you how I set it up for the new version of my website and some lessons I learned along the way.

The components involved

Before we start I'll just list out the different components that were involved and show you a diagram of where they all sit in the application and how they all talk to each other.

Component File Role
Blog view Views/Blog.cshtml + Views/Partials/layout/blogListing.cshtml Renders the search form, category filter buttons with counts, sort dropdown, article card grid, and pagination
Controller Controllers/BlogController.cs Parses query string parameters (q, category, sort, page), calls the search service, and passes a populated view model to the Razor view
Search service interface Services/IArticleSearchService.cs Defines the contract, SearchAsync, GetCategoryFacetsAsync, and RenderLatestPostsAsync
Search service Services/ArticleSearchService.cs The main search implementation, calls Umbraco.Cms.Search, applies filters, requests facets, returns results
Composer Composers/SearchComposer.cs Wires everything up at startup, registers the search core, Examine provider, the article search service, the indexing handler, and the field options
Indexing notification handler Composers/ArticleIndexingNotificationHandler.cs Hooks into the index pipeline at index time to write a custom articleCategories keyword field and an isHidden field onto each article document
Field options Composers/ConfigureArticleCategoriesFieldOptions.cs Declares articleCategories as a facetable keyword field and articleDate as a sortable date field so Examine knows how to store and query them
View model Models/BlogListingViewModel.cs Carries search results, facets, pagination state, and URL builder helpers from the controller to the view
Search results model Models/ArticleSearchResults.cs Data bag holding the result items, total count, and facet snapshots returned by the search service

How they talk to each other

Browser request (?q=umbraco&category=csharp&page=2)
        │
        ▼
BlogController.IndexAsync()
  ├── reads query string params
  ├── calls IArticleSearchService.SearchAsync()  ──► Examine index
  ├── calls IArticleSearchService.GetCategoryFacetsAsync()
  └── builds BlogListingViewModel
        │
        ▼
Blog.cshtml / blogListing.cshtml (Razor)
  ├── search form
  ├── category filter buttons (with live counts from facets)
  ├── sort dropdown
  ├── article card grid
  └── pagination links (preserving q, category, sort in URLs)

At index time (separate flow):

Content published / index rebuilt
        │
        ▼
ArticleIndexingNotificationHandler
  ├── resolves category UDIs → shortName aliases
  ├── writes articleCategories keyword field
  └── writes isHidden keyword field

Installation

In order to use the new Umbraco search on your site you need to have these packages installed. Add them to your .csproj file:

UmbracoProject.csproj

<PackageReference Include="Umbraco.Cms.Search.BackOffice" Version="1.0.0-beta.4" />
<PackageReference Include="Umbraco.Cms.Search.Core" Version="1.0.0-beta.4" />
<PackageReference Include="Umbraco.Cms.Search.Provider.Examine" Version="1.0.0-beta.4" />

Here is what each package does:

Umbraco.Cms.Search.Core is the main abstraction layer. This gives you the ISearcherResolver, ISearcher, filter types (KeywordFilter), facet types (KeywordFacet, KeywordFacetValue), sorter types (DateTimeOffsetSorter, ScoreSorter), and the ContentIndexingNotification that lets you hook into the indexing pipeline. Everything else depends on this.

Umbraco.Cms.Search.Provider.Examine is the Examine/Lucene implementation of the search abstractions. This is what actually builds and queries the index on disk. It also provides FieldOptions and SearcherOptions which you configure to declare custom fields. Without this package you have the abstractions but nothing to run queries against.

Umbraco.Cms.Search.BackOffice adds backoffice UI support for the new search. This is optional for the front-end search feature itself, but useful to have so editors can see and manage the search index from within the Umbraco admin.

What the new Umbraco Search dashboard looks like

Create a Document Type

Create a document type with a template. I didn't want a search page on my site this time, I just wanted it all to go through the search on my blog page.

Set up the Composer

To make everything available to the application, you register it all in a single IComposer class. This is the entry point that Umbraco calls at startup:

SearchComposer.cs

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Search.Core.DependencyInjection;
using Umbraco.Cms.Search.Core.Notifications;
using Umbraco.Cms.Search.Provider.Examine.Configuration;
using Umbraco.Cms.Search.Provider.Examine.DependencyInjection;
using UmbracoProject.Services;

namespace UmbracoProject.Composers;

/// <summary>
/// Registers the Umbraco Search services, the Examine provider, the article search service,
/// and the <see cref="ArticleIndexingNotificationHandler"/> that enriches articles with category data.
/// </summary>
public class SearchComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        // Register the Umbraco Search core abstractions and the Examine search provider
        builder
            .AddSearchCore()
            .AddExamineSearchProvider();

        // Expand facet values so all category values are returned even when a filter is active
        builder.Services.Configure<SearcherOptions>(options => options.ExpandFacetValues = true);

        // Register the article search service for use in views
        builder.Services.AddScoped<IArticleSearchService, ArticleSearchService>();

        // Register the notification handler that enriches article index documents with category keywords
        builder.AddNotificationAsyncHandler<ContentIndexingNotification, ArticleIndexingNotificationHandler>();

        // Register the article categories field as a facetable keyword field via FieldOptions
        builder.Services.ConfigureOptions<ConfigureArticleCategoriesFieldOptions>();
    }
}

The ExpandFacetValues = true option is important. Without it, when a category filter is active, the facet counts only reflect the filtered results, not the full dataset. With it expanded, you always see accurate counts for every category regardless of what's currently selected. Kenn made sure to point this out in his talk.

Shared constants

Before we get into the controller, here's a small static class that the rest of the code references for shared field names, query parameter names and default values. Centralising them means we never duplicate magic strings between the indexer, the search service and the view model — and the field names have to match between writer and reader, so a constant is the right place for them.

BlogConstants.cs

namespace UmbracoProject;

/// <summary>
/// Shared compile-time constants used by the blog listing feature: search field names,
/// query string parameter names, default values, and sort options.
///
/// Values are grouped into nested static classes so call sites read like
/// <c>BlogConstants.FieldNames.ArticleCategories</c>.
/// </summary>
public static class BlogConstants
{
    public const string BlogPath = "/blog/";
    public const int DefaultPage = 1;
    public const int DefaultPageSize = 12;
    public const int DefaultTake = 10;

    /// <summary>Query string parameter names used on the blog listing page.</summary>
    public static class QueryParameters
    {
        public const string Query = "q";
        public const string Category = "category";
        public const string Sort = "sort";
        public const string Page = "page";
    }

    /// <summary>Sort option values accepted by the search service and surfaced in the URL.</summary>
    public static class SortOptions
    {
        public const string Recent = "recent";
        public const string Oldest = "oldest";
        public const string Relevant = "relevant";
        public const string Default = Recent;
    }

    /// <summary>Field names written to and read from the search index.</summary>
    public static class FieldNames
    {
        public const string ArticleCategories = "articleCategories";
        public const string ArticleDate = "articleDate";
        public const string IsHidden = "isHidden";
        public const string ShortName = "shortName";
        public const string Colour = "colour";
        public const string Categories = "categories";
        public const string UmbracoNaviHide = "umbracoNaviHide";
        public const string ContentTypeId = "Umb_ContentTypeId";
    }

    /// <summary>Keyword values stored in the <see cref="FieldNames.IsHidden"/> field.</summary>
    public static class HiddenValues
    {
        public const string True = "1";
        public const string False = "0";
    }
}

Controller

This is what runs when the blog page is rendered and when the search form is submitted or the category filters are clicked.

BlogController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
using UmbracoProject.Models;
using UmbracoProject.Services;

namespace UmbracoProject.Controllers;

public class BlogController : RenderController
{
    private readonly IArticleSearchService _searchService;
    private readonly ILogger<BlogController> _logger;

    public BlogController(
        ILogger<BlogController> logger,
        ICompositeViewEngine compositeViewEngine,
        IUmbracoContextAccessor umbracoContextAccessor,
        IArticleSearchService searchService)
        : base(logger, compositeViewEngine, umbracoContextAccessor)
    {
        _logger = logger;
        _searchService = searchService;
    }

    public override IActionResult Index()
    {
        return IndexAsync().GetAwaiter().GetResult();
    }

    private async Task<IActionResult> IndexAsync()
    {
        try
        {
            var q = Request.Query[BlogConstants.QueryParameters.Query].ToString().Trim();
            var category = Request.Query[BlogConstants.QueryParameters.Category].ToString().Trim().ToLowerInvariant();
            var sort = Request.Query[BlogConstants.QueryParameters.Sort].ToString() is { Length: > 0 } sv ? sv : BlogConstants.SortOptions.Default;

            var hasQuery = !string.IsNullOrEmpty(q);
            var hasCategory = !string.IsNullOrEmpty(category);
            var hasSort = sort != BlogConstants.SortOptions.Default;

            int page = int.TryParse(Request.Query[BlogConstants.QueryParameters.Page], out var p) && p > BlogConstants.DefaultPage ? p : BlogConstants.DefaultPage;
            int skip = (page - BlogConstants.DefaultPage) * BlogConstants.DefaultPageSize;

            if (Request.QueryString.HasValue && !hasQuery && !hasCategory && !hasSort && page == BlogConstants.DefaultPage)
            {
                return Redirect(CurrentPage?.Url() ?? BlogConstants.BlogPath);
            }

            var searchResults = await _searchService.SearchAsync(q, category, sort, skip, BlogConstants.DefaultPageSize);

            var facets = await _searchService.GetCategoryFacetsAsync(category, q, searchResults.FacetResults);

            var blogPath = CurrentPage?.Url() ?? BlogConstants.BlogPath;

            var viewModel = new BlogListingViewModel(CurrentPage!)
            {
                SearchResults = searchResults,
                Facets = facets,
                Query = q,
                Category = category,
                Sort = sort,
                BlogPath = blogPath,
                Page = page,
                PageSize = BlogConstants.DefaultPageSize
            };

            return CurrentTemplate(viewModel);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An error occurred while loading the blog listing page.");
            throw;
        }
    }
}

The controller reads four query string parameters from the URL and passes them straight into the search service. Pagination is calculated here too, skip is (page - 1) * pageSize. The facets come back from GetCategoryFacetsAsync using the facet results already returned by SearchAsync, so there is only one round trip to the index.

The search service interface

The controller depends on an interface so it stays easy to test and the implementation can be swapped later. Here's the contract:

IArticleSearchService.cs

using UmbracoProject.Models;

namespace UmbracoProject.Services;

/// <summary>
/// Searches published articles using Umbraco.Cms.Search and returns strongly-typed results.
/// Follows the provider pattern from the Umbraco Search / Umbraco Spark blog post:
/// https://codeshare.co.uk/blog/umbraco-search-umbraco-spark
/// </summary>
public interface IArticleSearchService
{
    /// <summary>Search articles by full-text keyword and optional category alias.</summary>
    /// <param name="query">Free-text search term, or empty/null for unfiltered results.</param>
    /// <param name="categoryAlias">Comma-separated category short-name aliases to filter by, or empty/null for all categories.</param>
    /// <param name="sort">"recent" (default) | "oldest" | "relevant"</param>
    /// <param name="skip">Number of results to skip (pagination).</param>
    /// <param name="take">Number of results to return.</param>
    Task<ArticleSearchResults> SearchAsync(string query, string categoryAlias, string sort = BlogConstants.SortOptions.Default, int skip = 0, int take = BlogConstants.DefaultTake);

    /// <summary>Return all published Category nodes as facets for the search UI.</summary>
    /// <param name="selectedAlias">Comma-separated category aliases currently selected by the user.</param>
    /// <param name="query">The current search query, used to scope facet counts.</param>
    /// <param name="prefetchedFacets">Facet results from a prior <see cref="SearchAsync"/> call to avoid a second index query.</param>
    Task<IReadOnlyList<CategoryFacet>> GetCategoryFacetsAsync(string selectedAlias, string query = "", IReadOnlyList<FacetResultSnapshot>? prefetchedFacets = null);

    /// <summary>Return the latest published articles sorted by date, bypassing the search index.</summary>
    /// <param name="take">Maximum number of articles to return.</param>
    Task<ArticleSearchResults> RenderLatestPostsAsync(int take = BlogConstants.DefaultTake);

    /// <summary>Return all published Category nodes as facets without counts, using the content cache only.</summary>
    Task<IReadOnlyList<CategoryFacet>> GetCategoryFacetsFromPublishedContentAsync();
}

ArticleSearchService

The controller calls the search service for each request so it can get the relevant search results. This is the part which calls Umbraco Search to get the results.

ArticleSearchService.cs

using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Search.Core.Models.Searching.Faceting;
using Umbraco.Cms.Search.Core.Models.Searching.Filtering;
using Umbraco.Cms.Search.Core.Models.Searching.Sorting;
using Umbraco.Cms.Search.Core.Services;
using Umbraco.Cms.Web.Common.PublishedModels;
using UmbracoProject.Models;
using SearchConstants = Umbraco.Cms.Search.Core.Constants;

namespace UmbracoProject.Services;

/// <summary>
/// Implementation of <see cref="IArticleSearchService"/> using the Umbraco.Cms.Search API.
///
/// Uses <see cref="ISearcherResolver"/> to get the published-content searcher, applying:
///   - A content-type filter to restrict results to articles
///   - An optional keyword query via managed full-text search
///   - An optional category filter on the custom articleCategories keyword field
///   - A <see cref="KeywordFacet"/> on articleCategories for sidebar counts
/// </summary>
public class ArticleSearchService : IArticleSearchService
{
    private static readonly Guid BlogCollectionPageId = new("344aff0a-a2ee-408f-a265-3d0ba039710a");
    private static readonly Guid CategoryCollectionPageId = new("4b73a8ff-ff81-4166-b48a-16b1908e2889");
    private static readonly Guid ArticleContentTypeId = new("81baeded-fcd6-4811-b516-420e0dfe718f");
    private static readonly Guid ContentContentTypeId = new("7d54cbee-ea89-4a45-85a1-a0f441fecfb5");

    private readonly ISearcherResolver _searcherResolver;
    private readonly IPublishedContentCache _publishedContentCache;
    private readonly ILogger<ArticleSearchService> _logger;

    public ArticleSearchService(
        ISearcherResolver searcherResolver,
        IPublishedContentCache publishedContentCache,
        ILogger<ArticleSearchService> logger)
    {
        _searcherResolver = searcherResolver;
        _publishedContentCache = publishedContentCache;
        _logger = logger;
    }

    public Task<ArticleSearchResults> RenderLatestPostsAsync(int take = BlogConstants.DefaultTake)
    {
        var blogCollectionPage = _publishedContentCache.GetById(BlogCollectionPageId);
        IEnumerable<IPublishedContent> articles = (blogCollectionPage?.Children() ?? Enumerable.Empty<IPublishedContent>())
            .Where(c => !c.Value<bool>(BlogConstants.FieldNames.UmbracoNaviHide));

        var sortedArticles = SortArticles(articles, BlogConstants.SortOptions.Recent).ToList();

        return Task.FromResult(new ArticleSearchResults { Items = sortedArticles.Take(take).ToList(), Total = sortedArticles.Count });
    }



    public async Task<ArticleSearchResults> SearchAsync(string query, string categoryAlias, string sort = BlogConstants.SortOptions.Default, int skip = 0, int take = BlogConstants.DefaultTake)
    {
        var searcher = _searcherResolver.GetSearcher(SearchConstants.IndexAliases.PublishedContent);

        if (searcher is not null)
        {
            try
            {
                var filters = BuildArticleFilters(categoryAlias).ToArray();
                var sorters = BuildSorters(query, sort).ToArray();

                var facets = new[] { new KeywordFacet(BlogConstants.FieldNames.ArticleCategories) };

                var result = await searcher.SearchAsync(
                    SearchConstants.IndexAliases.PublishedContent,
                    query: string.IsNullOrWhiteSpace(query) ? null : query.Trim(),
                    filters: filters,
                    facets: facets,
                    sorters: sorters,
                    skip: skip,
                    take: take);

                var allowedTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Article.ModelTypeAlias, Content.ModelTypeAlias };
                var items = result.Documents
                    .Select(doc => _publishedContentCache.GetById(doc.Id))
                    .OfType<IPublishedContent>()
                    .Where(c => allowedTypes.Contains(c.ContentType.Alias))
                    .ToList();

                // Snapshot facet results so they can be reused by GetCategoryFacetsAsync
                var facetSnapshots = result.Facets
                    .Select(f => new FacetResultSnapshot(
                        f.FieldName,
                        f.Values
                            .OfType<KeywordFacetValue>()
                            .Select(v => new FacetValueSnapshot(v.Key, v.Count))
                            .ToList()))
                    .ToList();

                return new ArticleSearchResults { Items = items, Total = result.Total, FacetResults = facetSnapshots };
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Search query failed. The index may need rebuilding.");
            }
        }

        // Fallback: return empty results when the searcher is unavailable or the query fails.
        return ArticleSearchResults.Empty;
    }

    public Task<IReadOnlyList<CategoryFacet>> GetCategoryFacetsFromPublishedContentAsync()
    {
        var categoryCollectionPage = _publishedContentCache.GetById(CategoryCollectionPageId);

        var categoryNodes = categoryCollectionPage?.Children().ToList() ?? [];

        IReadOnlyList<CategoryFacet> facets = categoryNodes
            .OrderBy(c => c.Name)
            .Select(c =>
            {
                var alias = c.Value<string>(BlogConstants.FieldNames.ShortName)?.Trim().ToLowerInvariant() ?? string.Empty;
                var colour = c.Value<Umbraco.Cms.Core.PropertyEditors.ValueConverters.ColorPickerValueConverter.PickedColor>(BlogConstants.FieldNames.Colour)?.ToString();
                return new CategoryFacet(
                    Name: c.Name ?? alias,
                    Alias: alias,
                    IsSelected: false,
                    Count: 0,
                    Colour: colour);
            })
            .Where(f => !string.IsNullOrEmpty(f.Alias))
            .ToList();

        return Task.FromResult(facets);
    }

    public async Task<IReadOnlyList<CategoryFacet>> GetCategoryFacetsAsync(string selectedAlias, string query = "", IReadOnlyList<FacetResultSnapshot>? prefetchedFacets = null)
    {
        var searcher = _searcherResolver.GetSearcher(SearchConstants.IndexAliases.PublishedContent);

        // Use pre-fetched facet counts from the main search if available,
        // otherwise query the index for facet counts.
        var categoryCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

        if (prefetchedFacets is not null)
        {
            var snapshot = prefetchedFacets.FirstOrDefault(f => f.FieldName == BlogConstants.FieldNames.ArticleCategories);
            if (snapshot is not null)
            {
                categoryCounts = snapshot.Values.ToDictionary(v => v.Key, v => (int)v.Count, StringComparer.OrdinalIgnoreCase);
            }
        }
        else if (searcher is not null)
        {
            var filters = BuildArticleFilters(null).ToArray();
            try
            {
                var facetResult = await searcher.SearchAsync(
                    SearchConstants.IndexAliases.PublishedContent,
                    query: string.IsNullOrWhiteSpace(query) ? null : query.Trim(),
                    filters: filters,
                    facets: [new KeywordFacet(BlogConstants.FieldNames.ArticleCategories)],
                    take: 0);

                categoryCounts = facetResult.Facets
                    .FirstOrDefault(f => f.FieldName == BlogConstants.FieldNames.ArticleCategories)
                    ?.Values
                    .OfType<KeywordFacetValue>()
                    .ToDictionary(v => v.Key, v => (int)v.Count, StringComparer.OrdinalIgnoreCase)
                    ?? categoryCounts;
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to retrieve category facets from search index. The index may need rebuilding.");
            }
        }


        List<IPublishedContent> categoryNodes = [];

        if (searcher is not null)
        {
            try
            {
                var categoryCollectionPage = _publishedContentCache.GetById(CategoryCollectionPageId);

                categoryNodes = categoryCollectionPage?.Children().ToList() ?? [];
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to retrieve category nodes from the published content cache.");
            }
        }

        return categoryNodes
            .OrderBy(c => c.Name)
            .Select(c =>
            {
                var alias = c.Value<string>(BlogConstants.FieldNames.ShortName)?.Trim().ToLowerInvariant() ?? string.Empty;
                var colour = c.Value<Umbraco.Cms.Core.PropertyEditors.ValueConverters.ColorPickerValueConverter.PickedColor>(BlogConstants.FieldNames.Colour)?.ToString();
                categoryCounts.TryGetValue(alias, out var count);
                return new CategoryFacet(
                    Name: c.Name ?? alias,
                    Alias: alias,
                    IsSelected: selectedAlias.Trim().ToLowerInvariant().Split(',').Contains(alias),
                    Count: count,
                    Colour: colour);
            })
            .Where(f => !string.IsNullOrEmpty(f.Alias))
            .ToList();
    }

    private static IEnumerable<IPublishedContent> SortArticles(IEnumerable<IPublishedContent> articles, string sort)
    {
        DateTime GetDate(IPublishedContent a)
        {
            var d = a.Value<DateTime>(BlogConstants.FieldNames.ArticleDate);
            return d != default ? d : a.CreateDate;
        }

        return sort switch
        {
            BlogConstants.SortOptions.Oldest => articles.OrderBy(GetDate),
            _ => articles.OrderByDescending(GetDate)
        };
    }

    private IEnumerable<Filter> BuildArticleFilters(string? categoryAlias)
    {
        yield return new KeywordFilter(
            BlogConstants.FieldNames.ContentTypeId,
            [ArticleContentTypeId.ToString(), ContentContentTypeId.ToString()],
            false);

        // Exclude hidden pages at the query level so they don't affect totals or facet counts
        yield return new KeywordFilter(
            BlogConstants.FieldNames.IsHidden,
            [BlogConstants.HiddenValues.True],
            true);

        if (!string.IsNullOrWhiteSpace(categoryAlias))
        {
            yield return new KeywordFilter(
                BlogConstants.FieldNames.ArticleCategories,
                categoryAlias.Trim().ToLowerInvariant().Split(','),
                false);
        }
    }

    private static IEnumerable<Sorter> BuildSorters(string query, string sort)
    {
        yield return sort switch
        {
            BlogConstants.SortOptions.Oldest => new DateTimeOffsetSorter(BlogConstants.FieldNames.ArticleDate, Direction.Ascending),
            BlogConstants.SortOptions.Relevant when !string.IsNullOrWhiteSpace(query) => new ScoreSorter(Direction.Descending),
            _ => new DateTimeOffsetSorter(BlogConstants.FieldNames.ArticleDate, Direction.Descending)
        };
    }

}

The service uses ISearcherResolver to get Umbraco's published content searcher, then calls SearchAsync with three things:

  • Filters, a KeywordFilter restricts results to articles only (by content type ID), and another excludes any articles where isHidden is 1. If a category is selected, a third filter matches on the articleCategories field.

  • Facets, a KeywordFacet on articleCategories tells Examine to return counts per category value alongside the results. These are snapshotted and passed to GetCategoryFacetsAsync so no second query is needed.

  • Sorters, sort by articleDate descending (recent), articleDate ascending (oldest), or ScoreSorter descending when there is a keyword query and relevant is selected.

The document IDs from the index are resolved back to IPublishedContent instances via the published content cache so the view gets full strongly-typed content objects.

Indexing Notification Handler

This is the part that took me the longest to figure out. Umbraco Search indexes your content out of the box, but for filtering and faceting on your own custom fields you need to give it a hand.

On my site, each article has a property called categories which is a multinode tree picker. Each category document has a shortName property like umbraco, dotnet, or csharp. I wanted to filter and facet on those short names rather than UDIs or GUIDs, because they make for much nicer URLs like /blog/?category=umbraco.

To do that, I hook into the ContentIndexingNotification and write a custom articleCategories keyword field onto each document at index time.

ArticleIndexingNotificationHandler.cs

using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Search.Core.Models.Indexing;
using Umbraco.Cms.Search.Core.Notifications;
using Umbraco.Cms.Web.Common.PublishedModels;
using SearchConstants = Umbraco.Cms.Search.Core.Constants;

namespace UmbracoProject.Composers;

/// <summary>
/// Hooks into the Umbraco Search <see cref="ContentIndexingNotification"/> to enrich article documents
/// with a custom articleCategories keyword field containing the short name aliases of each linked
/// category node, and an isHidden keyword field for nav-hide filtering.
///
/// Uses <see cref="IContentService"/> instead of the published content cache so that category data
/// is available during bulk index rebuilds — the published content cache may not be fully populated
/// while the indexer is running, which would cause documents to be indexed without the isHidden /
/// articleCategories fields and silently disappear from search results.
/// </summary>
public class ArticleIndexingNotificationHandler
    : INotificationAsyncHandler<ContentIndexingNotification>
{
    private readonly IContentService _contentService;
    private readonly ILogger<ArticleIndexingNotificationHandler> _logger;

    public ArticleIndexingNotificationHandler(
        IContentService contentService,
        ILogger<ArticleIndexingNotificationHandler> logger)
    {
        _contentService = contentService;
        _logger = logger;
    }

    public Task HandleAsync(
        ContentIndexingNotification notification,
        CancellationToken cancellationToken)
    {
        try
        {
            // Only handle the published content index
            if (notification.IndexAlias != SearchConstants.IndexAliases.PublishedContent)
                return Task.CompletedTask;

            var content = _contentService.GetById(notification.Id);
            if (content?.ContentType.Alias is not (Article.ModelTypeAlias or Content.ModelTypeAlias))
                return Task.CompletedTask;

            // Add a searchable field indicating whether this item is hidden from navigation
            var isNaviHide = content.GetValue<bool>(BlogConstants.FieldNames.UmbracoNaviHide);
            notification.Fields = notification.Fields
                .Union([new IndexField(
                    FieldName: BlogConstants.FieldNames.IsHidden,
                    Value: new IndexValue { Keywords = [isNaviHide ? BlogConstants.HiddenValues.True : BlogConstants.HiddenValues.False] },
                    Culture: null,
                    Segment: null)])
                .ToArray();

            // Read the raw categories property value (comma-separated UDIs from MNTP)
            var categoriesValue = content.GetValue<string>(BlogConstants.FieldNames.Categories);
            if (string.IsNullOrWhiteSpace(categoriesValue))
                return Task.CompletedTask;

            // Parse UDIs to GUIDs and resolve each category node to get its shortName
            var categoryGuids = categoriesValue
                .Split(',', StringSplitOptions.RemoveEmptyEntries)
                .Select(v => UdiParser.TryParse(v.Trim(), out Udi? udi) && udi is GuidUdi guidUdi ? guidUdi.Guid : (Guid?)null)
                .Where(g => g.HasValue)
                .Select(g => g!.Value)
                .ToArray();

            var categoryAliases = categoryGuids
                .Select(guid =>
                {
                    var categoryNode = _contentService.GetById(guid);
                    if (categoryNode is null) return null;
                    var shortName = categoryNode.GetValue<string>(BlogConstants.FieldNames.ShortName);
                    return shortName?.Trim().ToLowerInvariant() ?? Slugify(categoryNode.Name);
                })
                .Where(s => !string.IsNullOrEmpty(s))
                .Cast<string>()
                .ToArray();

            if (categoryAliases.Length == 0)
                return Task.CompletedTask;

            _logger.LogDebug(
                "Adding articleCategories field with {Count} categories: {Categories} for article {Id}",
                categoryAliases.Length, string.Join(", ", categoryAliases), notification.Id);

            // Add the article categories as a keyword field so it supports both exact-match
            // filtering (KeywordFilter) and faceted counts (KeywordFacet).
            notification.Fields = notification.Fields
                .Union([new IndexField(
                    FieldName: BlogConstants.FieldNames.ArticleCategories,
                    Value: new IndexValue { Keywords = categoryAliases },
                    Culture: null,
                    Segment: null)])
                .ToArray();

            return Task.CompletedTask;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An error occurred while enriching the index document for content {Id}.", notification.Id);
            return Task.CompletedTask;
        }
    }

    private static string Slugify(string? name) =>
        System.Text.RegularExpressions.Regex.Replace(
            (name ?? string.Empty).Trim().ToLowerInvariant(), @"[^a-z0-9]", "");
}

A few things worth pointing out.

I'm using IContentService rather than the published content cache here. That is deliberate. When you rebuild the index from the backoffice, the published content cache is not always in the state you'd expect, and you can end up with empty category data. IContentService reads straight from the database and always gives you what's there.

The MNTP stores its value as a comma-separated string of UDIs, so the UDI parsing step turns each one into a GUID. From the GUID I look up the category node and read the shortName property.

If for some reason a category doesn't have a shortName set, there's a Slugify fallback that uses the node name. That way the index still gets something usable rather than silently skipping the category.

Everything is lowercased before it goes into the index because keyword matching is case sensitive. If you index CSharp and then filter on csharp, you'll get nothing back.

isHidden is written as "1" or "0" rather than a bool because the filter on the search service side uses KeywordFilter, which matches on string values.

One thing that caught me out is that the notification handler only runs when content is published or when the index is rebuilt. If you change the logic in here and just refresh the page, nothing will change. You need to go to the backoffice and rebuild the index from the new Umbraco Search settings dashboard. I lost a good half hour wondering why my tweaks weren't taking effect before I remembered that.

Field Options

For Examine to know how to store and query the custom fields, you have to declare them. This is where FieldOptions comes in.

ConfigureArticleCategoriesFieldOptions.cs

using Microsoft.Extensions.Options;
using Umbraco.Cms.Search.Provider.Examine.Configuration;

namespace UmbracoProject.Composers;

/// <summary>
/// Registers custom fields in <see cref="FieldOptions"/> for the Umbraco Search Examine indexer:
/// - <c>articleCategories</c> as a facetable keyword field for category filtering
/// - <c>articleDate</c> as a sortable date field for date-based ordering
/// </summary>
public class ConfigureArticleCategoriesFieldOptions : IConfigureOptions<FieldOptions>
{
    public void Configure(FieldOptions options)
    {
        var existing = options.Fields?.ToList() ?? [];
        existing.Add(new FieldOptions.Field
        {
            PropertyName = BlogConstants.FieldNames.ArticleCategories,
            FieldValues = FieldValues.Keywords,
            Facetable = true
        });
        existing.Add(new FieldOptions.Field
        {
            PropertyName = BlogConstants.FieldNames.ArticleDate,
            FieldValues = FieldValues.DateTimeOffsets,
            Sortable = true
        });
        options.Fields = existing.ToArray();
    }
}

Two fields are declared. articleCategories is a keyword field marked as facetable, which is what lets the search service request facet counts on it. articleDate is a date field marked as sortable, which is what powers the recent and oldest sort options.

Notice the pattern of reading the existing fields into a list, adding to it, and assigning it back. That matters because other parts of Umbraco might be adding their own fields and you don't want to overwrite them.

If you forget this step, your filters and facets will silently return nothing. I know, because I forgot it the first time round.

The view model

The view model carries the search results, facets, and URL-building logic from the controller to the view.

BlogListingViewModel.cs

using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Web.Common.PublishedModels;

namespace UmbracoProject.Models;

public class BlogListingViewModel : ContentModel
{
    public BlogListingViewModel(IPublishedContent content) : base(content) { }

    public Blog Blog => (Blog)Content;
    public ArticleSearchResults SearchResults { get; init; } = ArticleSearchResults.Empty;
    public IReadOnlyList<CategoryFacet> Facets { get; init; } = [];
    public string Query { get; init; } = string.Empty;
    public string Category { get; init; } = string.Empty;
    public string Sort { get; init; } = BlogConstants.SortOptions.Default;
    public string BlogPath { get; init; } = BlogConstants.BlogPath;
    public int Page { get; init; } = BlogConstants.DefaultPage;
    public int PageSize { get; init; } = BlogConstants.DefaultPageSize;
    public int TotalPages => (int)Math.Ceiling((double)SearchResults.Total / PageSize);

    public bool HasQuery => !string.IsNullOrEmpty(Query);
    public bool HasCategory => !string.IsNullOrEmpty(Category);
    public bool HasSort => Sort != BlogConstants.SortOptions.Default;

    private HashSet<string> SelectedCategories =>
        new(Category.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
            StringComparer.OrdinalIgnoreCase);

    public string BlogUrl(string? toggleCat = null, bool clearCat = false, bool clearQuery = false)
    {
        var parts = new List<string>();
        if (!clearQuery && HasQuery) parts.Add($"{BlogConstants.QueryParameters.Query}={Uri.EscapeDataString(Query)}");

        if (!clearCat)
        {
            var cats = SelectedCategories;
            if (toggleCat != null)
            {
                if (!cats.Remove(toggleCat))
                    cats.Add(toggleCat);
            }
            if (cats.Count > 0)
                parts.Add($"{BlogConstants.QueryParameters.Category}={string.Join(",", cats)}");
        }

        if (HasSort) parts.Add($"{BlogConstants.QueryParameters.Sort}={Sort}");
        return parts.Count > 0 ? $"{BlogPath}?{string.Join("&", parts)}" : BlogPath;
    }

    public string PageUrl(int page)
    {
        var parts = new List<string>();
        if (HasQuery) parts.Add($"{BlogConstants.QueryParameters.Query}={Uri.EscapeDataString(Query)}");
        if (HasCategory) parts.Add($"{BlogConstants.QueryParameters.Category}={Category}");
        if (HasSort) parts.Add($"{BlogConstants.QueryParameters.Sort}={Sort}");
        if (page > BlogConstants.DefaultPage) parts.Add($"{BlogConstants.QueryParameters.Page}={page}");
        return parts.Count > 0 ? $"{BlogPath}?{string.Join("&", parts)}" : BlogPath;
    }
}

The interesting bit is BlogUrl. It supports multi-select categories by toggling a value in and out of the selection. When you click a category that's already selected, it gets removed. When you click a new one, it gets added to whatever is already selected. The selected values are stored as a comma-separated string in the category query string parameter, so the URLs stay clean and bookmarkable.

PageUrl is a simpler helper that just preserves the current search, category, and sort when you click a pagination link.

HasSort returns false when the sort is recent, which is the default. That stops sort=recent cluttering up the URL when it doesn't need to be there.

The results come back in an ArticleSearchResults record, with a couple of supporting facet records:

ArticleSearchResults.cs

using Umbraco.Cms.Core.Models.PublishedContent;

namespace UmbracoProject.Models;

public class ArticleSearchResults
{
    public IReadOnlyList<IPublishedContent> Items { get; init; } = [];
    public long Total { get; init; }
    public IReadOnlyList<FacetResultSnapshot> FacetResults { get; init; } = [];

    public static readonly ArticleSearchResults Empty = new();
}

/// <summary>Snapshot of a single facet result returned from the search index.</summary>
public record FacetResultSnapshot(string FieldName, IReadOnlyList<FacetValueSnapshot> Values);

/// <summary>A single keyword facet value (alias + count).</summary>
public record FacetValueSnapshot(string Key, long Count);

/// <param name="Name">Human-readable label shown in the UI, e.g. ".NET".</param>
/// <param name="Alias">Slug used in data-search-filter and data-categories, e.g. "dotnet".</param>
/// <param name="IsSelected">True when this facet matches the current ?category= query param.</param>
/// <param name="Count">Number of published articles tagged with this category.</param>
public record CategoryFacet(string Name, string Alias, bool IsSelected, int Count = 0, string? Colour = null);

FacetResultSnapshot and FacetValueSnapshot are a neutral representation of what comes back from the search index. CategoryFacet is the view-friendly version with the display name, whether it's currently selected, and an optional colour for the UI.

The view

The view is split across two files. Blog.cshtml is the entry point that sets up the layout and adds JSON-LD schema for SEO. The heavy lifting is done in a partial at Views/Partials/layout/blogListing.cshtml. Splitting them keeps the partial reusable.

Here are the bits of the partial that matter most:

blogListing.cshtml

@using UmbracoProject.Extensions
@using Umbraco.Cms.Web.Common.PublishedModels
@using UmbracoProject.Models
@model BlogListingViewModel

@{
    var q = Model.Query;
    var category = Model.Category;
    var sort = Model.Sort;
    var hasQuery = Model.HasQuery;
    var hasCategory = Model.HasCategory;
    var hasSort = Model.HasSort;
    var blogPath = Model.BlogPath;
}

<form method="get" action="@blogPath" role="search" class="search-form" aria-label="Search articles">
    @if (hasCategory) { <input type="hidden" name="category" value="@category"> }
    @if (hasSort)     { <input type="hidden" name="sort"     value="@sort"> }
    <div class="search-input-wrap">
        <label for="blog-search-input" class="sr-only">Search articles</label>
        <input id="blog-search-input"
               type="search"
               name="q"
               value="@q"
               placeholder="Search articles…"
               autocomplete="off"
               class="search-field"
               aria-label="Search articles"
               aria-controls="blog-results">
        <button type="submit" class="search-submit btn btn-primary">
            <svg width="16" height="16" aria-hidden="true" viewBox="0 0 24 24" fill="none"
                 stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
            </svg>
            Search
        </button>
        @if (hasQuery)
        {
            <a href="@Model.BlogUrl(clearQuery: true)" class="search-clear btn btn-outline">Clear</a>
        }
    </div>
</form>

The search form is a plain old GET form that posts back to the blog URL. No JavaScript needed. The current category and sort are kept as hidden fields so searching within a category or sort order keeps you in that context. The clear button uses BlogUrl(clearQuery: true) to strip the query while preserving everything else.

The category sidebar builds the filter buttons from the facets:

blogListing.cshtml

@using UmbracoProject.Models
@model BlogListingViewModel

@{
    var hasCategory = Model.HasCategory;
    var facets = Model.Facets;
}

<a href="@Model.BlogUrl(clearCat: true)"
   data-filter="all"
   class="nb-filter-btn@(!hasCategory ? " active" : "")"
   aria-current="@(!hasCategory ? "true" : "false")">
    All Posts
</a>
@foreach (var facet in facets.Where(f => f.Count > 0 || f.IsSelected))
{
    var activeStyle = facet.IsSelected && !string.IsNullOrEmpty(facet.Colour)
        ? $"background: {facet.Colour}; color: white; border-color: {facet.Colour};"
        : null;
    <a href="@Model.BlogUrl(toggleCat: facet.Alias)"
       data-filter="@facet.Alias"
       class="nb-filter-btn@(facet.IsSelected ? " active" : "")"
       aria-current="@(facet.IsSelected ? "true" : "false")"
       style="@activeStyle">
        <svg class="nb-filter-ico" aria-hidden="true"><use href="#[email protected]" /></svg>
        @facet.Name
        <span class="nb-filter-count">@facet.Count</span>
    </a>
}

Each button uses BlogUrl(toggleCat: facet.Alias) so clicking it adds or removes that category from the current selection. The count next to each name comes straight from the facet results, which is why the ExpandFacetValues setting in the composer is so important. Without it, the counts would only reflect whatever is already filtered.

The sort dropdown is another tiny GET form that auto-submits when you change the selection:

blogListing.cshtml

@using UmbracoProject.Models
@model BlogListingViewModel

@{
    var q = Model.Query;
    var category = Model.Category;
    var sort = Model.Sort;
    var hasQuery = Model.HasQuery;
    var hasCategory = Model.HasCategory;
    var blogPath = Model.BlogPath;
}

<form class="nb-sort-form" method="get" action="@blogPath" aria-label="Sort posts">
    @if (hasQuery)    { <input type="hidden" name="q"        value="@q"> }
    @if (hasCategory) { <input type="hidden" name="category" value="@category"> }
    <label for="blog-sort" class="nb-sort-label">Sort</label>
    <select id="blog-sort" name="sort" class="nb-sort-select" onchange="this.form.submit()">
        <option value="recent" selected="@(sort == "recent" ? "selected" : null)">Most recent</option>
        <option value="oldest" selected="@(sort == "oldest" ? "selected" : null)">Oldest first</option>
        <option value="relevant" selected="@(sort == "relevant" ? "selected" : null)">Most relevant</option>
    </select>
    <noscript><button type="submit" class="btn btn-outline">Apply</button></noscript>
</form>

There's a <noscript> fallback with a submit button, so if JavaScript is off, the sort still works. Little things like that are worth doing.

The grid uses a helper class to attach category data attributes and colour styles to each card, and pagination uses PageUrl to build links that preserve everything else in the URL. I've also added an ellipsis pattern in the pagination so when there are lots of pages you don't end up with a huge row of page number links.

For accessibility, the filter status has an aria-live="polite" region that announces the number of results whenever the query or filter changes. If you're building anything with filtering, I'd really encourage adding something like this. It costs almost nothing and makes the page usable for people on screen readers.

Lessons learned

A few things I picked up along the way that might save you some time:

  • Rebuild the index after changing the notification handler. The handler only runs at index time, so changes to its logic need a rebuild from the backoffice before anything will change.

  • Use IContentService in the notification handler. The published content cache can be unreliable during a full index rebuild. IContentService is slower but dependable.

  • Lowercase your keyword values. Keyword matches are case sensitive. Lowercase what you index and what you read from the query string.

  • Turn on ExpandFacetValues from the start. You almost always want this for category filters with counts that stay accurate when a filter is applied.

  • Don't skip the FieldOptions registration. Filters and facets silently return nothing if the field isn't declared as facetable or sortable.

  • Start simple. I built it with just a search box first, then added category filters, then facets with counts, then sort, then pagination. Trying to do it all at once would have made debugging a nightmare.

Further reading

Kenn Jacobsen has been writing and speaking about the new search in more detail than I can fit in one post. Check out his blog for more examples.

The official Umbraco docs for the search packages are also a good read, especially the sections on filters, facets, and custom fields.

If you're building something similar and get stuck, come and find me on the Umbraco Community Discord. There's a helpful crowd in there and Kenn himself is usually around.

// Further reading

Learn more about Umbraco Search

Check out the official docs and Kenn's website for more information about Umbraco Search

Pictures from Umbraco Spark

As promised, here are a few pictures from the day. It was great to see so many familiar faces and meet some new ones too. If you've never been to Spark, I'd recommend putting it on your list for next year.

Day 1 - Hackathon and pre-party

Sample photo 5
How I used the new Umbraco Search on my website
Great turnout for the hackathon
Sample photo 5
How I used the new Umbraco Search on my website
People playing pool at the pre-party
Sample photo 5
How I used the new Umbraco Search on my website
Bowling at the pre-party
Sample photo 5
How I used the new Umbraco Search on my website
More pool at the pre-party

Day 2 - The main event and post party

Sample photo 5
How I used the new Umbraco Search on my website
Morning audience side view
Sample photo 5
How I used the new Umbraco Search on my website
Umbraco Spark 2026 Programme
Sample photo 5
How I used the new Umbraco Search on my website
People enjoying a break on the balcony
Sample photo 5
How I used the new Umbraco Search on my website
Break time networking
Sample photo 5
How I used the new Umbraco Search on my website
Packed audience for Kenn's talk
Sample photo 5
How I used the new Umbraco Search on my website
Me with some of the members of the Umbraco in AI Community Team
Sample photo 5
How I used the new Umbraco Search on my website
Introducing the Umbraco UK Foundation Ambassadors
Sample photo 5
How I used the new Umbraco Search on my website
A touching tribute to Terence Burridge
Sample photo 5
How I used the new Umbraco Search on my website
Gibe team demoing their winning package
Sample photo 5
How I used the new Umbraco Search on my website
Venue for the post event drinks and networking
Sample photo 5
How I used the new Umbraco Search on my website
Post event drinks and networking
Sample photo 5
How I used the new Umbraco Search on my website
The colourful big wheel
Sample photo 5
How I used the new Umbraco Search on my website
Emma Burstow being funny

Let me know what you think

Thanks for reading, I would love to know what you think of this post, and about Umbraco Search and Umbraco Spark. Let me know in the comments below.

Comments and reactions