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

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

What the back office tells you that the pitch never will

This is the first of these. Every week I will write about one specific way digital work drifts from what it was supposed to achieve, usually because of a decision made years earlier by someone who has since left.

by Adam Shallcross

I built a free, self-hosted PWA package for Umbraco, because the alternative bills per site

by Baryo Dev

I've used Umbraco for more than a decade. An unexpected reflection.

I just got back from vacation, and, for the first time in ages, I managed to ditch my laptop. So no Visual Studio, no migrations and no coding whatsoever. No people asking me “can you just…?”. It felt pretty good to unplug for a bit. I wasn’t mentally writing code the whole time, and I definitely didn’t set out to think about Umbraco over my morning coffee.And I was planning on reading blog posts, listen to a few podcasts, reading the docs, etc. But the terrible mobile connection in France prevented that. So I kicked back, soaked up some sun, and enjoyed the joy of doing nothing productive (and a beer here of there), my mind wandered back to it anyway. Because this holiday made me look back and reflect at the last couple of years somehow. Maybe it was the lack of a laptop that made me a look back…I’ve been working with Umbraco for 10+ years now. I started somewhere around 2012 or 2013, with Umbraco 4. In that time, I’ve watched versions come and go, seen the platform shift in big ways, and watched the community explode to what it is today. And I’ve wrangled my way through more projects and upgrades than I care to count.Thinking about that is weird. I never expected, back when I started, that I’d still be talking about Umbraco a decade later. It became part of my career, a source of major learning, and eventually even something I started writing about.So while I was taking a break, I found myself reflecting on this whole adventure. Not just on the version numbers, but on how the platform changed—and, honestly, how I changed right alongside it.

by Dave Jonker

Expanding the backoffice search in Umbraco - Highlighting lesser known Umbraco features

Recently, I started using Erik-Jan Westerndorp's community package "Heading". on a project. It’s one of those packages that solves a very specific problem really neatly. In this case, the client wanted editors to be able to choose the heading level for a title on an element. So, for example, one image block might need an H2, while another might need an H3, depending on where it sits on the page. The package worked great for that. But, as often happens, solving one problem uncovered another. Not long after, the client came back with a new bit of feedback: I can’t search for the custom title I’ve added. And honestly, my first reaction was: surely that should just work? So I gave it a try - and they were right. The backoffice document search was only finding results based on the page name, not on values stored in custom fields. Searching by page name returns the page Searching by page title returns no results The first thing I did was check the internal index — and the title field was there, so that allowed me to cross off one possible issue. So the content was being indexed - it just wasn’t being searched by the backoffice search. I asked a few colleagues about it, and the general feeling was that this probably wasn’t something you could change. The assumption was that Umbraco backoffice search just searches a predefined set of fields and that was that. Still, it felt like there had to be a way. And there is. The Missing Piece: UmbracoTreeSearcherFields It turns out this is already supported and documented, but it was a feature I hadn’t come across before. Since I suspect I’m not the only one, it’s worth highlighting here: Backoffice Search - A guide to customization of Backoffice Search That article introduced me to UmbracoTreeSearcherFields, which controls the indexed fields used by backoffice search. By replacing it with a custom implementation, you can expand the list of searchable fields. My first attempt looked like this: public class CustomUmbracoTreeSearcherFields(ILanguageService languageService) : UmbracoTreeSearcherFields(languageService), IUmbracoTreeSearcherFields { public new IEnumerable<string> GetBackOfficeDocumentFields() { return new List<string>(base.GetBackOfficeFields()) { "title" }; } } I restarted my environment, tried again… and it still didn’t work. Backoffice search could still only find the page by name. The Catch: Variant Field Names After taking a closer look at the index, the reason became clear: the field key wasn’t simply title. Because the property had language variants, the indexed field name includes the language ISO code. So instead of hardcoding a single field name, I updated the implementation to generate the variant field names dynamically. That also makes it more future-proof — if new languages are added later, search continues to work without any code changes. Here’s the updated version: public class CustomUmbracoTreeSearcherFields(ILanguageService languageService) : UmbracoTreeSearcherFields(languageService), IUmbracoTreeSearcherFields { private static readonly string TitleAlias = PublishedModelHelper.GetModelPropertyAlias((Page x) => x.Title); public override IEnumerable<string> GetBackOfficeDocumentFields() { return base.GetBackOfficeDocumentFields().Concat(GetVariantFieldNames(TitleAlias)); } private IEnumerable<string> GetVariantFieldNames(string alias) => languageService.GetAllAsync().GetAwaiter().GetResult().Select(l => $"{alias}_{l.IsoCode.ToLowerInvariant()}"); } And with that in place, the backoffice search started returning results based on the custom title value as well. Why I’m sharing this This is one of those things that’s probably obvious once you’ve worked with it before, but until then, it’s easy to assume backoffice search is less flexible than it actually is. If you’ve got editors relying on custom fields it’s worth checking whether those fields are included in backoffice search. If they’re already indexed, you may only need to extend UmbracoTreeSearcherFields to make them searchable. A small change, but a very useful one for editors.

by Bernadet Goey

Umbraco Codegarden 2026 Day 1: AI, Elements & Community News | manifesto

Explore key takeaways from Day 1 of Umbraco Codegarden 2026, including major product keynotes, AI governance, new Elements features, and the Umbraco Awards.

by Rich Howell