# skttl.dev — Full Content
> Søren Kottal — Umbraco MVP and tinkerer of all kinds of stuff.
> Topics: Umbraco CMS, ASP.NET, frontend development, DevOps, developer tools.
---
# AI is changing how I approach Umbraco development
URL: https://skttl.dev/blog/ai-is-changing-how-i-approach-umbraco-development/
Date: 2026-10-05
Description: Building a PDF metadata dashboard with AI made me reconsider when I reach for Umbraco's standard tools and when a custom extension is worth the maintenance.
Tags: umbraco, ai, backoffice
A customer needed to fill in missing title metadata in PDFs uploaded to Umbraco's media archive. I built a health check to find the files and offer actions to fix them. It worked.
Then I replaced it with a dashboard in the Media section and added a box showing PDF metadata on the media item's Info tab. Building those additions with AI assistance took at most a couple of hours, including directing the agent, reviewing its work, and trying it out.
That changed which approach made sense for this task. A custom interface had previously felt like something I would have to justify spending several hours on. This time, it was practical to build the interface around the editor's job.
## A PDF cleanup job
The ongoing part of the problem was straightforward. I used saved notification handlers to keep the PDF's title metadata in sync with the media item's name. When an editor saves a media item, the handler checks whether the titles match and updates the PDF if necessary. For new uploads, it takes the PDF's existing title and uses it as the media name.
Umbraco's [MediaService notifications](https://docs.umbraco.com/umbraco-cms/17.latest/extend-your-project/server-side-extensions/notifications/mediaservice-notifications) provide the hook for that work. The PDF handling is custom code in the customer's project.
But this was an existing site with plenty of PDFs already missing titles. Keeping future changes in sync did not clear that backlog. The editors needed a way to find the problematic files and decide what their titles should be.
That is where I reached for a health check.
## The health check worked
I have used this approach before. Umbraco's [health check framework](https://docs.umbraco.com/umbraco-cms/extending/health-check) lets me implement a class that returns results and provides actions to resolve them. Umbraco supplies the interface and the flow around it.
From a developer's perspective, that is a useful shortcut. I can focus on detecting and correcting the problem instead of building the UI and figuring out where and how to integrate it into the backoffice.

The problem was the editing experience. The check lived in Settings, somewhere an editor might not think to look, or might not have access to. Even with access, getting to the check took several clicks. Each result took up a fair amount of space, and I had put the link to the individual media item behind a button labelled "Read more".
It could find and fix the PDFs. It was less suitable as the place where an editor would work through an archive of them.
I had picked an interface because it was convenient to implement. Looking at it again, a dashboard in Media was a better fit for the actual task.
## A better place to do the work
The dashboard finds PDFs with missing or mismatched titles and presents the media name and PDF title side by side. The media name is a direct link to the item.
For each PDF, the editor can use the media name as the PDF title, use the existing PDF title as the media name, or enter a new name for both.

Those choices matter because a mismatch does not tell me which title is right. Sometimes the media name is useful and the PDF title is empty. Sometimes the PDF already has a better title. Sometimes neither is what the editor wants.

I also added a PDF metadata box to the media item's Info tab. A custom condition makes it appear only for PDF files. It shows the current metadata so an editor can see what is in the file before changing its name.

