hear what the community is talking about

Community Blogs

We’ve gathered blog posts from across the internet to highlight the many voices that make up our community. Powered by Umbraco, this space brings together diverse stories, ideas, and perspectives in one easy‑to‑explore hub. Dive in and discover what the community is creating, sharing, and talking about.

Want to add your future blog posts to the list? Submit it here!

Umbraco

Examine v4 is out

Examine v4 is out Examine v4.0.0 has been released to NuGet! It's been in beta since October 2023, so it feels great to finally have this one out the door. The headline feature is faceting, which people have been asking me about for years, along with taxonomy indexes, deep paging, and a fair amount of modernization of the codebase. What took so long The first facet commit landed in November 2022 from Nikolaj Brask-Nielsen who did the original implementation, and then Chad Currie built taxonomy index support, deep paging and a lot of the surrounding infrastructure on top of that. Between them that's well over a hundred commits of genuinely hard Lucene work, and honestly the community contributions on this release have been fantastic. After that it sat in beta across nine releases. Part of that is simply that Examine is a side project I maintain around a full time job, and nothing was broken enough in the betas to force the issue. The other part is that Examine v4 is built on Lucene.NET 4.8, which is itself still in beta, and I didn't want to ship a final release that depended on a beta of something else. So the plan was to wait for Lucene.NET 4.8 to go stable first. Lucene.NET 4.8.0-beta00001 was released in May 2017, and the last stable Lucene.NET release was 3.0.3 back in 2012, so in hindsight that was a fairly optimistic plan 😊 That's not a criticism by the way - porting Lucene to .NET is an enormous amount of work done by volunteers and the project is very much active. beta00018 shipped in June this year and the hope is for a final 4.8 release before the end of the year. In practice these betas have been running in production on a very large number of Umbraco sites for years now, so the beta label was never really the risk that it sounds like. What changed is that Umbraco needed v4. What's new in v4 Faceting is the main one. You declare facet fields in your field definitions like any other field, request facets on a query with WithFacets(...), and read them back with GetFacet(): var results = searcher.CreateQuery("content") .Field("nodeName", "product") .WithFacets(facets => facets.FacetString("category")) .Execute(); foreach (var value in results.GetFacet("category")!) { Console.WriteLine($"{value.Label}: {value.Value}"); } There are facet types for full text, numerics, dates and ranges, and each has a taxonomy variant. Being able to do this natively in Examine, rather than dropping down to Lucene yourself, is the bit I'm most pleased about in this release. The rest of it: Taxonomy indexes - a sidecar index enabled with UseTaxonomyIndex, which gives you hierarchical facets and faster faceting generally. This started out as a separate index type and ended up merged into LuceneIndex, which is a much better place for it. Deep paging - SearchAfterOptions lets you page a long way into a result set without Lucene having to materialize everything up to your offset. It works with faceted queries too. Boost factors and phrase queries - WithBoost() on individual search values, and a proper Phrase() instead of the old escaped value approach. Nullable reference types throughout, with warnings as errors, so every public API is annotated. You'll likely get some new warnings when you upgrade, all of which were true before, you just couldn't see them. .NET 8, 9 and 10. .NET 6 support is gone. The replicator has also had a lot of work done to it. Taxonomy replication is handled properly, transient lock failures are retried instead of thrown, and persistent commit failures now surface through IsReplicationHealthy instead of being silently swallowed. If you're running Examine on Azure App Service, or anything else with a network file system, that's the part that matters to you. There are breaking changes and they're mostly constructors. The release notes have a summary and the full API change report lists every one of them. Umbraco and the new search abstraction Umbraco HQ have been rebuilding search from the ground up in Umbraco.Cms.Search, based on the Future of Search RFC written by Bjarke Berg and Kenn Jacobsen. It's a provider based abstraction, so in principle you can put Elasticsearch, Algolia or anything else behind it. For the initial release there is one provider, and it's Examine. From the RFC: We plan to ship a single implementation of the search abstraction, which will be based on Examine to be backward compatible. Their README says it will replace the current search implementation "at the earliest starting from Umbraco v19" so I'm not going to put a date on that, but it ships as an add-on for v17 and v18 first and Examine is what sits underneath it either way. Which meant that v4 suddenly had a real consumer with real deadlines, and a beta wasn't going to cut it any more. It's a nice position to be in though - Examine has been quietly doing its job under Umbraco for years, and it's great to see it become the foundation for the new search stack too. Backwards compatibility Most of the work in the final stretch wasn't features at all, it was making sure v4 could be a drop-in replacement for v3. Examine underpins search in every Umbraco install, so telling everyone to upgrade and fix up their code isn't a realistic answer. I spent a good while on this with Kenn Jacobsen and Nikolaj Geisle at Umbraco HQ - they would run v4 against the CMS, something would break, and we'd work out whether it was a genuine bug, an API I'd removed too eagerly, or something that needed a compatibility shim. Then repeat. There was a lot of testing on both sides of this. A good example of the kind of thing that catches you out: I had removed some old positional overloads of AddExamineLuceneIndex and replaced them with optional parameters. That's source compatible, so anything recompiling against v4 is fine. But optional parameters are a compile time feature, and Umbraco's already published DLLs still had IL referencing the original method signatures, so it fell over on startup with a MissingMethodException. Source compatible, binary incompatible, and the only way to find it is to actually run it. Automating the compatibility testing Doing that verification manually over and over is pretty tedious, so I stopped doing it manually. I wrote a set of Copilot skills that live in the Examine repo and automate the whole loop - clone the downstream consumer, rewrite its Examine NuGet package references into project references pointing at my local working copy, build it, run its tests, and report back exactly what broke. There's one each for Umbraco CMS, Umbraco.Cms.Search and ExamineX, plus a compat validator agent that picks the right one and drives it. That turned "have I broken Umbraco?" from an afternoon's work into something I could run as a routine check while working on a branch. Combined with the Roslyn public API analyzer, which tracks every public member in a checked-in file and fails the build when one disappears, breaking changes had to be a deliberate decision rather than something discovered later by somebody else. If you maintain a library with downstream consumers you care about, this is honestly worth setting up. It's a lot more approachable to build than it was even a year ago, and it takes a whole category of worry off your plate. Umbraco.Cms.Search is currently pinned to 4.0.0-beta.9 and can now move to the final release. Documentation The documentation site hadn't been published since March 2023. Not for lack of anyone writing any - the publishing pipeline itself had quietly been broken for years. It only triggered on a branch nobody was pushing to, it used versions of the GitHub Pages actions that have since been removed, and it built with .NET 6, which can't compile a project targeting .NET 8 or later. So documentation kept getting written and committed and never actually went anywhere. That's all fixed now and the docs rebuild and publish automatically on every push, which means about three years of writing has gone live in one go. Faceting, taxonomy indexes, deep paging and replication are all documented, so it's well worth a look if the last version you saw was the old site. A number of the older code samples turned out to be wrong as well, including every faceting example, which called a method that doesn't exist. This time around every sample was compiled against the real v4 assemblies rather than just eyeballed, which is how those got caught. The public API change report is published there too - 246 additions, 19 changed signatures and 14 removals between v3.10.0 and v4.0.0. It's generated from the API surface files that the analyzer keeps in the repo rather than written by hand, so it's the complete picture rather than what I remembered to write down. What this means for ExamineX I've been asked this a few times now so it's worth spelling out. ExamineX is my commercial product that swaps Examine's Lucene implementation for Azure AI Search or Elasticsearch, so that you don't have to deal with file locks, index corruption and index rebuilds on Azure App Service. It has always been an implementation of the Examine abstractions rather than a fork or a competing API. That's the important part here. Umbraco Search has a provider model and its default provider talks to Examine. Examine has its own implementation model underneath that, and that's the layer ExamineX plugs into. They're two separate seams stacked on top of each other, so ExamineX continues to work under Umbraco Search without needing to be an Umbraco Search provider at all - it just replaces what sits underneath the Examine one. So the new Umbraco search stack doesn't replace ExamineX, it sits on top of the same abstraction it always has. The v4 support including faceting is already written, I just haven't cut the release yet - that's next on my list. Upgrading If you're on v3 and not using Umbraco then it should be reasonably straight forward. Retarget to net8.0 or later, rebuild, work through whatever the nullable annotations tell you, and check the API change report if you were constructing searchers or directory factories by hand. If you're on Umbraco, wait for Umbraco to pull it in rather than upgrading Examine underneath it yourself. Everything is on GitHub and issues and PRs are always welcome. Give it a go! Huge thanks to Chad, both Nikolajs and Kenn, and to everyone who ran the betas in production and told me what broke. Seventeen years in and Examine is in the best shape it's ever been, which is a pretty good feeling 🙂