I did not actually know that the Info tab's boxes could be extended like this. The extension type is called [`workspaceInfoApp`](https://apidocs.umbraco.com/v17/ui-api/interfaces/packages_core_workspace.ManifestWorkspaceInfoApp.html), and it was a useful discovery for this job. Otherwise, I would probably have built a separate workspace view. Or, slightly embarrassingly, a property editor whose only job was to display data. Extending the existing Info tab was a better fit.
The customer is now working through the PDFs. The dashboard is easy for them to find in Media, and correcting the titles is straightforward.
## A couple of hours changed the decision
The new backoffice has plenty of extension points for building this kind of editing experience. Yes, I still call it new. I have been doing Umbraco long enough that this may take a while to wear off.
The possibilities were already there. Writing the code to use them was the part that made me hesitate.
At Codegarden 2025, I watched Niels Lyngsø's [Next-Level Backoffice talk](https://www.youtube.com/watch?v=P0xxTIlHayg). He demonstrated a whole collection of extensions that improved the editor experience. I remember sitting in the audience thinking that it looked lovely, but I would never get the budget from my customers to do that sort of work.
This PDF task made me reconsider that assumption. With an AI agent doing much of the implementation, the extra work became small enough that I chose a different approach.
I still had to direct it. I had not installed [Umbraco's backoffice skills](https://github.com/umbraco/Umbraco-CMS-Backoffice-Skills) for this project, so I had to tell the agent to use `uui-table` for the table, for example. I have [written about those skills before](/blog/umbraco-skills-ai-agents). In this case, I managed to give myself a practical reminder of why they are useful.
The agent can produce the code, but I still need to check whether it uses the right components and whether the resulting interface makes sense. A table that displays the data is only part of the job. The choices and their wording need to make sense to the person correcting the PDFs.
Previously, the development time for these additions would have been difficult to sell. The health check already worked, so I would have had to justify several more hours to make the same task easier for the editor. At most a couple of hours is a much easier case to make.
In our customer work, I am also seeing more arrangements closer to fixed pricing, where the conversation focuses less on individual hours and more on value. Spending less time on implementation makes it more practical to include improvements like this, rather than stopping at the first interface that gets the job done.
## Custom still has to earn its place
There is a limit to this. If I build every custom extension an agent can produce, I can still end up with a Frankenstein's monster of a backoffice. Each little addition might make sense on its own, while the whole thing becomes harder to understand and maintain.
Standard components still have a lot of value. I prefer them where they fit because they leave less custom behaviour for me to maintain. Using Umbraco's components and extension points also helps a necessary addition fit into the existing interface.
My test is whether the extension makes a concrete task noticeably easier or more editor-friendly. Being quick to build is not enough reason to add it.
For this customer, the dashboard passes that test. The editors have the overview and choices they need, in the section where they already manage their files. AI assistance made that approach affordable enough for me to choose it.
The next time I reach for a health check or another convenient shortcut, I will spend a moment checking whether it is also a good place for the editor to do the work.
---
# Introducing RazorSearch for Umbraco
URL: https://skttl.dev/blog/introducing-razorsearch-for-umbraco/
Date: 2026-10-01
Description: RazorSearch is my successor to Full Text Search for Umbraco, with weighted titles and headings, background rendering and tools for inspecting extracted text.
Tags: umbraco, razor, package, backoffice
I've maintained [Full Text Search](https://marketplace.umbraco.com/package/our.umbraco.fulltextsearch) to solve a particular problem: searching the text a visitor sees, including blocks and related content assembled by Razor templates. Umbraco Search gave me a reason to rethink how the package does that.
I built [RazorSearch](https://marketplace.umbraco.com/package/umbraco.community.razorsearch) as the successor to Full Text Search for Umbraco 17 and 18. It renders published pages, extracts their text, and indexes the result through Umbraco Search. I've added a small API for building a search page, separate weighting for rendered titles and headings, and backoffice tools for inspecting the rendering work.
## Why a new package?
Full Text Search did this by adding fields containing the rendered text to Umbraco's `ExternalIndex`. It also provided a simpler way to query Examine and turn the results into something useful on a search page. I've written about [earlier improvements to Full Text Search](/blog/4-new-features-in-full-text-search-umbraco) before.
[Umbraco Search](https://github.com/umbraco/Umbraco.Cms.Search) introduces a search abstraction with providers underneath it. According to Umbraco's [Umbraco Search announcement](https://github.com/umbraco/Announcements/issues/36), it will be included in Umbraco 19 and replace the existing core Examine indexes. Examine will remain the default provider, but search implementations should use the Umbraco Search abstractions.
I built RazorSearch on that abstraction, with a separate index for the text extracted from stored page snapshots. I've kept the core package independent of the search provider and put the Examine index setup in a companion package.
I also wanted a different name. "Full Text Search" never quite described what made the package useful. RazorSearch is a separate package with the same starting point, and the new name makes that distinction clearer.
## Giving titles and headings more weight
This is the change I wanted most. Once the rendered text is split into separate parts, the search can distinguish a match in a heading from one in the body of the page.
By default, RazorSearch extracts and indexes these parts:
| Part of the page | Default source | Relevance level |
| --- | --- | --- |
| Title | `
` | Highest, using `TextsR1` |
| Headings | `
` through `
` | Next, using `TextsR2` |
| Body | `` | Normal, using `Texts` |
These are [Umbraco Search's relevance levels](https://github.com/umbraco/Umbraco.Cms.Search/blob/main/src/Umbraco.Cms.Search.Core/Models/Indexing/IndexValue.cs). The search provider applies them when scoring results. All heading levels share the same relevance level here.
I already had a title boost for configured index fields in Full Text Search. In RazorSearch, I extract the rendered title and headings separately, then pass them to Umbraco Search with their intended relevance.
For a query like "installation", I want a match in a heading to carry more weight than a mention halfway through a paragraph. The separate fields give the provider that information, though other scoring factors still affect the final order.
The extraction sources are configurable. You can use CSS selectors to select parts of the HTML, or read published Umbraco properties when those are a better source. The [configuration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/configuration/README.md) covers the available options.
## Seeing what RazorSearch is doing
Full Text Search already lets you trigger reindexing from the content tree and includes health checks. Older versions also had a dashboard. In RazorSearch, I've added a view of the rendering queue and the text extracted from individual pages.
The operations view shows pending, running, completed and failed jobs, along with progress for the current batch. You can queue a single page, a page and its descendants, or a backfill of published content.

For an individual page, you can inspect the stored title, headings, summary and body text, along with the culture and rendering status. Failed jobs also expose their error messages.
That gives you somewhere to start when a page is missing from search or contains unexpected text. You can check whether the rendering failed and whether the text you expected is present in the snapshot.

Rendering happens in the background. Publishing queues the work, and saving the resulting snapshot triggers an update of RazorSearch's index. That means there can be a short delay between publishing a page and seeing its updated text in search.
Changing a template, shared content or extraction rules requires a manual rebuild of the affected snapshots. Rebuilding the search index alone reads the stored snapshots without rendering the pages again. The rendering queue lives in memory, so a restart loses pending work. If a restart interrupts a batch, queue another rebuild. The [migration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/migrating-from-fulltextsearch/README.md) covers these operational differences too.
## Coming from Full Text Search
I've changed both the setup and the search API, so migrating involves more than swapping the NuGet package. I've written a [migration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/migrating-from-fulltextsearch/README.md) covering the full process. Here are the main changes.
### Set up Umbraco Search
Replace `Our.Umbraco.FullTextSearch` with `Umbraco.Community.RazorSearch`. If you use the Examine provider, install `Umbraco.Community.RazorSearch.Examine`, which includes the core package as a dependency.
For Umbraco 17:
```bash
dotnet add package Umbraco.Community.RazorSearch.Examine --version 17.0.0
```
For Umbraco 18:
```bash
dotnet add package Umbraco.Community.RazorSearch.Examine --version 18.0.0
```
I target Umbraco `17.0.0` and later in the 17.x line, and Umbraco `18.0.0` and later in the 18.x line. Both package lines require .NET 10 and Umbraco Search. Choose the RazorSearch package line matching your Umbraco major version.
Enable Umbraco Search and its provider in your existing `Program.cs`. For Examine, add these namespaces and registrations to the usual builder chain:
```csharp
using Umbraco.Cms.Search.Core.DependencyInjection;
using Umbraco.Cms.Search.Provider.Examine.DependencyInjection;
builder.CreateUmbracoBuilder()
.AddBackOffice()
.AddWebsite()
.AddComposers()
.AddSearchCore()
.AddExamineSearchProvider()
.Build();
```
Keep the rest of your Umbraco startup code. I've made RazorSearch register its own services automatically; your application still needs to register the search provider.
After installation, queue a backfill in the backoffice so RazorSearch can render the existing pages and populate its index. The old Full Text Search cache does not provide those new snapshots.
See the [installation guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/installation/README.md) for the complete setup.
### Update the search request
Replace the injected `ISearchService` with `IRazorSearchService`, which lives in the `Umbraco.Community.RazorSearch` namespace. The request model changes from `Search` to `RazorSearch`, and the search call becomes asynchronous.
For example, with a non-empty `query` and a one-based `pageNumber`, a Full Text Search request might look like this:
```csharp
using Our.Umbraco.FullTextSearch.Models;
var search = new Search(query)
.SetCulture(CurrentPage.GetCultureFromDomains())
.AddRootNodeId(CurrentPage.Root().Id)
.AddAllowedContentTypes("article")
.SetPageLength(10);
var result = _searchService.Search(search, pageNumber);
```
The corresponding RazorSearch request is:
```csharp
using Umbraco.Community.RazorSearch.Models;
using Umbraco.Extensions;
var search = new RazorSearch(query)
.InCulture(CurrentPage.GetCultureFromDomains())
.UnderRoot(CurrentPage.Root().Key)
.IncludeContentTypes("article")
.Page(pageNumber, 10);
var result = await _razorSearchService.SearchAsync(search);
```
The root now uses the content's GUID `Key`, and paging is part of the request. Set the culture to a configured Umbraco language to search that language and invariant content. Without a culture, RazorSearch searches invariant content only.
The [usage guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/usage/README.md) includes a complete Razor template for a search page.
I've kept the results focused on what a search page needs: a title, URL, published content and highlighted `SummaryHtml`.
### Translate configuration and rendering helpers
Configuration moves from `Umbraco:FullTextSearch` to `Umbraco:Community:RazorSearch` in `appsettings.json`.
I've included an appsettings schema that Umbraco picks up automatically when you build the application. That gives you IntelliSense for RazorSearch's settings when you edit `appsettings.json`.
XPath rules such as `XPathsToRemove` become CSS selectors in `SnapshotExtraction.RemoveSelectors`. Separate source lists control the title, summary, headings and body. If you use a helper in your templates to omit navigation or other markup during search rendering, replace `FullTextSearchHelper.IsRenderingActive()` with `SearchRenderingContext.IsActive`.
> **Note:** HTTP rendering was already the default in Full Text Search, so the site still needs to be able to request its own pages. The alternative `RenderTemplate()` renderer was there for backwards compatibility, and I haven't carried it over to RazorSearch.
Some other Full Text Search options also have no direct equivalent in RazorSearch's API, including custom Lucene queries, automatic wildcard and fuzziness settings, and selecting an arbitrary index. Result items no longer expose raw Examine fields or scores. If your implementation uses those features, review that code as part of the migration.
## What happens to Full Text Search?
Full Text Search is now in maintenance mode. Its [18.0.0 release](https://www.nuget.org/packages/Our.Umbraco.FullTextSearch/18.0.0) supports Umbraco 18. I don't expect to add more features, but I may still release occasional patches if something needs fixing.
You can continue using the existing releases on Umbraco 17 and 18. Installing RazorSearch is a separate migration, so an existing search implementation does not need to change simply because this package exists.
I plan to put future development into RazorSearch rather than Full Text Search. For projects moving to Umbraco Search, it is the successor I intend people to use. Because Umbraco Search will become part of Umbraco 19, projects on Umbraco 17 or 18 can start the migration before that breaking change arrives.
## Get RazorSearch
I've released `17.0.0` for Umbraco 17 and `18.0.0` for Umbraco 18. The [release notes](https://github.com/skttl/umbraco-razor-search/blob/main/docs/release-notes.md) cover the features and remaining limitations.
[RazorSearch is available on Umbraco Marketplace](https://marketplace.umbraco.com/package/umbraco.community.razorsearch), free and MIT-licensed. Start with the [installation guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/installation/README.md), or the [migration guide](https://github.com/skttl/umbraco-razor-search/blob/main/docs/migrating-from-fulltextsearch/README.md) if you're coming from Full Text Search.
---
# Manage video in Umbraco, stream elsewhere
URL: https://skttl.dev/blog/manage-video-in-umbraco-stream-elsewhere/
Date: 2026-09-14
Description: uMux now supports Umbraco 18, joined by uBun and uGum for syncing local videos to Mux, Bunny Stream or Gumlet without changing the editor workflow.
Tags: umbraco, package, video, umbraco-cloud
Serving a 50 MB video one hundred times uses about 5 GB of bandwidth. Do that across ten videos and you have used the entire monthly bandwidth allowance for an Umbraco Cloud Starter project.
Video delivery is therefore something I would rather leave to a video platform. Editors should not have to care about that choice, though. They should still be able to upload and manage videos in Umbraco without copying IDs or embed codes between systems.
That was the idea behind [uMux](/blog/umux-announcement), my integration for [Mux](https://www.mux.com/video-api). I have now released an Umbraco 18 version, along with two alternatives: uBun for [Bunny Stream](https://bunny.net/stream/) and uGum for [Gumlet](https://www.gumlet.com/).
## Why move video delivery elsewhere?
The original file stays in Umbraco. This gives me a copy if a project stops using its current provider or needs to move elsewhere later.
The website uses the provider's playback URL instead of serving that original file to visitors. This moves the playback traffic away from Umbraco Cloud, where the current monthly allowances are [50 GB for Starter, 300 GB for Standard and 1,000 GB for Professional](https://umbraco.com/products/umbraco-cloud/pricing/).
Mux, Bunny Stream and Gumlet all process the uploaded file into HLS streams. An HLS-compatible player can switch between different renditions as the viewer's available bandwidth changes. It also streams the video in segments instead of treating it as one large download.
## One workflow, three providers
The editor workflow is the same with all three packages:
1. Upload a video to an Umbraco Upload property, for example on the default Video media type.
2. Save the item.
3. The package sends the file to the configured provider.
4. The provider's asset ID and playback details are stored in a second property.
5. The website uses those playback details when rendering the video.
[uMux](https://marketplace.umbraco.com/package/umbraco.community.umux) is the original package I built for this workflow. Its latest release adds support for Umbraco 18.
[uBun](https://marketplace.umbraco.com/package/umbraco.community.ubun) and [uGum](https://marketplace.umbraco.com/package/umbraco.community.ugum) are now available on the Umbraco Marketplace. Both have releases for Umbraco 17 and 18.
I considered building a shared package with replaceable providers. In the end, each integration had enough settings, API behaviour and provider-specific details to make separate packages simpler. They can share the same general idea without forcing every future difference through a common abstraction.
## Small differences between the providers
This is not intended as a full comparison, but each provider has a useful angle:
- I find [Gumlet's fixed plans and included usage](https://www.gumlet.com/blog/gumlet-pricing-update-2026/) easier to explain than a price assembled from several kinds of usage.
- [bunny.net is based in Europe](https://bunny.net/about/), which can matter when a client prefers a European supplier.
- Mux has a strong set of developer tools. [Mux Player includes Mux Data](https://www.mux.com/docs/guides/player-core-functionality), and a playback ID can also produce [thumbnails, animated GIFs and storyboards](https://www.mux.com/docs/guides/get-images-from-a-video).
The right choice still depends on the project. The point of the three packages is that the Umbraco workflow does not have to depend on that choice.
## Could Umbraco Automate do this?
Yes, but Automate does not remove the provider-specific work. [Umbraco Automate supports custom actions](https://docs.umbraco.com/umbraco-automate/extending/custom-action), so an automation could react to a CMS event and send a video to an external service.
A complete video integration still needs code to read the media file, authenticate with the provider, follow its upload process, check the processing status, store the playback details in Umbraco and remove remote assets when videos are replaced or deleted. It also needs a way to show that status to the editor. At that point, the custom action has started to look a lot like one of these packages.
Automate makes more sense when video upload is one step in a larger workflow that editors need to configure. uMux, uBun and uGum cover the fixed case where saving an Umbraco item should keep its video in sync with one provider.
## Getting started
Install the package for the provider you want to use:
```bash
dotnet add package Umbraco.Community.uMux --version 18.0.0
dotnet add package Umbraco.Community.uBun --version 18.0.0
dotnet add package Umbraco.Community.uGum --version 18.0.0
```
> **Note:** Install one of these packages, not all three. For Umbraco 17, use version `17.0.0` of the corresponding package.
Add the matching provider settings to `appsettings.json`. Only the section for the installed package is needed.
```json
{
"Umbraco": {
"Mux": {
"ApiTokenId": "YOUR_TOKEN_ID",
"ApiSecret": "YOUR_TOKEN_SECRET"
},
"BunnyStream": {
"LibraryId": 123456,
"ApiKey": "YOUR_STREAM_API_KEY",
"PullZoneHostname": "vz-example.b-cdn.net"
},
"Gumlet": {
"ApiKey": "YOUR_GUMLET_API_KEY",
"SourceId": "YOUR_GUMLET_SOURCE_ID"
}
}
}
```
Then create a data type using **Mux Sync**, **Bunny Stream Sync** or **Gumlet Sync**. Set **Upload Property Alias** to the alias of the Upload property containing the local video, and add the sync property to the same media, content or member type.
The stored playback value differs slightly by provider:
| Package | Stored playback details |
| --- | --- |
| uMux | `PlaybackId`, used with Mux Player or `https://stream.mux.com/{PLAYBACK_ID}.m3u8` |
| uBun | `HlsUrl` and `EmbedUrl` |
| uGum | `PlaybackUrl` containing the HLS URL |
The repositories contain the complete setup and playback examples for [uMux](https://github.com/skttl/umbraco-mux), [uBun](https://github.com/skttl/umbraco-ubun) and [uGum](https://github.com/skttl/umbraco-ugum).
## One thing to get right
The sync property and Upload property must be on the same Umbraco type. The value in **Upload Property Alias** must also match the actual alias exactly.
For a video uploaded before adding the sync property, save the media item again to start the first synchronization.
## Final thoughts
These packages keep the part editors care about in Umbraco. They upload and manage a normal media item, while the chosen provider handles processing and playback. The local original remains available if the project needs to change provider later.
uBun and uGum are the first alternatives to uMux. I have more providers I want to integrate, and keeping each package independent leaves room for their differences.
---
# Bringing Excel export back to Umbraco Forms
URL: https://skttl.dev/blog/bringing-excel-export-back-to-umbraco-forms/
Date: 2026-09-07
Description: A client preferred Excel exports, so I built a small Umbraco Forms package to bring them back using SpreadCheetah.
Tags: umbraco, package, dotnet, ai
> **Note:** Rick Butterfield is also working on [Umbraco.Community.Forms.Excel](https://www.nuget.org/packages/Umbraco.Community.Forms.Excel), which covers more or less the same ground using MiniExcel instead of SpreadCheetah. You can find its source on [GitHub](https://github.com/rickbutterfield/Umbraco.Community.Forms.Excel).
After an upgrade from Umbraco 13 to 17, a client wondered where the Excel export in Umbraco Forms had gone. The built-in export now produced CSV files, but they were more comfortable working with Excel files.
That was enough reason to build [Umbraco Forms Excel Export](https://marketplace.umbraco.com/package/umbraco.community.forms.excelexport). It adds `.xlsx` exports to the existing Forms export menu, so editors can download their submissions in the format they prefer.
## Where did Excel export go?
Umbraco Forms used to include Excel export. In Forms 16, Umbraco replaced it with CSV export using CsvHelper.
The reason was the library behind it. Forms depended on EPPlus 4, the last LGPL-licensed version, which was no longer supported. Newer versions had different licensing requirements that Umbraco did not want for a built-in feature. The [Forms 16 upgrade notes](https://docs.umbraco.com/umbraco-forms/16.latest/upgrading/version-specific) explain the change.
I wanted to bring Excel export back with a dependency that could work well in a free, open source package.
## Choosing a spreadsheet library
Some Googling and AI-assisted research gave me a shortlist of ClosedXML, MiniExcel, the old EPPlus version, and SpreadCheetah.
I ended up choosing [SpreadCheetah](https://github.com/sveinungf/spreadcheetah). It has an MIT license and could do what I needed: write form submissions into a new Excel file.
One limitation I came across was that it cannot read or modify existing spreadsheets. That would matter for other projects, but this export only needs to create a new file. It writes the workbook row by row, which fits the job quite well.
The [published benchmarks](https://github.com/sveinungf/spreadcheetah#benchmarks) looked promising too, with lower execution times and memory allocations than ClosedXML and EPPlusFree in the project's test. I didn't run my own benchmarks, so there is a bit of "trust me bro" involved in that part of the decision. It did what I needed, and that was the main thing.
## About 30 questions and a plan
The implementation was fairly uneventful. I started with a grilling session using [Matt Pocock's grilling skill](https://github.com/mattpocock/skills). About 30 questions later, I had a detailed plan that Luna could implement on its own.
That included setting up automatic releases through GitHub Actions. Pushing a version tag triggers the workflow to build, test, publish the package to NuGet, and create a GitHub release.
In my experience, Umbraco Forms is relatively straightforward to extend, and its [documentation for adding export types](https://docs.umbraco.com/umbraco-forms/17.latest/developer/extending/adding-a-exporttype) gives you a useful starting point. There weren't many moving parts to keep track of here. The package plugs into the existing export system and uses its filters for the columns and submissions to include.
I also spent a little time finding a logo on Noun Project. That was more or less it.
## How to use it
Install the package version that matches your Umbraco Forms major version. For Forms 17:
```bash
dotnet add package Umbraco.Community.Forms.ExcelExport --version 17.0.3
```
For Forms 18:
```bash
dotnet add package Umbraco.Community.Forms.ExcelExport --version 18.0.3
```
These are the current releases at the time of writing. You can find newer releases on [NuGet](https://www.nuget.org/packages/Umbraco.Community.Forms.ExcelExport).
After installation, open the export menu for your form's entries. Two additional options appear alongside the built-in export types:
- **Excel** exports the stored values, preserving supported dates, booleans, and integers as native Excel cells. Other values are written as text, and multiple values are joined with commas.
- **Excel (display values)** writes values as text and replaces stored prevalue keys with their captions. This is useful for dropdowns, checkboxes, and radio buttons where the stored value differs from the label people recognise.
Both produce an `.xlsx` file containing an `Entries` worksheet. No configuration is required.

The package is free and MIT-licensed. You can find it on the [Umbraco Marketplace](https://marketplace.umbraco.com/package/umbraco.community.forms.excelexport), with the source code and issue tracker on [GitHub](https://github.com/skttl/umbraco-forms-excel-export).
---
# The ultimate Umbraco site structure, revisited
URL: https://skttl.dev/blog/the-ultimate-umbraco-site-structure-revisited/
Date: 2026-09-03
Description: How I use the Ultimate Site Structure, with a custom URL provider and content finder that keep the frontpage separate from the site root.
Tags: umbraco, csharp, contentfinder, architecture
At the Umbraco meetup in Odense on September 2, 2026, [Kaspar Boel Kjeldsen](https://kjeldsen.dev/) included the Ultimate Site Structure in his talk, [The 10 Umbracommandments](https://www.meetup.com/umbracodkmeetup/events/315773679/). It was good to hear somebody recommend a pattern that I have used since Umbraco 7.
The original article, [The "ultimate" site structure setup](https://cultiv.nl/blog/tip-of-the-week-the-ultimate-site-structure-setup/), was written by Sebastiaan Janssen in December 2010. It is more than 15 years old and starts with a warning that it is probably outdated. The implementation certainly is. The structure itself still holds up.
I use it on every Umbraco project I work on, without exception.
## What the Ultimate Site Structure looks like
The content tree has a site node at the root. The frontpage and all other top-level pages are children of that node.
```text
Content
└── Site
├── Home
├── Products
├── About
└── Contact
```
The site node owns things that apply to the whole site:
- Hostnames and cultures
- Navigation and footer settings
- Other global settings
- The selected frontpage
- Rules for which document types editors can create below it
The site node does not need to render its own content. Its job is to represent the site.
## The frontpage is not the site
A common Umbraco content tree puts the frontpage at the root and every other page below it. I do not think that hierarchy describes the content correctly. The contact page is part of the site, but it is not a child of the frontpage.
Keeping the site and frontpage separate also removes an awkward dependency. If the frontpage needs a different document type or a complete replacement, I can create a new page and select it as the frontpage. I do not have to move every other page below a new root node.
The hostname stays on the site node. So do cultures and global settings. They do not move just because the frontpage changes.
This separation is also useful for site-specific features. My approach to [editor-friendly 404 pages in Umbraco](/blog/editor-friendly-404-page-in-umbraco) stores the selected 404 page on the same site node.
## The missing routing pieces
Sebastiaan's original solution used a Content Picker with the alias `umbracoInternalRedirectId` on the site node. Umbraco then renders the selected frontpage when somebody requests the site root.
The remaining problem is outbound routing. When Umbraco generates a link to the Home node, its default URL is still `/home`. Hardcoding `/` in navigation only hides part of the problem. An editor can still select Home in the rich text editor or a link picker and get `/home`.
The complete solution needs to work in both directions:
- A URL provider must return the site URL when Umbraco asks for the frontpage URL.
- A content finder must resolve the site URL to the selected frontpage.
Umbraco's [routing documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline) explains this as the outbound and inbound request pipelines.
> **Note:** `umbracoInternalRedirectId` is one of Umbraco's special property aliases. Add it to a document type with a Content Picker, and Umbraco renders the picked page without changing the URL in the browser. The [special routing property aliases](https://docs.umbraco.com/umbraco-cms/reference/routing/routing-properties) also include `umbracoRedirect`, `umbracoUrlName`, and `umbracoUrlAlias`.
## Returning the site URL for the frontpage
The URL provider only handles content selected as a frontpage. For every other content item, it returns `null` and lets Umbraco continue through the provider collection.
```csharp
public class FrontpageUrlProvider : DefaultUrlProvider
{
private readonly FrontpageUrlHelper _frontpageUrlHelper;
// The Umbraco 18 DefaultUrlProvider dependencies are omitted here.
public override UrlInfo? GetUrl(
IPublishedContent content,
UrlMode mode,
string? culture,
Uri current)
{
// A frontpage uses the URL of the site node that owns it.
var site = _frontpageUrlHelper.GetSiteOfFrontpage(
content.Key,
culture);
// Returning null lets the next URL provider handle normal pages.
return site is null
? null
: base.GetUrl(site, mode, culture, current);
}
}
```
`FrontpageUrlHelper` is not an Umbraco class. It is a small project class I wrote to keep the routing rules in one place. Given a frontpage key and culture, it finds the site node that owns the frontpage. Given a site key and culture, it returns the selected frontpage or the fallback page. The URL provider uses the first operation. The content finder uses the second. I show the relevant parts of the helper further down.
Delegating to `DefaultUrlProvider` is important. Umbraco already knows how to choose between relative and absolute URLs and how to handle schemes, hostnames, and cultures. My provider only swaps the content item from the frontpage to its site node.
The official [outbound request pipeline documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/outbound-pipeline) covers provider ordering and the full constructor required when inheriting from `DefaultUrlProvider`.
## Resolving the site URL to the frontpage
The content finder handles the other direction. When a request maps to a site node, it replaces the published content with that site's frontpage.
The finder has `FrontpageUrlHelper`, `IUmbracoContextAccessor`, and `IDocumentUrlService` injected. The shortened method below shows where each one is used.
```csharp
public Task TryFindContent(IPublishedRequestBuilder request)
{
IPublishedContent? frontpage = null;
var path = request.Uri.AbsolutePath;
if (request.HasDomain() && request.Domain!.Uri.AbsolutePath.Length > 1)
{
// Remove a domain path such as /dk before resolving the route.
path = path[request.Domain.Uri.AbsolutePath.Length..];
}
if (path.InvariantEquals("/") && request.HasDomain())
{
// The matched domain already points to the site node.
var site = _umbracoContextAccessor
.GetRequiredUmbracoContext()
.Content?
.GetById(request.Domain!.ContentId);
if (site is not null)
{
frontpage = _frontpageUrlHelper.GetFrontpageOfSite(
site.Key,
request.Culture);
}
}
else
{
// Resolve the remaining path when the domain did not identify the site.
var siteKey = _documentUrlService.GetDocumentKeyByRoute(
path.EnsureStartsWith("/"),
request.Domain?.Culture,
request.Domain?.ContentId,
false);
if (siteKey.HasValue)
{
frontpage = _frontpageUrlHelper.GetFrontpageOfSite(
siteKey.Value,
request.Culture);
}
}
if (frontpage is null)
{
return Task.FromResult(false);
}
// Returning true stops the remaining content finders.
request.SetPublishedContent(frontpage);
return Task.FromResult(true);
}
```
The first branch handles a request to `/` when Umbraco has matched a domain directly to the site node. In that case, the domain already gives us the site node ID.
The second branch handles requests where the path still needs to be resolved. This includes a site reached without a hostname and a domain with a path such as `example.com/dk`. For the latter, the code removes `/dk` from the request path first. An empty remainder becomes `/` through `EnsureStartsWith("/")`. `IDocumentUrlService.GetDocumentKeyByRoute()` can then resolve the route to a document key. `GetFrontpageOfSite()` only returns content when that key belongs to a site node.
Umbraco runs content finders in collection order until one returns `true`. The [IContentFinder documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/icontentfinder) explains the interface and registration options.
## Falling back to the first page
The content finder improves on the original setup in one small but useful way. If an editor has not selected a frontpage, it uses the first child with a template.
```csharp
private IPublishedContent? GetFrontpage(
IPublishedContent site,
string? culture)
{
var frontpageUdi = site.Value(
"umbracoInternalRedirectId",
culture,
fallback: Fallback.ToLanguage)
?? site.Value(
"umbracoInternalRedirectId",
culture,
fallback: Fallback.ToDefaultLanguage);
if (frontpageUdi is not null)
{
// Use the page selected on the site node.
return _umbracoContextAccessor
.GetRequiredUmbracoContext()
.Content
.GetById(frontpageUdi.Guid);
}
// A site without a selection uses its first renderable child.
return site.FirstChild(child => child.TemplateId is not null);
}
```
The picker lookup first uses the requested culture, then its fallback language, and finally the default language. If none of them has a value, the first renderable child becomes the frontpage.
This means the site keeps working before somebody configures the picker. It also makes `umbracoInternalRedirectId` optional if the content finder handles all inbound requests in the project.
## Do not traverse the tree for every URL
The first versions of this provider caused performance problems that were difficult to explain. Profiling eventually led back to frontpage URL generation.
A URL provider can run many times during one request. A navigation, link collection, or sitemap may ask Umbraco to generate a URL for every item it contains. The early implementation searched through a large part of the content tree each time it had to decide whether an item was a frontpage.
The fix was to build the mapping once and cache it per culture.
```csharp
private sealed record CachedFrontpages(
Dictionary SiteKeyByFrontpageKey);
private CachedFrontpages GetAllFrontpages(string? culture)
{
// Each culture can select a different frontpage.
var cacheKey = $"Skttl.FrontpageUrl.FrontpageSettings__{culture}";
// Build the mapping only when this culture is not cached yet.
return _runtimeCache.Get(
cacheKey,
() => BuildFrontpageMappings(culture))
as CachedFrontpages
?? new CachedFrontpages([]);
}
private CachedFrontpages BuildFrontpageMappings(string? culture)
{
var content = _umbracoContextAccessor
.GetRequiredUmbracoContext()
.Content;
var siteKeyByFrontpageKey = new Dictionary();
// This simplified example assumes every site is a root node of type site.
var sites = content.GetAtRoot()
.Where(node => node.ContentType.Alias.InvariantEquals("site"));
foreach (var site in sites)
{
if (GetFrontpage(site, culture) is { } frontpage)
{
// The URL provider needs to look up the site from the frontpage.
siteKeyByFrontpageKey[frontpage.Key] = site.Key;
}
}
return new CachedFrontpages(siteKeyByFrontpageKey);
}
public IPublishedContent? GetSiteOfFrontpage(
Guid frontpageKey,
string? culture)
{
var frontpages = GetAllFrontpages(culture);
return frontpages.SiteKeyByFrontpageKey.TryGetValue(
frontpageKey,
out var siteKey)
? _umbracoContextAccessor
.GetRequiredUmbracoContext()
.Content
.GetById(siteKey)
: null;
}
```
This shortened version assumes that every site uses the `site` document type and lives at the content root. If a project allows nested site nodes or several site document types, I use `IDocumentNavigationQueryService` to collect those keys instead. Either way, building the cache reads each site's frontpage once. Normal URL generation then uses a dictionary lookup instead of repeating that work for every link.
I no longer have useful before-and-after numbers from the original issue. It happened many years ago. The important lesson is in Umbraco's own guidance for custom URL providers: cache the work used to decide which provider owns a URL.
## Invalidate the cache when the site changes
A cache of frontpage mappings is only correct until an editor selects another frontpage. The implementation clears it whenever a configured site node is published.
```csharp
public class ClearFrontpageCache(
AppCaches appCaches)
: INotificationHandler
{
public void Handle(ContentPublishedNotification notification)
{
var siteWasPublished = notification.PublishedEntities.Any(
content => content.ContentType.Alias.InvariantEquals("site"));
if (siteWasPublished)
{
// ClearByKey removes the cached entry for every culture suffix.
appCaches.RuntimeCache.ClearByKey(
"Skttl.FrontpageUrl.FrontpageSettings");
}
}
}
```
The individual cache keys end with a culture, but `ClearByKey` clears every key that starts with the supplied value. Clearing the base key therefore invalidates all culture-specific frontpage mappings.
## Gotchas
There are a few assumptions behind this implementation.
**One frontpage belongs to one site.** Umbraco's Content Picker allows two site nodes to select the same content item. The cache maps one frontpage key to one site key, so the last mapping would replace the first. I have never needed to share one frontpage between sites, and the implementation deliberately does not support it.
**The picker is not restricted to the current site.** I use a standard Content Picker and keep the setup simple. The helper does not verify that the selected page is a descendant of the site node. A stricter project could add that validation or configure a more constrained picker. I have not needed the extra guard so far.
**The cache must vary by culture.** A variant site can select different frontpages for different languages. A single global mapping would return the wrong site or frontpage for at least one culture.
**Domains can contain paths.** A culture or site may start at `/dk` rather than `/`. Remove the configured domain path from the request path, then add a leading slash before passing the remainder to `GetDocumentKeyByRoute()`.
**Collection order matters.** Both URL providers and content finders stop when one handles the item or request. Register the frontpage implementations early enough to run before Umbraco's default handling.
## How to wire it together
The main registrations fit in a composer. The exact `DefaultUrlProvider` constructor changes between Umbraco versions, which is why the earlier example focused on the overridden method.
```csharp
public class FrontpageUrlComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.Services.AddSingleton();
// Insert both routing components before Umbraco's default handling.
builder.UrlProviders().Insert();
builder.ContentFinders().Insert();
builder.AddNotificationHandler<
ContentPublishedNotification,
ClearFrontpageCache>();
}
}
```
The helper examples use `umbracoInternalRedirectId` as the frontpage property and `site` as the site document type alias. Replace those strings in the helper and cache notification if your project uses different aliases. You can use a normal property alias such as `frontpage` when the custom content finder owns inbound routing.
For multi-site solutions, put the hostname and culture on each site node. If the project has several site document types, check each alias when building and invalidating the cache. Umbraco's [multisite setup guide](https://docs.umbraco.com/umbraco-cms/tutorials/multisite-setup) covers domain mapping in the backoffice.
## Final thoughts
The Ultimate Site Structure has lasted because it models the site more accurately. The site node is the stable boundary for domains, cultures, settings, and routing. The frontpage is a page that can change without reorganizing the rest of the content tree.
The 2010 solution needed a modern routing implementation, but it did not need a new structure. A small URL provider, a content finder, and a carefully invalidated cache are enough to keep the pattern useful in current Umbraco projects.
---
# Make Razor Partials Navigable in VS Code
URL: https://skttl.dev/blog/go-to-partial/
Date: 2026-07-09
Description: A small VS Code extension that adds Go to Definition and live diagnostics for Razor partial view references in .cshtml files.
Tags: vscode, razor, aspnet, umbraco, developer-tools
I spend a lot of time in `.cshtml` files. That usually means jumping between views, partials, layout files, blocks, macros, and whatever naming convention the project has accumulated over the years.
Most of that navigation works well enough in VS Code. But partial views have always had one small annoyance: the editor can show me the string `"_Header"` inside `@Html.Partial("_Header")`, but it does not know where that file actually lives.
So I built [Go To Partial](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-go-to-partial), a small VS Code extension that adds Go to Definition support for Razor partial view references.
The source is available on [GitHub](https://github.com/skttl/vsc-go-to-partial), and it is also published on [Open VSX](https://open-vsx.org/extension/skttl/vsc-go-to-partial).
## The problem
Razor partials are usually referenced by name:
```html
@Html.Partial("_Hero")
@await Html.PartialAsync("_ArticleCard")
```
That is perfectly normal ASP.NET MVC and Razor Pages code, but it leaves a gap in the editor experience. When I want to inspect `_ArticleCard`, I either search for the file manually or use the file picker and hope the partial name is unique enough.
On smaller projects this is just a minor interruption. On Umbraco projects, especially older ones, partials can live in several places:
- next to the current view
- in `Views/Shared/`
- in `Views/Partials/`
- in `Pages/Shared/`
- inside an Area
That means the string in the `.cshtml` file is not just text. It is a reference. I wanted VS Code to treat it like one.
## What Go To Partial does
After installing the extension, you can press `F12` or `Ctrl+Click` a supported partial reference and jump directly to the resolved `.cshtml` file.
It supports the common helper methods:
```html
@Html.Partial("_Name")
@Html.PartialAsync("_Name")
@await Html.PartialAsync("_Name")
@Html.RenderPartial("_Name")
@Html.RenderPartialAsync("_Name")
```
It also supports the built-in partial tag helper:
```html
```
Both short names and rooted paths work:
```html
@await Html.PartialAsync("_ArticleCard")
@await Html.PartialAsync("~/Views/Shared/_ArticleCard.cshtml")
```
For short names, the extension follows the expected ASP.NET lookup pattern. It checks the current folder first, then shared and partial folders, and it also handles `Areas/{areaName}/Views/Shared/` when the current file is inside an Area.
## Live diagnostics
Navigation is useful when the reference exists. Broken references are the other half of the problem.
Go To Partial also adds live diagnostics for unresolved partial references. If a partial cannot be found, VS Code shows a warning squiggle on the reference.
That helps catch the boring mistakes early:
- a renamed partial
- a missing underscore
- a typo in a string literal
- a deleted view that is still referenced somewhere
- a partial moved from `Views/Partials/` to `Views/Shared/`
It is intentionally simple. It does not try to compile Razor, understand every possible C# expression, or become a full language server. It looks for the common static patterns and makes those nicer to work with.
## Custom tag helpers
Some projects wrap partial rendering in custom tag helpers. For example:
```html
```
You can tell the extension about those element names in your workspace settings:
```jsonc
{
"goToPartial.additionalPartialTagNames": ["ec-partial"]
}
```
If your tag helper uses a custom attribute instead of `name`, you can configure that too:
```html
```
```jsonc
{
"goToPartial.additionalTagHelperAttributes": ["view-name"]
}
```
With that in place, `view-name="_Navigation"` becomes navigable in the same way as a regular partial reference.
## Installing
You can install it from the VS Code Extensions panel by searching for **Go To Partial**.
Or install it directly from:
- [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-go-to-partial)
- [Open VSX Registry](https://open-vsx.org/extension/skttl/vsc-go-to-partial)
If you prefer installing from a `.vsix` file, grab the latest release from [GitHub Releases](https://github.com/skttl/vsc-go-to-partial/releases), then run **Extensions: Install from VSIX...** from the Command Palette.
## When it helps
This is one of those tools that does not change how you build a site. It just removes a small bit of repeated friction.
I find it most useful when working in:
- Umbraco sites with many partial views
- ASP.NET MVC projects with old view conventions
- Razor Pages projects with shared page fragments
- projects using custom tag helpers for rendering view fragments
It is also handy when reviewing code. If I see:
```html
@await Html.PartialAsync("_ProductTeaser", product)
```
I can jump straight into the partial instead of searching for it, checking whether it lives under `Views/Shared`, `Views/Partials`, or somewhere nearby.
## Small Tools, Less Friction
Go To Partial is deliberately small. It adds editor navigation and warnings for a set of Razor patterns that I hit often, and it stays out of the way when those patterns are not present.
If you work in `.cshtml` files and partial views are part of your daily route through a codebase, it should save a few tiny interruptions. Sometimes that is enough to make the editor feel a little more connected to the code you are actually reading.
---
# Small UX Details Make AI Coding Better
URL: https://skttl.dev/blog/small-ux-details-ai-coding/
Date: 2026-06-30
Description: After trying Devin, Windsurf, Codeium, Claude Code, and now the Codex desktop app, I keep coming back to the same thing: small interface details matter a lot when AI is writing code.
Tags: ai, codex, umbraco, developer-tools
For the past few months, I have been using Devin (the artist formerly known as Windsurf, formerly known as Codeium), plus a few detours through Claude Code and other AI coding tools. I was generally happy with them.
Then a colleague lured me into trying the Codex desktop app.
I was probably also getting slightly confused by the different agent and editor modes in Devin. Not in a dramatic "I can no longer operate a computer" way. More in the familiar developer way where you know the feature you need exists somewhere, but your brain has already wandered off before you find the right button.
I have only used Codex for about a week, mostly on Umbraco projects, so this is not a grand verdict. It is more of a first impression from someone who has spent a lot of time letting AI write code, then trying to understand whether the code is something I actually want to keep.
And my main takeaway so far is simple: small UX details make AI coding much better.
## Projects in the sidebar
In Codex, my projects are just there in the sidebar.
That sounds almost too basic to mention, but it makes a real difference. I can switch between project-specific chats without having to mentally reconstruct where I was. There is also a general chat for things that are not tied to a repository.
That could be something useful, like:
> Do I have access to the Figma MCP server?
Or something more practical:
> Should I walk, or take the car to the car wash?
The useful bit is that I do not have to keep the whole map in my head. Project work lives with the project. Random questions live somewhere else. When you work across multiple client projects, package ideas, blog drafts, and experiments, that little bit of structure removes friction.
I had even started building small VS Code extensions to improve the editors I was already using, including Devin and the many other VS Code-shaped editors. [Project Lobby](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-project-lobby) gives me a start page for jumping between repositories. [Go To Partial](https://marketplace.visualstudio.com/items?itemName=skttl.vsc-go-to-partial) adds Go to Definition support for Razor partial references in `.cshtml` files, mostly because that felt like a feature that should exist.
Neither of them are huge ideas. They are small quality-of-life improvements. Project Lobby solves one of the workflow papercuts that Codex also happens to handle nicely: project switching and keeping an overview.
## Vertical chat tabs are underrated
Another tiny thing: chat tabs are listed vertically.
This should not feel like a revelation, but after a long AI coding session it really does. Horizontal tabs quickly become a row of truncated titles where the important part is always the part you cannot see:
- "Update Umbraco..."
- "Update Umbraco..."
- "Update Umbraco..."
- "Continue from..."
- "Continue from..."
- "Fix review..."
- "Fix review..."
Those might be seven completely different tasks. One might be a package update, one might be a content model change, one might be a build fix, and one might be the thread where I finally explained the weird edge case properly.
At that point, finding the right thread becomes more guessing than navigation.
With vertical tabs, I can scan the list properly. Codex is also reasonably good at naming threads, which helps more than I expected. I do not need the interface to be clever all the time. I just need it to keep me oriented while I am juggling a task, a diff, a terminal, and a half-remembered requirement from three hours ago.
## Code review feels built in
This is the part that has made the biggest difference for me.
Codex opens a panel on the right with the changes it has made. I can go through them, line by line, and add comments directly on the code. Codex can then pick up those comments and continue from there.
That workflow feels much closer to how I want AI coding to work.
Not:
1. Ask AI to implement something
2. Receive a pile of code
3. Try to understand what happened
4. Hope I catch the weird parts before committing
But more:
1. Define a contained task
2. Let the agent work
3. Review the changes in context
4. Leave specific comments
5. Let it adjust the implementation
That is especially useful on Umbraco projects, where a change is often isolated enough to describe clearly, but still specific enough that I need to check the details. Backoffice extensions, package code, content model tweaks, build scripts, deployment helpers, these are all places where AI can move quickly, but where I still need to stay in control.
The review experience makes that easier.
It also helps reduce AI slop. Not because the model magically stops producing it, but because the interface makes it easier to catch, challenge, and reshape the output before it becomes part of the codebase.
## Git is close enough to use
The Git integration is also simple in the right way.
When the changes look good, committing them is only a click or two away. Codex can stage the relevant changes and write a commit message, or I can choose exactly what should be staged.
Again, none of this is impossible elsewhere. It is just close enough to the work that I actually use it.
That matters. AI coding already creates a lot of small decisions:
- Is this task scoped well enough?
- Did the agent understand the existing patterns?
- Are the tests meaningful?
- Is this change too broad?
- Should this be one commit or three?
If the tool can remove a few bits of ceremony around the boring parts, I have more attention left for the code itself.
## The built-in terminal helps
Codex also has a built-in terminal, so I can run the project manually from the same place.
My colleague is already at the point where he just asks Codex to start the project. I am not quite there yet.
I still like typing the command myself. There is a small comfort in seeing `npm run dev`, `dotnet test`, or whatever else the project needs, and knowing exactly what is running. Maybe that will change. Maybe I just need a few more weeks before I fully accept my new colleague as the person operating the terminal.
For now, I like that both options exist. Codex can run commands, but I can also take the wheel when I want to.
## I miss adaptive mode
The one thing I miss from Devin is adaptive mode.
In Devin's model selector, adaptive mode can decide how much thinking a task needs and which model makes sense for it. That suits me very well, because I am distractible by nature. I will absolutely forget what I set the tool to.
And I am apparently patient enough that it can take a disturbingly long time before I notice I have had it on maximum thinking all day.
In Codex, I mostly just sit on GPT 5.5 all the time. In Devin's adaptive mode, I would probably bounce between Claude Opus, Claude Sonnet, and one of the cheaper models, like SWE or Kimi, depending on the task.
That kind of automatic model selection is not just about cost. It is also about rhythm. Some tasks need deep reasoning. Some tasks need a quick pass over a file. Some tasks are basically "rename this, update the tests, do not make a meal out of it."
I would love Codex to have a similar mode.
## This is not really tool versus tool
All of the things above are probably possible in other tools too. Whichever name or logo is currently being announced while I am still learning the previous one.
This is not meant as a "Codex wins, everyone else go home" post. That would be silly, and it would probably age badly before I had finished writing it.
What Codex has done for me, at least in this first week, is make the whole AI coding loop feel a little easier to stay inside:
- The projects are easy to find
- The threads are easier to scan
- The diff is always close
- Comments can become follow-up work
- Git is nearby
- The terminal is there when I need it
None of those are magic features. They are small product decisions. But together they make the difference between "AI wrote some code somewhere" and "I can actually work with this."
That is the part I care about.
AI coding tools are useful when they help me move faster without making me feel less responsible for the result. The model matters, of course. But the interface around the model matters too. Sometimes it matters more than I expected.
## Be careful who you let users dance with
I have only used Codex for a little under a week, so ask me again in a month and I may have defected to something else with a nicer sidebar, a better review flow, or a model selector that reads my mind.
As the legendary Danish football coach Richard Møller Nielsen said:
> You have to be careful about letting your wife dance with another man.
So yes, another tool may come along and dance with me instead of Codex very soon.
But for now, I am pretty happy.
---
# Browsing Umbraco Logs in Kudu
URL: https://skttl.dev/blog/browsing-umbraco-logs-in-kudu/
Date: 2026-05-26
Description: A new userscript that brings a proper Serilog log viewer into the Kudu interface: file picker, expression search, saved queries, and a momentum graph, all with no build step required.
Tags: umbraco, kudu, userscripts, devops
A while back I wrote [two userscripts that improve the Kudu interface for Umbraco Cloud](/blog/kudu-userscripts) (one for the Windows event log, one for deployment monitoring). Both make raw data that's already there a lot more usable.
The same problem exists for application logs. Umbraco writes structured logs in Serilog compact JSON format, one JSON object per line.
Finding the right file is already a needle-in-a-haystack problem before you've even opened it. On a long-running project there can be hundreds of log files in the directory, and Kudu's file listing caps out at a fixed number of entries, so older files simply don't show up, unless you "hack it" using javascript (by increasing the value of `maxViewItems` in localStorage to a huge number).
And when you do open one, you're staring at a wall of unformatted JSON.
Umbraco's backoffice already has a decent log viewer. It parses the same Serilog JSON, lets you filter by level, and supports expression queries. The problem is it's only available when the site is running. If your site won't start (a failed upgrade, a missing dependency, a bad config change), the backoffice is exactly what you can't reach. Kudu is still there, the log files are still there, but there's nothing to help you read them.
For Umbraco Cloud projects, you can download the file through the Cloud portal, but it's SLOW, and you still have to open it in a text editor - or the amazing [Compact Log Viewer](https://apps.microsoft.com/detail/9n8rv8lktxrj) by Warren Buckley.
So I built a third script: a log viewer that runs inside the Kudu tab you already have open, inspired by the backoffice Log Viewer but available even when the site is completely down.

## What it does
Installing the [Kudu Umbraco Log Viewer](https://github.com/skttl/umbraco-userscripts) adds an "Umbraco Logs" link to the Kudu navbar. Click it and you get:
- **File picker**: lists `.json` log files from `/umbraco/Logs/`, sorted newest first, grouped by month so large archives are scannable at a glance.
- **Parsed log entries**: each line is parsed from Serilog compact JSON. The `@mt` message template is rendered with its property values when the pre-rendered `@m` field is absent.
- **Expandable rows**: click any entry to see the exception stack trace, the raw message template, and all structured properties in a nested table.
- **Color-coded level labels**: Badges for Verbose, Debug, Information, Warning, Error, and Fatal.
- **Level filter**: a dropdown with per-level checkboxes.
- **Sort toggle** and **client-side pagination** at 100 entries per page.
## Expression search
The search box accepts either plain text (substring match) or a Serilog-compatible expression (the same syntax you'd use in the backoffice Log Viewer).
Some examples:
```
@Level='Error'
Not(@Level='Verbose') and Not(@Level='Debug')
@Message like '*timeout*' or @Level='Fatal'
MachineName='web01'
@Exception like '*SqlException*'
```
Supported: `@Level`, `@Message`, `@Exception`, `@mt`, `@t`, any structured property, operators `=` / `!=` / `like` (glob `*`/`?`) / `>` / `>=` / `<` / `<=`, and boolean `And` / `Or` / `Not(...)`.
There's also a **?** button that opens a cheat sheet with clickable example queries.
For queries you use often, the **★ Save** button persists named searches to `localStorage`.
## Momentum graph
Above the log table there's a stacked bar chart split into 60 time buckets covering the full span of the log file. Each bar is broken down by level.
The bars are clickable: clicking a bucket jumps to the page containing those entries.
For a large log file this gives you immediate orientation before you've typed a single query. You can see exactly when an error spike happened and click straight into it.
## Updates to the existing scripts
Both the Event Log Viewer and the Deployment Viewer got a v1.1 update alongside this release:
- **`@updateURL` and `@downloadURL`** added to all three scripts. Tampermonkey can now auto-update them without a reinstall.
- **`@match`** replaces `@include` for better cross-browser compatibility.
- **XSS fixes**: user-facing values rendered into `innerHTML` are now properly escaped in both scripts.
- **Deployment Viewer**: log line rendering was duplicated in three places; extracted into a single `renderLogLines` function. The `triggerNewDeployment` handler was also fixed: v1.0 fired a success toast on a `setTimeout` regardless of the response; v1.1 awaits the fetch and only toasts on the actual HTTP result.
## Installing
All three scripts are in the [umbraco-userscripts](https://github.com/skttl/umbraco-userscripts) repository. Each has a one-click install link in its README. You need [Tampermonkey](https://www.tampermonkey.net/) (available for Chrome, Firefox, and Edge).
The scripts are zero-dependency, single-file IIFEs. No build step, no npm. Install from the raw GitHub URL and you're done.
> **Tip:** With `@updateURL` in place, Tampermonkey will check for updates automatically. You can also trigger a manual check from the dashboard.
## Final Thoughts
I hope these scripts save you some time. They're simple, focused, and designed to work with the tools you already have. If you find them useful, great! If you have ideas for improvements or run into issues, feel free to open an issue on the repository.
🇩🇰👑 And a happy birthday to King Frederik X of Denmark!
---
# Smarter redirects when editors trash content in Umbraco
URL: https://skttl.dev/blog/smarter-redirects-when-editors-trash-content-in-umbraco/
Date: 2026-05-21
Description: A new Umbraco package that replaces the default trash action. It captures the URLs of doomed content first, then suggests redirect targets using Umbraco.AI.Search, with a non-AI fallback for bulk trashes.
Tags: umbraco, ai, package, hackathon, backoffice
Last week, a good handful of Umbraco developers met up at Ecreo's office in Odense for an Umbraco AI Hackathon, guided by the DevRel team from Umbraco HQ. One of the ideas Sebastiaan Janssen pitched on the day was an old one: when an editor trashes a published page, the URL it used to live on quietly stops working, and Umbraco doesn't help you redirect it anywhere.
I recognised the idea instantly. I'm fairly sure I saw an issue about exactly this on the old CodePlex tracker, somewhere around the time built-in URL tracking shipped in Umbraco 7.5. It's been on my mental wishlist for roughly a decade. A hackathon day felt like a good excuse to finally build it.
The result is a new package: [Umbraco.Community.SmartRedirectSuggester](https://marketplace.umbraco.com/package/umbraco.community.smartredirectsuggester).
## What it does
When an editor trashes a published document, the package replaces the default trash dialog with one that:
- captures the document's current URL (and the URLs of every published descendant, if you trash a parent)
- asks an Umbraco searcher to suggest likely redirect targets for each of those URLs
- lets the editor confirm, change, or skip each target
- registers the chosen redirects in Umbraco's built-in URL tracker after the trash succeeds

## The wrong turn: hooking into a post-trash event
My first instinct was to keep the standard trash flow intact and just react afterwards. My AI agent helpfully pointed at a client-side backoffice notification that fires once a document has been moved to the recycle bin. I wired up a modal to pop on that event and got something running quickly.
It took embarrassingly little testing to realise the obvious problem.
By the time my modal opened, the document was already in the recycle bin. Its published route was gone. `GetUrl(...)` had nothing to return. I could happily ask the user to pick a redirect *target*, but I'd already lost the redirect *source*. The very URL I needed to redirect from was gone. The thing I wanted to preserve had been forgotten between the click and the callback.
## The right turn: replacing the trash action
Instead of listening for a post-trash event, I needed to take over the trash action itself. The new backoffice makes that fairly clean. Entity actions are extension points, and an extension can declare that it overwrites an existing alias. Point that at the core `Umb.EntityAction.Document.RecycleBin.Trash` alias (and its bulk equivalent), reuse the same repositories and conditions as the original, and the extension registry quietly swaps my action in for the built-in one. Only the UX is replaced, not the authorisation rules.
With that hook in place, the flow becomes:
1. **Prepare.** When the user clicks "Trash", the action calls a `prepare` API endpoint that expands the selection to include published descendants, captures their current URLs, and asks for redirect suggestions. Crucially, this happens while the documents are still published and routable.
2. **Trash.** The action runs the standard trash logic and the document(s) move to the recycle bin.
3. **Commit.** Once the trash succeeds, the action calls a `commit` endpoint that registers the chosen redirects against the cached old URLs.
The legacy URLs are kept in a short-lived server-side cache between `prepare` and `commit`. That's how the package can still create redirects after the routes have technically vanished.
## AI suggestions, with a sensible fallback
For the actual suggestion step, the package asks Umbraco's search infrastructure for similar pages. It tries two searchers in order:
1. **`UmbAI_Search`**, provided by [Umbraco.AI.Search](https://www.nuget.org/packages/Umbraco.AI.Search). This is the preferred path. In my experience, AI-powered search matches "this page is about a discontinued product" to "this is the page about its successor" far better than keyword search ever does.
2. **`Umb_Content`**, provided by [Umbraco.Cms.Search](https://www.nuget.org/packages/Umbraco.Cms.Search). The non-AI fallback. Faster, free, and lower-quality, but still useful, especially for bulk operations.
If neither searcher is registered, the package still loads and the modal still opens. The editor just gets an empty suggestion list and can pick a target manually or skip the redirect.
## Bulk trashes and the 500-node moment
The package handles bulk trashes too. There's an entity bulk action on document collections, plus the implicit "trash a parent and take its descendants with it" case. Both go through the same `prepare` step, which means a single bulk trash can produce dozens or hundreds of suggestion lookups.
While testing, I trashed about 500 nodes in one go to see what would happen. It worked. The modal listed every URL with its suggested target and let me eyeball them.
Then I remembered I'd done all 500 lookups against `UmbAI_Search`.
I haven't gone back to look at my AI billing for that day, and I'd rather not. But this is the headline gotcha of the package: AI searchers are great for accuracy, and potentially expensive when you point them at a lot of content at once. The `Umb_Content` fallback exists partly *because* of this. It's a deliberate feature, not just a degraded mode.
> **Heads up:** if you're trashing a parent with a large subtree, be aware that every published descendant becomes a search call. With `UmbAI_Search` enabled, that's a real cost.
## How to install
The package needs Umbraco 17 and at least one searcher.
```bash
dotnet add package Umbraco.Community.SmartRedirectSuggester
dotnet add package Umbraco.Cms.Search
```
For AI-powered suggestions, also install:
```bash
dotnet add package Umbraco.AI.Search
```
That's it. Restart the site, trash a published page, and the new dialog takes over.



## What I'd change
A hackathon day buys you a working package. It doesn't buy you every nice-to-have. The thing I'd add next is a guard against the bulk-AI cost trap:
- A config switch to force `Umb_Content` regardless of whether `Umbraco.AI.Search` is installed.
- Or, better, a soft prompt in the modal when the bulk count crosses a threshold: *"You're about to ask AI Search for suggestions on 500 documents. Use the non-AI fallback instead?"*
Both feel more honest than just hoping editors notice the bill at the end of the month.
## Final thoughts
This is the kind of feature that's been sitting in Umbraco's "yeah, that'd be nice" pile for years, and it took a hackathon day with a clear pitch and good company to finally push it out the door. Thanks to the Umbraco HQ DevRel team for organising, to Sebastiaan Janssen for the idea, and to Ecreo for hosting.
The repo, with installation docs, screenshots and the full source, lives at [skttl/umbraco-smart-redirect-suggester](https://github.com/skttl/umbraco-smart-redirect-suggester). You are welcome to create an issue, especially if you have opinions on how the bulk-AI guardrails should look.
---
# Releasing Image Hotspot 17.0.0
URL: https://skttl.dev/blog/releasing-image-hotspot-17/
Date: 2026-05-18
Description: What's new in version 17.0.0 of the Image Hotspot property editor: Umbraco 17 support, block editor fixes, localization in 29 languages, and a new versioning scheme.
Tags: umbraco, package
A couple of things had been bothering me about the Image Hotspot package for a while. The image sometimes failed to appear in the editor due to a race condition, and the hotspot didn't work at all inside block editors. Version 17.0.0 fixes both of those, and picks up a few other improvements along the way.
## What's new
**Block editor support.** Previously the editor only resolved the image property from the document workspace context, which meant it broke when the hotspot was placed inside a Block List or Block Grid element. It now consumes `UMB_PROPERTY_DATASET_CONTEXT` first (which covers block items), then falls back to the parent dataset context, then the document workspace, and finally walks the document tree recursively until it finds a match.
**Race condition fix.** When the image property value changed quickly, or when multiple contexts resolved in quick succession, you could end up with the wrong image or no image at all. The load logic now uses a generation counter: each new load attempt increments the counter, and any async result that arrives after a newer attempt has started is simply discarded.
**UUI components throughout.** The editor UI now uses `uui-box`, `uui-tag`, and `uui-table` wherever it makes sense, rather than bare HTML elements. Previously, if the image property alias wasn't configured correctly, the editor would just render broken with no indication of why. It now shows a proper warning with a collapsible debug table that lists exactly where it looked and what it found.


**Localization in 29 languages.** I asked my AI agent to find all hardcoded strings in the UI and add proper localization for them. It replaced them with `` and generated locale files for all languages that ship with Umbraco. A task that would have taken an afternoon of copy-pasting, done in minutes.
## A new versioning scheme
The package has switched from rolling SemVer to an Umbraco-based scheme: `[UmbracoMajor].[PackageMajor].[PackageMinor]`.
So `17.0.0` means: targets Umbraco 17, first major release for that version, no minor changes yet.
The old scheme (`0.1.0`, `0.2.0`, `0.3.0`) worked fine until I needed to remember which version supported which Umbraco. I never could. Browsing NuGet for a compatible version meant checking the package description or release notes rather than just reading the number. The new scheme makes the minimum Umbraco dependency obvious at a glance, both when installing and when planning an Umbraco major upgrade.
Other community packages do the same. [ManifestPeek](https://www.nuget.org/packages/Umbraco.Community.ManifestPeek) and [SimpleWorkspaceViews](https://www.nuget.org/packages/Umbraco.Community.SimpleWorkspaceViews) both ship at 17.0.0 for Umbraco 17, and it makes sense to me.
> **Note:** This versioning scheme is not standard SemVer. If your tooling enforces strict SemVer, the major version bump on every Umbraco upgrade may look alarming. It just means "this release targets a new Umbraco major". Which to me is also quite breaking, and in need of a major version bump.
## Installing
```bash
dotnet add package Umbraco.Community.ImageHotspot
```
The package is available on [NuGet](https://www.nuget.org/packages/Umbraco.Community.ImageHotspot) and listed on the [Umbraco Marketplace](https://marketplace.umbraco.com/package/umbraco.community.imagehotspot). Source is on [GitHub](https://github.com/skttl/umbraco-image-hotspot).
## Final Thoughts
The block editor fix and the race condition fix were the main reasons for this release. Everything else came along for the ride once I was in there. The localization in particular is the kind of thing that's easy to skip when you're building a small package. It feels disproportionate to the effort, but with AI doing the translation work, there's not much reason not to.
---
# Two Umbraco Cloud CLI spinoffs: fetch DB and media into your local dev
URL: https://skttl.dev/blog/umbraco-cloud-fetch-database-and-media/
Date: 2026-05-04
Description: Two wizard CLIs that pull a fresh database and media folder from Umbraco Cloud straight into your local dev project - spinoffs from the archiver, useful on their own.
Tags: umbraco, umbraco-cloud, cli, devops
While building [`umbraco-cloud-archiver`](/blog/umbraco-cloud-archiver/) I realised I had 80% of the plumbing for a tool I actually wanted day-to-day: a one-shot way to refresh my **local dev environment** with real Cloud data, without doing the whole archive dance every time.
So I pulled the database and blob bits out into two small spinoff CLIs:
- [`umbraco-cloud-fetch-database`](https://www.npmjs.com/package/umbraco-cloud-fetch-database) - downloads the database from a Cloud environment and replaces the LocalDB `.mdf` / `.ldf` files of your local Umbraco project.
- [`umbraco-cloud-fetch-media`](https://www.npmjs.com/package/umbraco-cloud-fetch-media) - downloads the `media/` folder from a Cloud environment's blob storage into your project's `wwwroot/media`.
They are not companions to the archiver - they do completely different things. You may find great use in these two without ever needing the archiver, and vice versa.
## Try Umbraco Deploy first
Before I go any further: if you are on Umbraco Cloud, **Umbraco Deploy is the right tool for syncing schema and content between environments**. It is what it is built for, and it does it well. Always reach for that first.
The reason these CLIs exist is that on **large content-heavy sites**, pulling content via Deploy can take a while. Sometimes I just want a fresh snapshot of the database and a copy of the media folder on disk, fast, so I can debug a content-shaped bug, reproduce something a client is seeing, or onboard a new project to my laptop. That is the gap these two fill.
## umbraco-cloud-fetch-database
Run it from the root of your Umbraco web project (the folder that contains `*.csproj`):
```sh
npx umbraco-cloud-fetch-database@latest
```
The wizard:
1. Verifies you are inside an Umbraco web project.
2. Lets you pick an `appsettings.*.json` file (defaults to `appsettings.Development.json`).
3. Detects how LocalDB is configured in that file (more on that below).
4. Verifies the existing `.mdf` / `.ldf` files exist and are not locked.
5. Prompts for **server / login / password / database** of the source Cloud database.
6. Shows a summary and asks for confirmation.
7. Ensures `sqlpackage` is on PATH, or offers to download it into a per-user cache.
8. Backs up the existing `.mdf` / `.ldf` to `/backup//` (keeping the 3 most recent).
9. Detaches the local database from `(LocalDB)\MSSQLLocalDB`.
10. Exports a `.bacpac` from the Cloud server with `sqlpackage /Action:Export`.
11. Imports the bacpac into LocalDB under a temporary name with `sqlpackage /Action:Import`.
12. Detaches that temporary database and moves its `.mdf` / `.ldf` into the paths your project expects.
13. Cleans up the bacpac on success.
Umbraco re-attaches the database on next start because of `AttachDbFileName`.

### The two LocalDB shapes it detects
The tool recognises both common ways an Umbraco ASP.NET Core project can be configured to use LocalDB.
The classic connection-string shape:
```json
{
"ConnectionStrings": {
"umbracoDbDSN": "Server=(LocalDB)\\MSSQLLocalDB;AttachDbFileName=|DataDirectory|\\Umbraco.mdf;Integrated Security=True"
}
}
```
And the Deploy-driven shape, which is what you get on a Cloud-style local setup:
```json
{
"Umbraco": {
"Deploy": {
"Settings": {
"PreferLocalDbConnectionString": true
}
}
}
}
```
In the second case the tool assumes the standard Cloud project layout with the database files at `umbraco/Data/Umbraco.mdf` (and matching `.ldf`).
> **Note:** The Cloud SQL password is entered interactively at the prompt. It is never written to disk or to any config.

## umbraco-cloud-fetch-media
Same story, different payload. Run it from the project root:
```sh
npx umbraco-cloud-fetch-media@latest
```
The wizard:
1. Verifies the current directory is an Umbraco web project.
2. Asks for the **container-level SAS URL** of the blob container (e.g. `https://.blob.core.windows.net/?sv=...`).
3. If `wwwroot/media` already contains files, asks whether to back it up first.
4. Shows a summary and asks for confirmation.
5. Ensures `azcopy` is available, or offers to download it into a per-user cache.
6. Optionally copies the current `wwwroot/media` to `/.media-backup//` (keeping the last 3 backups).
7. Deletes the existing `wwwroot/media` folder and recreates it empty.
8. Runs `azcopy copy "/media/*?" "" --recursive=true`.
The backup folder is `.`-prefixed and lives outside `wwwroot`, so the ASP.NET Core static file middleware will not serve it, and it is easy to add to `.gitignore`.

### Finding the SAS URL
In the Umbraco Cloud portal: open your project → environment → **Configuration** → **Connections** → **Blob Storage**. You can copy the
SAS url from there.

## Pitfalls and gotchas
A few things to be aware of:
- **Windows only.** Both tools refuse to run elsewhere. LocalDB is a Windows feature, and the media tool currently follows suit.
- **Run from the project root.** Both tools expect to find a `*.csproj` referencing `Umbraco.Cms*` in the current directory.
- **Stop the site first.** If Umbraco / IIS Express / `dotnet run` is still running, the `.mdf` is locked and the swap will fail. Kill the site before running the database tool.
- **The DB files must already exist.** The database tool *replaces* the existing `.mdf` / `.ldf`. If you have just cloned the repo and never started the project, there is nothing to replace yet - run Umbraco once first so it creates the files.
- **Container-level SAS only.** The media tool needs a SAS URL scoped to the **container**, not to a single blob. A blob-level SAS will not work.
- **Whitelist your IP.** Cloud SQL is firewalled. Add your current IP under the SQL server's firewall settings on the Connections page in the Cloud portal, otherwise `sqlpackage` will sit there until it times out.
- **Backups rotate to the last 3.** Both tools keep the three most recent backup folders and delete older ones. Treat them as a "let me undo the last refresh" safety net, not as long-term archives - that is what [`umbraco-cloud-archiver`](/blog/umbraco-cloud-archiver/) is for.
- **Password is interactive, never stored.** The SQL password is prompted for each run and is not persisted anywhere.
## Outside Umbraco Cloud
Although these tools are built around Umbraco Cloud's conventions, there is nothing Cloud-specific about the actual work they do. Any Umbraco ASP.NET Core project with a reachable SQL Server and an Azure blob container can be the source.
The catch is the **detection logic**: both tools sniff `appsettings.*.json` and the project layout assuming a Cloud-style setup. If your self-hosted project uses a different connection string shape, a different media path, or stores blobs somewhere else, the wizard may not recognise it. In that case you will probably get further by extending the tool than by working around it - PRs welcome.
## Final thoughts
The archiver was the "this old project is being retired, save everything" tool. These two are the everyday-dev versions: one `npx` for the database, one `npx` for the media, and your local site is running on a fresh Cloud snapshot. Use Deploy as the primary way to sync content - but when you just need the bits on disk in a hurry, these are nice to have around.
Source, issues and PRs are on GitHub:
- [skttl/umbraco-cloud-fetch-database](https://github.com/skttl/umbraco-cloud-fetch-database)
- [skttl/umbraco-cloud-fetch-media](https://github.com/skttl/umbraco-cloud-fetch-media)
And if you want the whole-project archive flavour, that one lives over at [skttl/umbraco-cloud-archiver](https://github.com/skttl/umbraco-cloud-archiver). Feedback very welcome on all three.
---
# I built a CLI to archive Umbraco Cloud projects
URL: https://skttl.dev/blog/umbraco-cloud-archiver/
Date: 2026-04-29
Description: When a client upgrade meant retiring an old Umbraco Cloud project, I wanted a complete local archive before pulling the plug. So I built a wizard CLI to do it.
Tags: umbraco, umbraco-cloud, cli, devops
I recently finished upgrading a client from Umbraco 8 all the way to Umbraco 17. Because of the switch from old ASP.net Framework in Umbraco 8 to the modern ASP.net Core in Umbraco 17, the only path was a fresh build on a brand new Umbraco Cloud project. Once the v17 site was live, the old v8 Cloud project had to go.
Before clicking that scary "delete project" button, I wanted a safety net. Not just a SQL backup, but **everything**: the full git history, the media blobs, and the database. Future me might want to dig something up - a content snippet, a piece of code, an asset I forgot we had - and "we deleted it" is a terrible answer.
So I built a small CLI to do exactly that, and published it to npm: [`umbraco-cloud-archiver`](https://www.npmjs.com/package/umbraco-cloud-archiver).
## What "archive" actually means
A Cloud project has three things worth keeping, and each has its own quirk:
- **Git repository** - Cloud gives you a git remote per environment. You want both a `--mirror` clone (full history, all branches, all tags) and a working copy you can actually browse without git commands.
- **Blob storage** - The media files live in Azure Blob Storage. You can grab them with a container-level SAS URL from the Cloud portal.
- **Database** - The trickiest one. You can either export a `.bacpac` with `sqlpackage` if you have SQL credentials, or download the latest backup manually from the Cloud portal.
Doing all of that by hand, for `live`, `stage`, and `dev`, is the kind of repetitive task I'd rather automate once than do carefully three times.
## Introducing umbraco-cloud-archiver
It's a wizard-based CLI. One command, no flags to remember:
```sh
npx umbraco-cloud-archiver@latest
```
It walks you through the archive step by step, asks for the bits it needs, and dumps everything into a tidy folder structure on disk.

## How to use it
The wizard has four phases:
1. **Pick an output base folder.** Everything ends up here, one subfolder per environment.
2. **Choose database backup mode.** Either skip the DB entirely, or use `sqlpackage` to export a `.bacpac`. If you skip, the tool drops a `MANUAL_BACKUP_REQUIRED.txt` reminder so you don't forget to grab it from the portal.
3. **Add environments one by one.** For each environment you give it a name (`live`, `stage`, `dev`), the git clone URL, the blob storage SAS URL, and optionally DB credentials. After each one it asks "add another?" until you're done.
4. **Confirm and run.** It prints a summary, you confirm, and it gets to work.
> **Tip:** You can find the SQL connection details and container-level SAS URL in the Umbraco Cloud portal under the environment's Configuration section, on the Connections page. Remember to whitelist your IP address in the SQL server's firewall settings before running the wizard. This is done on the Connections page too. The git clone URL is on the environment overview page.
If `azcopy` or `sqlpackage` aren't on your PATH, the tool offers to download them into a per-user cache (`~/.umbraco-cloud-archiver/bin/`) so you don't have to install anything globally.

## What you end up with
```
/
live/
git-mirror/ # bare clone, full history
repo/ # working copy (browseable)
blobs/ # blob storage contents
database/
.bacpac # if sqlpackage was used
MANUAL_BACKUP_REQUIRED.txt # otherwise
stage/
...
archive-info.json # metadata about this run
```
A self-contained snapshot of the whole project that you can zip up, drop on a NAS, hand to the client, or just keep around in cold storage.

## Prerequisites
- **Node.js 20+**
- **git** in your PATH
- A **container-level SAS URL** per environment (Umbraco Cloud → environment → Storage)
- For DB export: SQL Server credentials with permission to export, *or* grab the backup manually from the Cloud portal
## Final thoughts
This started as a one-off script for a single client shutdown, but it solved the problem cleanly enough that I figured I'd polish it up and share it. If you're about to retire an old Umbraco Cloud project - whether because of an upgrade, a migration, or a project ending - give it a try and let me know how it goes.
Source, issues and PRs are on GitHub: [skttl/umbraco-cloud-archiver](https://github.com/skttl/umbraco-cloud-archiver). Feedback very welcome.
---
# Auto-Register Block Previews in Umbraco
URL: https://skttl.dev/blog/auto-register-block-preview-in-umbraco/
Date: 2026-04-17
Description: Skip manual registration and let your project automatically discover which blocks should have preview enabled based on existing views.
Tags: umbraco, csharp
The [Block Preview package](https://github.com/rickbutterfield/BlockPreview) by Rick Butterfield is a fantastic addition to any Umbraco project using Block List or Block Grid. It gives editors a real-time preview of their blocks right in the backoffice, instead of a generic placeholder.
By default, the package wants to preview all blocks, which might be fine for some. But I use block editors for lots of other things, where a preview doesn't make sense.
So you can configure which blocks to preview, but that's still manual work and as your project grows and you add more blocks, you need to keep remembering to register each one in your `BlockPreviewOptions`. That's easy to forget, and it means developers less comfortable with C# need to touch configuration code every time they create a new block.
What if registration just... happened automatically?
## The Idea
The Block Preview package looks for views in specific default locations. If you follow those conventions, there's no reason you should also have to manually tell the package which blocks to enable - it should be able to figure that out itself.
My approach:
1. Scan the default view locations for `.cshtml` files
2. Also check for precompiled Razor views in the assembly
3. Match file names to content type aliases
4. Register only the blocks that have a corresponding view
This way, adding a new block with a preview is as simple as creating the view file. No C# changes required.
## The Extension Method
Here's an extension method that replaces the standard `AddBlockPreview()` registration:
```csharp
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Reflection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Extensions;
using BlockPreview;
using BlockPreview.Settings;
public static class AddBlockPreviewsExtension
{
public static IUmbracoBuilder AddBlockPreviews(this IUmbracoBuilder builder)
{
var stylesheet = new string[] { "/build/css/main.css" };
builder.AddBlockPreview();
builder.Services.AddSingleton>(sp =>
{
var env = sp.GetRequiredService();
var contentTypeService = sp.GetRequiredService();
var contentTypeAliases = contentTypeService.GetAllContentTypeAliases().ToList();
return new ConfigureOptions(options =>
{
options.RichText = GetSettings(
env,
contentTypeAliases,
BlockPreviewConstants.DefaultViewLocations.RichText,
stylesheets);
options.BlockGrid = GetSettings(
env,
contentTypeAliases,
BlockPreviewConstants.DefaultViewLocations.BlockGrid,
stylesheets);
options.BlockList = GetSettings(
env,
contentTypeAliases,
BlockPreviewConstants.DefaultViewLocations.BlockList,
stylesheets);
});
});
return builder;
}
private static BlockWithStylesheetSettings GetSettings(
IWebHostEnvironment hostingEnvironment,
List contentTypeAliases,
string defaultViewLocation,
List? viewLocations = null,
string[]? stylesheets = null)
{
viewLocations = viewLocations.EmptyIfNull().ToList();
var contentTypes = GetViewNames(
hostingEnvironment,
contentTypeAliases,
viewLocations,
defaultViewLocation);
return new()
{
ViewLocations = viewLocations,
ContentTypes = contentTypes,
Enabled = contentTypes.Any(),
Stylesheets = stylesheets
};
}
private static List GetViewNames(
IWebHostEnvironment hostingEnvironment,
List contentTypeAliases,
List? viewLocations,
string? defaultLocation = null)
{
var locations = (viewLocations ?? [])
.Append(defaultLocation)
.WhereNotNull()
.Select(CleanPath)
.Select(x => x.TrimEnd("{0}.cshtml"))
.ToList();
// Find view files on disk
var files = locations
.Select(hostingEnvironment.MapPathWebRoot)
.Where(Directory.Exists)
.SelectMany(location =>
Directory.EnumerateFiles(location, "*.cshtml", SearchOption.AllDirectories))
.Select(CleanPath)
.WhereNotNull();
// Find precompiled Razor views in the assembly
var assembly = Assembly.GetExecutingAssembly();
var allRazorViews = assembly
.GetCustomAttributes()
.Select(x => x.Identifier)
.Select(CleanPath)
.WhereNotNull();
var razorViews = locations
.SelectMany(location => allRazorViews
.Where(view => view.InvariantStartsWith(location)));
// Match view names to content type aliases
return ((List)[.. files, .. razorViews])
.Select(Path.GetFileNameWithoutExtension)
.Select(view => contentTypeAliases
.FirstOrDefault(alias => alias.InvariantEquals(view)))
.WhereNotNull()
.ToList();
}
private static string CleanPath(string path)
{
return path
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar)
.EnsureStartsWith(Path.DirectorySeparatorChar);
}
}
```
## How It Works
The extension method does three things:
1. **Calls the standard registration** - `builder.AddBlockPreview()` still sets up the package as normal.
2. **Configures options via DI** - By registering an `IConfigureOptions`, we can modify the options after the default setup, with access to Umbraco services.
3. **Auto-discovers content types** - For each editor type (Block Grid, Block List, Rich Text), it scans the default view locations and matches file names to content type aliases.
The `GetViewNames` method handles both scenarios:
- **Views on disk** in `wwwroot` (development)
- **Precompiled Razor views** baked into the assembly (production/deployment)
# Using It
Replace your existing Block Preview registration in `Program.cs` or your composer:
```csharp
// Before
builder.AddBlockPreview(options =>
{
options.BlockGrid.ContentTypes = ["heroBlock", "textBlock", "imageBlock"];
options.BlockGrid.Stylesheets = ["/build/css/main.css"];
});
// After
builder.AddBlockPreviews();
```
That's it. Now any block with a view in the default location gets preview automatically.
## Customization
You might want to customize the stylesheet path or add additional view locations. Simply modify the extension method:
```csharp
public static IUmbracoBuilder AddBlockPreviews(
this IUmbracoBuilder builder,
string[] stylesheets = ["/css/main.css"])
{
// ... use the stylesheet parameter
}
```
Or if you have blocks that should explicitly *not* have preview (like a tracking code snippet block), you could add an exclusion list:
```csharp
var excludedBlocks = new[] { "htmlSnippetBlock", "trackingCodeBlock" };
var contentTypes = GetViewNames(/* ... */)
.Where(x => !excludedBlocks.InvariantContains(x))
.ToList();
```
## Benefits
- **Zero maintenance** - New blocks with views are automatically registered
- **Developer-friendly** - Frontend developers can add block views without touching C#
- **Convention over configuration** - Follow the default view locations and everything just works
- **No forgotten registrations** - If a view exists, it's registered
## Final Thoughts
This approach trades explicit configuration for convention-based discovery. For teams where multiple developers work on blocks, this removes a common friction point and source of "why isn't my preview working?" questions.
---
# Editor Friendly 404 Pages in Umbraco
URL: https://skttl.dev/blog/editor-friendly-404-page-in-umbraco/
Date: 2026-04-09
Description: How to create customizable 404 pages that editors can manage, with support for multi-site setups using a ContentLastChanceFinder.
Tags: umbraco, csharp, contentfinder
When a visitor lands on a page that doesn't exist, a helpful 404 page can make all the difference. But hardcoded error pages are a maintenance burden and don't give editors the flexibility they need. Let's fix that by creating editor-friendly 404 pages in Umbraco.
## The Site Structure
This approach builds on the ["ultimate" site structure](https://cultiv.nl/blog/tip-of-the-week-the-ultimate-site-structure-setup/) pattern, where each site has a root node containing site-wide settings. On this site node, we add a content picker property that lets editors choose which page should be displayed when a 404 occurs.
The property setup is simple:
- **Property name**: Not Found Page
- **Alias**: `notFoundPage`
- **Type**: Content Picker
This gives editors full control over the 404 experience. They can create a dedicated "Page Not Found" content page with custom messaging, helpful links, search functionality, or whatever else might help visitors find their way.
## The ContentLastChanceFinder
Umbraco's `IContentLastChanceFinder` is called when no other content finder can match the requested URL. This is our hook for serving the custom 404 page.
The challenge with multi-site setups is figuring out *which* site's 404 page to show. A request to `/dk/some/nonexistent/page` should show the Danish site's 404, while `/en/missing` should show the English one.
The solution: find the closest ancestor of the requested URL in the content tree, then traverse up to find the site node and its configured 404 page.
```csharp
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
public class NotFoundContentFinder : IContentLastChanceFinder
{
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
public NotFoundContentFinder(IUmbracoContextAccessor umbracoContextAccessor)
{
_umbracoContextAccessor = umbracoContextAccessor;
}
public Task TryFindContent(IPublishedRequestBuilder request)
{
if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext))
{
return Task.FromResult(false);
}
var notFoundPage = FindNotFoundPage(request, umbracoContext);
if (notFoundPage is null)
{
return Task.FromResult(false);
}
request.SetPublishedContent(notFoundPage);
request.SetResponseStatus(404);
return Task.FromResult(true);
}
private IPublishedContent? FindNotFoundPage(
IPublishedRequestBuilder request,
IUmbracoContext umbracoContext)
{
var closestPage = GetClosestPageFromRequest(request, umbracoContext);
if (closestPage is null)
{
// Fallback: try to get from the first root node
var root = umbracoContext.Content?.GetAtRoot().FirstOrDefault();
return root is not null ? GetNotFoundPageFromAncestors(root) : null;
}
// Found the closest ancestor, now find the 404 page
return GetNotFoundPageFromAncestors(closestPage);
}
private IPublishedContent? GetClosestPageFromRequest(IPublishedRequestBuilder request, IUmbracoContext umbracoContext)
{
// Try to find the closest ancestor based on the URL segments
var path = request.AbsolutePathDecoded.Trim('/');
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
// Start from the full path and work backwards
for (var i = segments.Length; i >= 0; i--)
{
var testPath = "/" + string.Join("/", segments.Take(i));
var content = umbracoContext.Content?.GetByRoute(testPath);
if (content is not null)
{
return content;
}
}
return null;
}
private IPublishedContent? GetNotFoundPageFromAncestors(IPublishedContent content)
{
// Traverse up to find a node with the notFoundPage property
var current = content;
while (current is not null)
{
var notFoundPage = current.Value("notFoundPage");
if (notFoundPage is not null)
{
return notFoundPage;
}
current = current.Parent;
}
return null;
}
}
```
## Registering the ContentLastChanceFinder
Register the finder in your composer:
```csharp
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
public class NotFoundComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.SetContentLastChanceFinder();
}
}
```
## How It Works
1. A visitor requests `/products/nonexistent-item`
2. Umbraco's content finders fail to match the URL
3. Our `NotFoundContentFinder` kicks in
4. It tries `/products/nonexistent-item`, then `/products`, then `/`
5. When it finds `/products` exists in the content tree, it has the closest ancestor
6. It traverses up from `/products` looking for a `notFoundPage` property
7. The site node has the property set, so it returns that page
8. Umbraco renders the 404 page with a 404 status code
## Multi-Site Benefits
This approach shines in multi-site setups:
- **Each site can have its own 404 page** - Different branding, language, and content
- **Editors manage everything** - No developer intervention needed to update 404 content
- **Automatic site detection** - The correct 404 page is served based on where in the content tree the request would have been
## Final Thoughts
By combining a simple content picker property with a `ContentLastChanceFinder`, you give editors full control over the 404 experience while ensuring visitors always see the right page for the site they're on. It's a small investment that pays off in maintainability and editor happiness.
---
# Umbraco Skills for AI Agents - A Game Changer for Backoffice Development
URL: https://skttl.dev/blog/umbraco-skills-ai-agents/
Date: 2026-02-04
Description: How the new Umbraco Backoffice Skills repository supercharges AI agents like Windsurf/Cascade to understand and generate Umbraco backoffice extensions
Tags: umbraco, ai, windsurf, backoffice, property-editors

Umbraco has released a repository called [Umbraco-CMS-Backoffice-Skills](https://github.com/umbraco/Umbraco-CMS-Backoffice-Skills) that you can install in any AI agent. It essentially teaches your AI assistant everything about Umbraco backoffice development.
## Getting Started
Installation is straightforward:
```bash
npx skills add umbraco/Umbraco-CMS-Backoffice-Skills
```
This will prompt you to select which AI agents you want to install the skills for (like Windsurf, Cursor, or others) and whether the skills should be installed globally or just for your current project.
Once installed, your AI agent gains deep knowledge of the new Umbraco backoffice architecture, including:
- Property editor development
- Context APIs
- Extension registration
- UUI component library
- And much more
## A Real-World Test
I recently needed to migrate a custom property editor to the new backoffice. The editor reads blocks from a Block List elsewhere on the page and presents them as checkboxes for selecting "related blocks" - useful for things like feature comparison matrices.

The challenge? In the new backoffice, the checkbox list needs to **automatically update** when the source Block List changes. That's not trivial - you need to observe the right contexts and react to changes.
With the skills installed in Windsurf, I created a plan first, reviewed it, then had Windsurf implement the entire property editor in one go:
> I need to create a new property editor for Umbraco. It should be used to select references to blocks from another block list on the current page. For data type configuration, the user needs to input the alias of the block list, from where references are picked. And there should be an input where the user can specify the alias of the field containing the referenced blocks name. When showing the property editor, it should find the block list from the current page, and list the blocks available for picking as a check box list. It should observe the block list, so when new blocks are added, the checkbox list gets updated automatically.
The AI generated a working solution on the first try. It only needed one additional iteration to handle the edge case of being used inside nested blocks.

What's even better? The new editor is actually an improvement over the original. In the old backoffice, users had to click a somewhat hidden refresh button when blocks were added or removed from the source Block List. The new implementation observes the block list value directly and updates the checkbox list automatically - a better user experience that came "for free" because the AI understood the reactive patterns in the new backoffice architecture.
## What Impressed Me
The resulting code is clean - about 176 lines for the main `BlockReferencePickerElement`. But what's remarkable is **how the AI figured out which contexts to use**.
Finding the right context APIs in the new backoffice can be tricky. There's `UmbDocumentWorkspaceContext`, block contexts, property contexts - it's easy to get lost. The AI, armed with the Umbraco skills, navigated this complexity without any hand-holding.
It also correctly used UUI (Umbraco's UI library) for the checkboxes. This is something I've struggled with before when asking AI to generate Umbraco backoffice code - it would often fall back to generic HTML or other component libraries.
## Final Thoughts
If you're doing any Umbraco backoffice development with AI assistance, install these skills. The difference is night and day compared to generic AI assistance.
The skills bridge the knowledge gap between what AI models learned during training and the relatively new Umbraco backoffice architecture. Instead of generating outdated AngularJS code or making up APIs that don't exist, your AI actually understands the current patterns and can generate working code.
This is exactly the kind of tooling that makes AI-assisted development practical for specialized domains like Umbraco.
---
# Customizing Umbraco ModelsBuilder Output
URL: https://skttl.dev/blog/customize-umbraco-models-builder-output/
Date: 2026-02-02
Description: How to modify what Umbraco ModelsBuilder generates by creating a custom ModelsGenerator - adding property alias constants and more.
Tags: umbraco

Umbraco's ModelsBuilder is a fantastic tool that generates strongly-typed models from your document types. But what if you want to customize the generated output? Maybe you want to add property alias constants or other custom code to your models.
In this article, I'll show you how to create a custom `ModelsGenerator` that post-processes the generated files to add your own customizations.
## The Problem
When working with Umbraco, you often need to reference property aliases as strings - for example, when using `SetValue()` while creating or editing content through the ContentService, or maybe you need the dynamic property access from `IPublishedContent`. This means scattering magic strings throughout your codebase:
```csharp
// setting a property value on IContent
var title = content.SetValue("pageTitle", "New Title");
// checking for a property on IPublishedContent
var items = content.Children.Where(x => x.HasValue("featuredImage"));
```
If a property alias changes, you'll need to hunt down every occurrence. It would be much better to have strongly-typed constants for each property alias, so you get compile-time safety and IntelliSense support.
ModelsBuilder does provide a way to get the property aliases from the document type, but it is rather verbose, and it requires a dependency on `IPublishedContentTypeCache`:
```csharp
@using Umbraco.Cms.Core.PublishedCache
@inject IPublishedContentTypeCache _contentTypeCache;
@{
var blocksPropertyAlias = ContentPage.GetModelPropertyType(_contentTypeCache, x => x.Blocks)?.Alias;
}
```
## The Solution
We can create a custom `ModelsGenerator` that runs after the standard generation and post-processes the output files. This approach:
- Doesn't require modifying Umbraco's source code
- Works with the existing ModelsBuilder pipeline
- Allows for any kind of text manipulation on generated files
Here's the full implementation:
```csharp
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using System.Text;
using System.Text.RegularExpressions;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Infrastructure.ModelsBuilder;
using Umbraco.Cms.Infrastructure.ModelsBuilder.Building;
using Umbraco.Extensions;
namespace MyProject.ModelsGenerators;
public class MyOwnModelsGenerator(
UmbracoServices umbracoService,
IOptionsMonitor config,
OutOfDateModelsStatus outOfDateModels,
IHostEnvironment hostingEnvironment)
: ModelsGenerator(umbracoService, config, outOfDateModels, hostingEnvironment), IModelsGenerator
{
public new void GenerateModels()
{
base.GenerateModels();
var modelsDirectory = config.CurrentValue.ModelsDirectoryAbsolute(hostingEnvironment);
if (!Directory.Exists(modelsDirectory)) return;
foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs"))
{
var fileContents = File.ReadAllText(file);
var newFileContents = AddPropertyAliasConstants(fileContents);
if (newFileContents != fileContents)
{
File.WriteAllText(file, newFileContents);
}
}
}
private static string AddPropertyAliasConstants(string fileContents)
{
var marker = "public new const string ModelTypeAlias =";
var index = fileContents.IndexOf(marker, StringComparison.Ordinal);
if (index == -1) return fileContents;
var pattern = @"\[.*ImplementPropertyType\(""([^""]+)""\)\][\s\S]*?public\s+(?:virtual\s+)?[\w\.:<>]+\s+([A-Za-z0-9_]+)\s*(?:{|=>)";
var matches = Regex.Matches(fileContents, pattern);
var sb = new StringBuilder();
sb.Append("public new static class ModelPropertyAliases\n\t\t{\n");
foreach (Match match in matches)
{
var alias = match.Groups[1].Value;
var propName = match.Groups[2].Value;
sb.Append($"\t\t\tpublic const string {propName} = \"{alias}\";\n");
}
sb.Append("\t\t}\n\n\t\t");
return fileContents.Insert(index, sb.ToString());
}
}
```
## How It Works
The custom generator does a few key things:
### 1. Inheriting from ModelsGenerator
By inheriting from `ModelsGenerator` and implementing `IModelsGenerator`, we can override the generation behavior while still using all the built-in functionality.
```csharp
public class MyOwnModelsGenerator : ModelsGenerator, IModelsGenerator
```
### 2. Post-Processing Generated Files
The `GenerateModels()` method first calls `base.GenerateModels()` to run the standard generation, then iterates through all `*.generated.cs` files to apply our customizations:
```csharp
public new void GenerateModels()
{
base.GenerateModels();
var modelsDirectory = _config.ModelsDirectoryAbsolute(_hostingEnvironment);
foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs"))
{
// Post-process each file...
}
}
```
### 3. Adding Property Alias Constants
The `AddPropertyAliasConstants` method uses regex to find all properties marked with `[ImplementPropertyType]` and extracts both the alias and property name. It then generates a nested static class containing constants for each property:
```csharp
public new static class ModelPropertyAliases
{
public const string PageTitle = "pageTitle";
public const string FeaturedImage = "featuredImage";
public const string Blocks = "blocks";
}
```
## Registering the Custom Generator
To use your custom generator, you need to register it with Umbraco's dependency injection. Add this to your startup configuration:
```csharp
builder.Services.AddSingleton();
```
Or if you're using a composer:
```csharp
public class ModelsGeneratorComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.Services.AddSingleton();
}
}
```
## Using the Generated Constants
Once the models are regenerated, you can use the constants throughout your code:
```csharp
// Before: magic strings
content.SetValue("pageTitle", "New Title");
// After: strongly-typed constants
content.SetValue(HomePage.ModelPropertyAliases.PageTitle, "New Title");
// Works great in LINQ queries too
var featured = content.Children
.Where(x => x.HasValue(Article.ModelPropertyAliases.FeaturedImage));
```
Now if a property alias changes, you'll get a compile-time error instead of a runtime surprise.
## Final Thoughts
Customizing ModelsBuilder output gives you more control over your generated code without fighting against the built-in tooling. By post-processing the generated files, you can add compile-time safety features like property alias constants while keeping all the benefits of automatic model generation.
The approach shown here - inheriting from `ModelsGenerator` and processing files after generation - is clean, maintainable, and survives Umbraco upgrades since it doesn't modify any core code.
---
# Enhanced Event Logs and Deployment Monitoring for Kudu in Umbraco Cloud
URL: https://skttl.dev/blog/kudu-userscripts/
Date: 2026-01-20
Description: Two userscripts that make working with Kudu in Umbraco Cloud easier - view event logs and monitor deployments with enhanced interfaces.
Tags: umbraco, kudu, userscripts

When working with Umbraco Cloud, I occasionally need to access Kudu for debugging and deployment tasks. You can access Kudu by clicking the "Kudu" button in your site's dashboard, or directly via the URL: `https://.scm..umbraco.io` (where the cloud region might be `euwest01`, for example).
When a site fails to start or a deployment fails without clear errors, the event log in Kudu can provide crucial insights. However, the raw experience leaves much to be desired.
To solve this problem, I created two userscripts that enhance the Kudu interface.
>
> **What is a userscript?**
>
> A userscript is a program, usually written in JavaScript, for modifying web pages to augment browsing. Uses include adding shortcut buttons and keyboard shortcuts, controlling playback speeds, adding features to sites, and enhancing the browsing history.
>
> To use a userscript, you need a userscript manager. There are several available, but the most popular is [Tampermonkey](https://www.tampermonkey.net/). Tampermonkey is available for Chrome, Firefox, and Edge.
>
## Visualizing Event Logs

First thing to look at is the event log of the site. The event log contains information from IIS, and tells you any problems when starting the site. It can also contain information about failed deployments, and other issues.
But, it's not the most user-friendly experience. It's a raw XML file, and you have to manually parse it.
- The event log is located at `/LogFiles/eventlog.xml`
- It can be a massive XML file with thousands of entries
- The most recent (and relevant) information is at the bottom
- There's no syntax highlighting or formatting
- You have to manually parse XML in your head
This userscript adds a new "Event Log" tab to the Kudu interface, providing a much better experience:
- **Parsed and formatted**: Events are displayed in a clean table format
- **Most recent first**: The latest events appear at the top (no more scrolling)
- **Color-coded**: Each event type has its own color for quick identification
- **Structured columns**: Event time, type, message, and source are clearly separated
The event log helps you diagnose issues like missing DLLs, IIS startup failures, configuration problems, and data-related errors.
### How to use it
1. Install the Kudu Event Log Viewer userscript from my [umbraco-userscripts](https://github.com/skttl/umbraco-userscripts) repository
2. Navigate to your Umbraco Cloud Kudu interface (e.g., `*.scm.euwest01.umbraco.io`)
3. Click the "Event Log" link in the navbar
4. Click "Load Event Log" to fetch and display events
5. Events are displayed in Bootstrap panels with full details
## Tracking Deployments

When using CI/CD with Umbraco Cloud, deployment visibility is limited compared to traditional git push deployments. You lose the immediate feedback from git logs about build status and deployment progress.
Normally, when a deployment fails, you need to manually navigate through files in `/site/deployments` to find:
- Which deployment is currently active
- Individual deployment folders with logs and status files
- Scattered information across multiple locations
The Deployment Viewer userscript consolidates all this information into a single dashboard:
- **Deployment status**: See the current state of all deployments
- **Live logs**: View logs for each deployment
- **Auto-refresh**: Enable live updates to monitor deployments in real-time
- **Manual triggers**: Start a new deployment without making a git change
### How to use it
1. Install the Umbraco Cloud Deployment Viewer userscript from my [umbraco-userscripts](https://github.com/skttl/umbraco-userscripts) repository
2. Navigate to your Umbraco Cloud Kudu interface (e.g., `*.scm.euwest01.umbraco.io`)
3. Click the "Deployments" link in the navbar
4. View the latest deployment status and log
5. Click "Auto-refresh" to enable live log and status updates
6. Click "Trigger New Deployment" to start a new deployment
7. Click on any deployment in the history table to view full details in a modal
8. Click file counts to view the deployment manifest
## Final Thoughts
While Umbraco Cloud's dashboard provides excellent visibility for most scenarios, these userscripts fill important gaps when you need to dig deeper. They transform Kudu from a basic file browser into a powerful debugging and deployment monitoring tool.
Both userscripts are available on [GitHub](https://github.com/skttl/umbraco-userscripts). Give them a try the next time you're troubleshooting a deployment issue or investigating a site failure.
---
# Make Umbraco’s Welcome Dashboard Your Own
URL: https://skttl.dev/blog/customize-umbraco-welcome-dashboard/
Date: 2026-01-16
Description: Umbraco’s default Welcome dashboard is useful for developers, but often irrelevant for clients. This article shows how to extend the Welcome dashboard by implementing INewsDashboardService, allowing agencies to display their own news, updates, and links directly inside the Umbraco backoffice.
Tags: umbraco

Umbraco comes with a default Welcome dashboard in the backoffice. It displays news from Umbraco HQ along with handy links to documentation, training, and other community resources.
That’s great for developers, but when you’re building Umbraco sites for clients, those links are often not the most relevant. In fact, the traditional recommendation has often been to simply remove the Welcome dashboard entirely.
But with the current backoffice (which I should probably stop calling *new* by now), there are plenty of extension points - and the Welcome dashboard is one of them.
So instead of removing it, why not use it for your own content?
## Extending the Welcome dashboard
The Welcome dashboard is powered by a service in the backend, and you can replace or extend its behaviour by implementing the `INewsDashboardService` interface in your own C# code.
Once you do that, Umbraco will use your implementation to populate the dashboard.
All you need to return is a `NewsDashboardResponseModel`, which contains a collection of items to display. Each item supports:
* A header
* Body text
* An image (URL + alt text)
* A link URL
* Button text
* A priority (High, Medium, or Low)
The dashboard will automatically sort the items by priority before rendering them.
## A (slightly useless) example implementation
As a simple demonstration, here’s an intentionally useless implementation that displays the weather from 12 random locations on Earth every time the dashboard loads.
```cs
using System.Text.Json;
using Umbraco.Cms.Api.Management.Services.NewsDashboard;
using Umbraco.Cms.Api.Management.ViewModels.NewsDashboard;
public class UselessNewsDashboardService : INewsDashboardService
{
private readonly HttpClient _httpClient;
private readonly AppCaches _appCaches;
public GiNewsDashboardService(HttpClient httpClient, AppCaches appCaches)
{
_httpClient = httpClient;
_appCaches = appCaches;
}
public async Task GetItemsAsync()
{
// fetch some data - this depends on your usecase!
var locations =
await _appCaches.RuntimeCache.GetCacheItemAsync(
"randomWeather",
async () => await GetWeatherFromRandomLocationsAsync(),
TimeSpan.FromHours(1)
) ?? [];
// build the response model
var response = new NewsDashboardResponseModel()
{
Items = locations.Select(x => new NewsDashboardItemResponseModel()
{
Header = x.Title,
Priority = "High", // or Medium or Low
Url = x.Url,
ImageUrl = x.ImageUrl,
Body = x.ShortDescription,
ButtonText = "Go to website",
}),
};
return response;
}
// below are methods for communicating with the weather API
// - you don't need these
public async Task GetWeatherAsync(double lat, double lon)
{
// this is just to prevent 529 Too many requests
await Task.Delay(Random.Shared.Next(100, 1000));
var url =
$"https://api.open-meteo.com/v1/forecast"
+ $"?latitude={lat}&longitude={lon}"
+ $"¤t_weather=true";
var json = await _httpClient.GetStringAsync(url);
using var doc = JsonDocument.Parse(json);
var current = doc.RootElement.GetProperty("current_weather");
var temperature = current.GetProperty("temperature").GetDouble();
var weatherCode = current.GetProperty("weathercode").GetInt32();
var description = GetDescription(weatherCode);
var imageUrl = GetImageUrl(weatherCode);
return new WeatherResult(
Title: $"{description}, {temperature}°C",
ShortDescription: $"Weather at {lat}/{lon}",
ImageUrl: imageUrl,
Url: $"https://www.open-meteo.com/en/forecast?latitude={lat}&longitude={lon}"
);
}
public record WeatherResult(string Title, string ShortDescription, string ImageUrl, string Url);
public static string GetDescription(int code) =>
code switch
{
0 => "Clear sky",
1 or 2 or 3 => "Partly cloudy",
45 or 48 => "Fog",
51 or 53 or 55 => "Drizzle",
61 or 63 or 65 => "Rain",
71 or 73 or 75 => "Snow",
80 or 81 or 82 => "Rain showers",
95 => "Thunderstorm",
_ => "Unknown weather",
};
public static string GetImageUrl(int code) =>
code switch
{
0 => "https://cdn-icons-png.flaticon.com/512/869/869869.png",
1 or 2 or 3 => "https://cdn-icons-png.flaticon.com/512/1163/1163661.png",
61 or 63 or 65 => "https://cdn-icons-png.flaticon.com/512/3351/3351979.png",
71 or 73 or 75 => "https://cdn-icons-png.flaticon.com/512/642/642102.png",
95 => "https://cdn-icons-png.flaticon.com/512/1146/1146869.png",
_ => "https://cdn-icons-png.flaticon.com/512/1779/1779940.png",
};
}
```
You also have to register the service with a Composer, like so:
```cs
using Umbraco.Cms.Core.Composing;
public class UselessNewsDashboardComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.Services.AddSingleton();
}
}
```
And there you have it:

This example obviously isn’t very useful - but it shows how little code is required to take control of the dashboard content.
## A practical use case
Where this does become useful is for agencies and teams building Umbraco sites for clients.
Instead of showing Umbraco HQ news, you can use the Welcome dashboard to:
* Share important project updates
* Announce new features or releases
* Provide onboarding instructions
* Link to internal documentation or support channels
* Communicate maintenance windows or known issues
In other words, it becomes a direct communication channel inside your client’s CMS.
## Final thoughts
Rather than removing the Welcome dashboard altogether, extending it allows you to turn an otherwise generic screen into something genuinely valuable for your clients.
With just a small custom service, you can make the Umbraco backoffice feel more tailored, more informative, and more aligned with the people actually using it.
---
# Readable, simple Chinese URL segments in Umbraco with NPinyin
URL: https://skttl.dev/blog/pinyin-url-segments/
Date: 2026-01-09
Description: When working with Chinese content in Umbraco, URL segments are often generated using Chinese characters, which can hurt readability and sharing. This article shows how to use NPinyin and a custom URL segment provider to automatically convert Chinese titles into clean, SEO-friendly Latin URLs with just a few lines of code.
Tags: umbraco

On a recent project, the client had a Chinese version of their site. The texts and titles were, of course, written using Chinese characters, which meant that the URLs were automatically generated using those characters as well.
For SEO and UX reasons, we wanted the URLs to use Latin characters instead. While search engines are perfectly capable of indexing Chinese URLs, we felt that Latin URLs would perform better in practice.
Our reasoning was:
* Latin URLs are more readable (especially since the audience was not exclusively Chinese), which we assumed would lead to higher click-through rates
* Hyphenated Latin words provide clearer URL keyword signals
* Sharing URLs and linking to them is much easier with Latin characters than with encoded Chinese characters
For example, a title like:
```
击掌!你太棒了!
```
would normally generate a URL segment using Chinese characters. With pinyin conversion applied, it instead becomes:
```
/ji-zhang-ni-tai-bang-le
```
The latter is much easier to read, share, and reason about, especially in mixed-language environments.
## Converting Chinese characters to Latin using pinyin
Umbraco already tries to convert special characters when generating URLs, but the default character replacement set is quite limited. You can see the default configuration in the request handler settings in the documentation article about [Request Handler Settings](https://docs.umbraco.com/umbraco-cms/reference/configuration/requesthandlersettings).
Because of the nature of Chinese characters, having more than 50,000 of them, mapping each character to a Latin equivalent is not feasible. Instead, we can use pinyin, which is a Latin-based phonetic transcription system for Chinese characters.
Using pinyin allows us to convert Chinese text into readable Latin equivalents without maintaining huge character maps. There is an open-source NuGet package called [NPinyin.Core](https://www.nuget.org/packages/NPinyin.Core) that makes this conversion straightforward. It’s lightweight and integrates cleanly into existing logic.
This approach also works well for mixed-language titles, such as:
```
击掌!You Rock!
```
which results in:
```
/ji-zhang-you-rock
```
## Using a custom URL segment provider in Umbraco
With the conversion logic in place, the next step was to make Umbraco use it when generating URL segments.
When Umbraco generates URL segments, it runs through a set of URL segment providers, and it’s possible to create a custom one. The process is simple and [well documented](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/outbound-pipeline#url-segment-provider).
By adding a small amount of additional logic to separate Chinese characters with dashes, we ended up with a short and simple custom segment provider that generates readable, SEO-friendly Latin URLs for Chinese pages.
> ** Note: **
> This approach converts each Chinese character to its pinyin representation and inserts dashes between characters. While this is not full linguistic word segmentation, it produces consistent, readable URL segments without adding unnecessary complexity.
## Example implementation
```cs
using System.Text;
using NPinyin;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Strings;
namespace Project.Core.Urls;
// Custom URL segment provider that wraps Umbraco's default provider
// and applies Chinese to Pinyin conversion on top.
public class ChineseUrlSegmentProvider : IUrlSegmentProvider
{
// We delegate to the default provider first, so we keep
// Umbraco's built-in behavior for non-Chinese characters.
private readonly DefaultUrlSegmentProvider _defaultUrlSegmentProvider;
public ChineseUrlSegmentProvider(IShortStringHelper shortStringHelper)
{
_defaultUrlSegmentProvider = new DefaultUrlSegmentProvider(shortStringHelper);
}
public string? GetUrlSegment(IContentBase content, string? culture = null)
{
// Let Umbraco generate the initial URL segment,
// then post-process it with our custom logic.
return ChineseToPinyinWithDashes(
_defaultUrlSegmentProvider.GetUrlSegment(content, culture)
);
}
// Converts Chinese characters to pinyin and inserts dashes
// between consecutive Chinese characters to improve readability.
private static string? ChineseToPinyinWithDashes(string? input)
{
if (input is null)
{
return input;
}
var sb = new StringBuilder();
var lastWasChinese = false;
foreach (var c in input)
{
if (IsChinese(c))
{
// Insert a dash between consecutive Chinese characters
if (sb.Length > 0 && lastWasChinese)
{
sb.Append('-');
}
// Convert the individual character to pinyin
sb.Append(Pinyin.GetPinyin(c.ToString()));
lastWasChinese = true;
}
else
{
// Non-Chinese characters are passed through unchanged
sb.Append(c);
lastWasChinese = false;
}
}
return sb.ToString();
}
// Simple Unicode range check for CJK Unified Ideographs
private static bool IsChinese(char c)
{
return c >= 0x4E00 && c <= 0x9FFF;
}
}
```
## Results
With this approach in place, Chinese pages now automatically generate clean, readable, and shareable URL segments without any manual intervention. It works equally well for fully Chinese titles and mixed-language content, and it integrates cleanly into Umbraco’s existing routing pipeline.
The solution is lightweight, easy to maintain, and avoids the complexity of large character-mapping tables or full linguistic word segmentation — making it a practical choice for real-world Umbraco projects.
---
# Sync videos from Umbraco to Mux effortlessly
URL: https://skttl.dev/blog/umux-announcement/
Date: 2025-12-23
Description: I just released uMux, the easiest way to sync video files from Umbraco to Mux!
Tags: umbraco, mux, video, packages
uMux is a brand new, free and open source, package for Umbraco 17+, that takes the hassle out of hosting videos on your website. Just drop your videos into Umbraco, and uMux will automatically upload and sync them with Mux. That means you get all the power of Mux’s streaming, thumbnails, and analytics, while still working in the CMS you love.
## Why uMux?
For years, I found myself recommending platforms like Vimeo or similar to clients because they’re user-friendly and handle video streaming well. But there was always a catch: editors had to jump between systems, upload videos to Vimeo, then copy and paste IDs or embed codes back into Umbraco.
It’s clunky, error-prone, and just not the smooth experience editors deserve.
With uMux, my goal was to keep everything in one place.
Editors can upload and manage videos right inside Umbraco, just like they’re used to, while still getting all the benefits of a modern video platform like Mux. No more juggling logins, no more copy-pasting codes, just a seamless workflow.
## What does it do?
uMux handles all the behind-the-scenes magic—your videos get sent to Mux, and Mux handles encoding, so the videos are ready to stream anywhere. Editors don’t have to worry about file types, encoding settings, or technical video stuff. It just works.
## Why use a third party video host?
Serving big video files from your own server can be a pain, especially if you’re on Umbraco Cloud or have bandwidth limits. Here’s why letting Mux handle your videos is a game-changer:
- **Save your bandwidth**
Mux takes care of the heavy lifting, so your Umbraco site stays speedy and you don’t get hit with surprise bandwidth bills.
- **Smooth streaming for everyone**
Mux automatically streams the best quality for each viewer’s device and connection, so videos load fast and play without hiccups.
- **Global delivery**
Your videos are delivered from servers close to your users, wherever they are in the world.
In addition to those obvious benefits, Mux also gives you some handy features around the videos you host with them.
- **Instant thumbnails & GIFs**
Need a thumbnail or animated preview? Mux generates them for you—no extra work needed.
- **Awesome analytics**
See how your videos are performing and where viewers might be dropping off.
- **Security & scalability**
No worries about traffic spikes or unauthorized access—Mux has you covered.
- **Works everywhere**
Embed videos in any player that supports HLS, or use Mux’s own slick player.
- **Automatic encoding**
Mux encodes your videos in all the right formats, so you don’t have to think about codecs or compatibility.
## Getting started
1. Install uMux:
```sh
dotnet add package Umbraco.Community.uMux
```
2. Add your Mux credentials to your appsettings.json or as environment variables.
3. Add the Mux Sync property editor to your media types in Umbraco.
4. Upload your videos as usual - uMux takes care of the rest!
For all the nitty-gritty details, check out the [README](https://github.com/skttl/umbraco-mux).
---
# Umbraco Marketplace Package Sync Bookmarklet
URL: https://skttl.dev/blog/umbraco-marketplace-sync-bookmarklet/
Date: 2025-03-28
Description: With this bookmarklet, it gets a lot easier, to list packages in Umbraco Marketplace
Tags: umbraco, nuget

The [Umbraco Marketplace](https://marketplace.umbraco.com/) automatically lists NuGet packages with a specific tag. New packages are indexed daily at 4:00 AM UTC, while updates to existing packages are checked every two hours.
However, waiting for the automatic indexing can be a test of patience when you're eager to see your new package listed and verify everything is correct.
Fortunately, the Umbraco Marketplace API allows you to trigger indexing for a single package using an HTTP POST request, as [detailed in the documentation](https://docs.umbraco.com/umbraco-dxp/marketplace/listing-your-package#synchronization-with-nuget-and-package-owner-data).
To streamline this process, I've created a bookmarklet that you can use directly from the NuGet package details page. This bookmarklet automatically extracts the package ID and sends it to the Marketplace endpoint for immediate indexing.
I'm happy to share it with the community, so feel free to grab it and use it yourself!
{% codepen https://codepen.io/skttl/pen/bNGLBvm %}
## How to Use
1. Navigate to a NuGet package details page on nuget.org (e.g., https://www.nuget.org/packages/Umbraco.Community.Contentment).
2. Click the "Sync with Marketplace" bookmarklet in your bookmarks bar.
3. An alert will confirm if the sync was initiated successfully or display any error messages. Eg. error 429, if you are being too eager, with too many requests.
---
# Say Goodbye to Tedious Icon Imports in Umbraco with Icoover!
URL: https://skttl.dev/blog/icoover-announcement/
Date: 2025-03-17
Description: A new package helps you add custom icons to Umbraco, without messing with javascript!
Tags: umbraco

We all know how important icons are for a clean and intuitive backoffice
experience in Umbraco. They help content editors quickly identify document
types, elements, and actions, making their lives easier. But manually
importing and managing those icons? That can be a real time-sink.
That's why I'm thrilled to introduce **Icoover**, a new Umbraco package
designed to automate the process of importing SVG icons into your Umbraco
backoffice. Say goodbye to the days of manually adding icons one by one!
## What is Icoover?
[Icoover](https://marketplace.umbraco.com/package/umbraco.community.icoover) is a simple yet powerful package that automatically imports SVG icons
from a dedicated folder within your Umbraco project. Just drop your SVG files
into the designated directory, restart Umbraco, and boom! Your icons are
ready to be used across your document types and the entire backoffice.
## Key Features:
* **Automatic Import:** Icoover automatically detects and imports SVG icons,
eliminating manual uploads.
* **Simple Setup:** Just install the package and add your icons to the
correct folder. Icoover automatically "hoovers" up SVG files in the `App_Plugins/Icoover/icons` folder and imports them into Umbraco.
## Best Practices for SVG Icons in Umbraco
To ensure your icons render correctly and are fully customizable within the Umbraco backoffice, keep these tips in mind:
### Use `viewBox`:
Instead of setting `width` and `height` attributes on your root SVG element, use `viewBox`. This allows Umbraco to handle the sizing of the icon dynamically.
**Do:**
```html