by Shanon Thompson (Deminick)

Upgrading Umbraco 13 to 17: What We Learned

What we learned upgrading a large production website from Umbraco 13 to 17, covering migrations, APIs, AngularJS, property editors, Forms, content and deployment.

by David Whalley

Umbraco on DDEV: .NET, SQL Server, and the generic project type

An experiment in running Umbraco CMS and .NET 10 under DDEV, using the generic project type, a custom web image, and Azure SQL Edge.

by Lee Mills

Building a Cloudflare Integration for Umbraco Automate

When I first started experimenting with Umbraco Automate, I wanted to find a real-world use case instead of creating an integration just for the sake of trying the API. It didn't take long to find one. On several Umbraco projects, we use Cloudflare in front of the website. When content changes, there are situations where we want to invalidate the cached version of the affected page. Traditionally, I would solve this in code: Content published ↓ Notification handler ↓ Determine published URLs ↓ Call Cloudflare API ↓ Purge cache That works. But after looking at Automate, I started wondering: What if the application doesn't need to know anything about Cloudflare at all? Instead, publishing content could simply trigger an automation, with small reusable actions taking care of the individual steps. That idea eventually resulted in three NuGet packages: Umbraco.Community.Automate Umbraco.Community.Automate.Extensions Umbraco.Community.Automate.Cloudflare All currently available as version 17.0.0 for Umbraco 17. This post describes how I got there, some of the problems I encountered, and what I learned about extending Umbraco Automate along the way. Starting with the obvious solution My first idea was simple. Umbraco Automate already provides a Content Published trigger, so I wanted to create a Cloudflare action that could take the published page and purge it from Cloudflare. Something like: Content Published ↓ Purge Cloudflare The Cloudflare API itself makes this fairly straightforward.To purge specific URLs you can send a request to: POST /client/v4/zones/{zoneId}/purge_cache with: { "files": [ "https://www.example.com/some-page/" ] } So initially I thought the Cloudflare action could simply accept the URL of the published page. The Content Published trigger doesn't provide a URL. It exposes information such as: contentKey contentName contentTypeKey contentTypeAlias cultures And actually, that makes sense. A URL isn't necessarily a property of the publish event. A content item doesn't necessarily have one URL This became the first interesting part of the implementation. Consider a multilingual Umbraco website: https://www.example.com/ https://www.example.com/en/ Now add multiple hostnames, which is perfectly valid in an Umbraco installation: https://www.example-a.com/ https://www.example-a.com/en/ https://www.example-b.com/ https://www.example-b.com/en/ For a single content item, asking: What is the URL? is therefore not always the right question. The better question is: What are the published URLs for this content item and culture? That distinction ended up having quite a large influence on the package design. Separating Umbraco from Cloudflare My first instinct was to put the URL resolution inside the Cloudflare action. The action could receive: Content Key Culture and internally: Resolve the Umbraco content. Determine its URLs. Call Cloudflare. Purge those URLs. But that didn't feel right. Resolving published URLs has nothing to do with Cloudflare. The same functionality could be useful when: notifying an external API; updating a search index; sending a webhook; invalidating another CDN; generating a sitemap; or building another Automate workflow. So instead I created a generic action: Get Published Content URLs and moved it into a separate package: Umbraco.Community.Automate.Extensions Cloudflare became its own integration: Umbraco.Community.Automate.Cloudflare This allows Automate to do what it is good at: composing small pieces of functionality. The workflow becomes: Content Published ↓ For Each Culture ↓ Get Published Content URLs ↓ Purge Cloudflare URLs That separation is probably the design decision I'm happiest with. Building Get Published Content URLs The generic action receives a content key and culture. Its settings are roughly: public sealed class GetPublishedContentUrlsSettings { public Guid ContentKey { get; set; } public string Culture { get; set; } = string.Empty; } The output deliberately contains an array: public sealed class GetPublishedContentUrlsOutput { public Guid ContentKey { get; set; } public string Culture { get; set; } = string.Empty; public string[] Urls { get; set; } = []; } Notice that it's Urls, not Url. This is important for installations where a culture is available through multiple domains. Conceptually the action does: Content Key + Culture ↓ Umbraco published content ↓ Primary + alternative URLs ↓ string[] That output can then be consumed by any other Automate action. Lesson learned: be careful with service lifetimes While building the action I ran into another useful lesson. My first implementation injected IPublishedContentQuery directly into the action. That resulted in: Cannot consume scoped service 'Umbraco.Cms.Core.IPublishedContentQuery' from singleton 'GetPublishedContentUrlsAction'. Automate actions are registered as singletons, while several Umbraco services used for published content are scoped. Injecting one directly into a singleton therefore isn't safe. Instead, the action needs to work with the appropriate Umbraco context/service scope when resolving published content. It's an easy mistake to make when building your first Automate extension because the action itself looks very similar to a normal service. The important takeaway: Always consider the lifetime of the services you inject into an Automate action. Cultures introduced another interesting problem The Content Published trigger exposes cultures. Not culture. That means a single publish operation can involve multiple cultures. The obvious workflow is therefore: Content Published ↓ For Each ↓ Get Published Content URLs The collection for the loop is: ${ trigger.cultures } And inside the loop the current culture is: ${ loop.item } Initially I accidentally passed: ${ trigger.cultures } directly into the action's Culture field. That resulted in something like: ["es"] being passed as the culture instead of: es and consequently: Content '<guid>' is not published for culture '["es"]'. The correct pattern is: For Each: ${ trigger.cultures } Get Published Content URLs: Content Key: ${ trigger.contentKey } Culture: ${ loop.item } A small mistake, but a good example of why understanding the difference between a collection binding and the current loop item matters. Then I wanted to purge multiple URLs at once Initially the Cloudflare package had: Purge Cloudflare URL with: public string Url { get; set; } = string.Empty; This worked perfectly when combined with another For Each: Get Published Content URLs ↓ For Each URL ↓ Purge Cloudflare URL But Cloudflare already accepts multiple URLs in one request. So why make one HTTP request per URL? I changed the action to: Purge Cloudflare URLs and the Cloudflare client now accepts a collection: Task PurgeUrlsAsync( string apiToken, string zoneId, IEnumerable<string> urls, CancellationToken cancellationToken = default); The resulting request can contain all URLs: { "files": [ "https://www.example-a.com/en/", "https://www.example-b.com/en/" ] } This makes the workflow much nicer: Get Published Content URLs ↓ Purge Cloudflare URLs At least, that was the idea. And then I learned something interesting about Automate bindings. Binding an array isn't the same as binding a string My first settings model looked perfectly reasonable: public sealed class PurgeUrlsSettings { public string[] Urls { get; set; } = []; } The previous action already returned: string[] Urls so I expected this binding to work: ${ previous.urls } Instead Automate failed before the action was even executed: Failed to resolve model 'community.cloudflare.purgeUrls' to type PurgeUrlsSettings. The JSON value could not be converted to System.String[]. That was confusing at first because the source value really was a string[]. The important detail is how action settings are stored. The binding itself is initially represented as: { "urls": "${ previous.urls }" } That's a string expression. If the property is already declared as string[], JSON deserialization needs to happen before that expression can resolve to its collection value. So: "${ previous.urls }" ↓ Deserialize as string[] ↓ 💥 Looking at For Each provided the answer The interesting thing was that this already worked: Get Published Content URLs ↓ For Each Collection: ${ previous.urls } So Automate clearly could consume my array. That led me into the Automate source code. The built-in For Each doesn't define its collection setting as an array. Instead, conceptually it does this: public string Collection { get; set; } = string.Empty; The setting contains the binding expression, not the resolved collection. The expression is evaluated later at runtime. That same approach works nicely for the Cloudflare action. The setting can remain: public sealed class PurgeUrlsSettings { public string Urls { get; set; } = string.Empty; } and the configured value remains: ${ previous.urls } At runtime, Automate's BindingEvaluator can evaluate the raw expression: var value = _bindingEvaluator.EvaluateRaw( settings.Urls, context.BindingData ?? new Dictionary<string, object?>()); The action can then turn that resolved value into the collection it needs. This was probably the most interesting technical lesson from building the package. Sometimes the type of a configuration property shouldn't represent the type of the eventual runtime value. In this case: Setting string expression ↓ BindingEvaluator ↓ runtime collection ↓ Cloudflare API is a better model. Normalizing the URLs Before sending anything to Cloudflare, the action normalizes the collection: urls = urls .Where(url => !string.IsNullOrWhiteSpace(url)) .Select(url => url.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); Then it verifies that something remains: if (urls.Length == 0) { return ActionResult.Failed( new ArgumentException( "At least one URL is required.")); } and validates the URLs: var invalidUrl = Array.Find( urls, url => !Uri.TryCreate( url, UriKind.Absolute, out _)); if (invalidUrl is not null) { return ActionResult.Failed( new ArgumentException( $"'{invalidUrl}' is not a valid absolute URL.")); } Only after that does the action call Cloudflare. Creating a reusable Cloudflare connection I didn't want API tokens and zone IDs to be configured on every action. Automate has a connection concept for exactly this purpose, so the Cloudflare package provides its own connection type. The connection contains information such as: Account ID Zone ID API Token The API token can also be validated against Cloudflare. Cloudflare provides an endpoint for checking whether a token is valid and active, which makes the connection test much more useful than simply checking whether a value was entered. A successful response looks roughly like: { "result": { "status": "active" }, "success": true } This gives users feedback while configuring the connection instead of waiting for their first automation run to fail. Keep API tokens scoped For the Cloudflare API token, I recommend granting only the permissions required by the workflow. For cache purging, don't use a token with unnecessary account-wide permissions. The connection should know: API Token Zone ID and the action should only perform the operation it was designed for. It's a small detail, but integrations like these make security boundaries very visible. The resulting packages The experiment eventually became three packages. Umbraco.Community.Automate The convenience package. Installing it brings in the complete collection: dotnet add package Umbraco.Community.Automate Umbraco.Community.Automate.Extensions Generic Automate building blocks: dotnet add package Umbraco.Community.Automate.Extensions Currently including: Get Published Content URLs Umbraco.Community.Automate.Cloudflare The Cloudflare integration: dotnet add package Umbraco.Community.Automate.Cloudflare Currently providing the Cloudflare connection and cache purge functionality. The packages are versioned alongside their supported Umbraco major version, so the first stable release is: 17.0.0 for Umbraco 17. Why I like this approach The part I like most isn't actually the Cloudflare API integration. Calling an HTTP API isn't particularly complicated. The interesting part is the separation of responsibilities. Instead of writing: public class ContentPublishedHandler { // Find content // Determine cultures // Determine domains // Resolve URLs // Read Cloudflare configuration // Call Cloudflare // Handle failures } we can create reusable building blocks: Content Published ↓ For Each Culture ↓ Get Published Content URLs ↓ Purge Cloudflare URLs And tomorrow somebody could build: Content Published ↓ Get Published Content URLs ↓ Notify external service or: Content Published ↓ Get Published Content URLs ↓ Update search index without changing Get Published Content URLs. That's where I think Automate becomes really interesting. What I learned A few things stood out while building these packages. Actions should do one thing Get Published Content URLs shouldn't know Cloudflare exists. And Purge Cloudflare URLs shouldn't need to know anything about Umbraco content. The workflow connects them. Model collections explicitly A published content item can have multiple cultures, and one culture can have multiple URLs. Designing around collections from the start avoids assumptions that only work for simple websites. Bindings are runtime values A binding expression such as: ${ previous.urls } is stored as a string but may resolve to something completely different at runtime. Looking at how Automate's own For Each implementation handles this was extremely useful. Watch your DI lifetimes Automate actions and Umbraco scoped services don't necessarily share the same lifetime. Be careful when injecting services such as published-content APIs directly into actions. Composition beats coupling The Cloudflare use case resulted in a generic action that turned out to be useful completely independently of Cloudflare. That's a good sign that the responsibility belongs in its own extension. What's next? There are plenty of directions this could go. Cloudflare supports more than purging individual URLs, so possible future actions include: Purge by cache tag Purge by prefix Purge by hostname Purge everything But I don't want one giant "Cloudflare action" with every possible option. I'd rather keep each operation explicit and let Automate compose them. The generic Extensions package also opens the door to other reusable Umbraco actions that aren't tied to a specific integration. And that's probably what I find most interesting about Automate: once you start thinking in small triggers, actions and outputs, you quickly start seeing automation opportunities everywhere. Try it The packages are available on NuGet: Umbraco.Community.Automate Umbraco.Community.Automate.Extensions Umbraco.Community.Automate.Cloudflare Source code, examples and issues are available on GitHub: https://github.com/erikjanwestendorp/Umbraco.Community.Automate The project is open source, so feedback, ideas, issues and pull requests are very welcome. If you're experimenting with Umbraco Automate as well, I'd love to hear what kind of integrations or reusable actions you're building.

by Erik-Jan Westendorp

Building my own Umbraco Lamp

I have wanted an UmbLamp ever since Matt Brailsford posted it back in November 2023, but I did not own a 3D printer. That finally changed this July, so I ordered the parts and built one. Here is what I used, what went wrong, and the one mistake worth avoiding before you start.

by Justin Neville

From Examine to Umbraco Search

Demonstrating an approach to migrate a site from Examine to Umbraco Search

by Kenn Jacobsen

Introducing Umbraco AI Copilot Workspace

The RC for Umbraco AI Copilot Workspace just went out on our open release branches, so it’s time to properly introduce it. If you’ve used Umbraco...

by Matt Brailsford

Umbraco Automate: Developer Automation or Marketing Automation?

Umbraco Automate is pitched at marketing teams, but it's developer tooling, and that's fine. What would have to change, from someone who's watched Kentico do it.

by Liam Goldfinch