This blog was still running on Umbraco 13. Time upgrade it to the latest version. This time I did not do it myself. I let Claude Code do the upgrade and I sat next to it, reviewing and testing the backoffice.
If you have read my earlier post Automating your Umbraco V8 database upgrade you know I am a fan of automating upgrades and that was even before AI. In that post I used a few PowerShell scripts to create empty V10 and V13 sites, and notification handlers for the repetitive database tasks. This time Claude took that idea to the next level. It created the in-between sites, ran the upgrades, read the logs and checked the database, all without me touching Visual Studio.
In this post I show you what happened and the process I would use next time.
The site
The whole solution is not that big... 5 projects (Site, Common, Core, Persistence and Client), Umbraco 13.5.2 on .NET 8.
Packages:
SEOChecker
Dawoe.OEmbedPickerPropertyEditor
Umbraco.StorageProviders.AzureBlob with ImageSharp.
There was also my own custom page view tracking: an EF Core DbContext, middleware, a recurring background job and an AngularJS dashboard. That code has since then became the SimpleStats package so part of this upgrade was replacing my own code with the package.
My usual manual approach is to create a blank new Umbraco site, point it at a copy of the old database, let it upgrade the database, then swap the real site code in and upgrade the projects. Claude basically did the same, only faster and without forgetting steps.
Plan first
I started Claude Code in plan mode. It read the solution, the Umbraco upgrade docs and checked NuGet. The most important finding: Umbraco 18 can't upgrade from versions older than 16.4. So the database needs two hops, 13 → 17 (LTS) → 18.
Next it checked that every package had both a 17.x and an 18.x version. Umbraco 17.8.1 / 18.3.1, SEOChecker 17.1.1 / 18.1.1, Dawoe.OEmbedPickerPropertyEditor 17.0.3 / 18.0.0, StorageProviders 18.0.0 and SimpleStats 18.0.0. To be honest, I had already forgotten the site used the Dawoe embed picker, and I didn't mention the blob storage provider in my instructions either, but Claude found and upgraded both.
It only asked me two questions. What to do with the old stats history (migrate it) and where SimpleStats 18 would come from (NuGet). I added that I test the backoffice and content editing myself, so it could focus on the rest.
Automating the database upgrade (two versions)
This is the part from the V8 post, but now scripted end to end. For each hop Claude creates a blank site with a pinned template version and adds the package versions for that hop, so their migrations run in the right order.
dotnet new install Umbraco.Templates::17.8.1
dotnet new umbraco -n UpgradeV17
cd UpgradeV17
dotnet add package SEOChecker --version 17.1.1
dotnet add package Dawoe.OEmbedPickerPropertyEditor --version 17.0.3
The connection string points to the copy of the database and the unattended upgrade is switched on.
{
"Umbraco": {
"CMS": {
"Unattended": {
"UpgradeUnattended": true
}
}
}
}
Then dotnet run, wait for "Now listening", and read the Umbraco JSON log plus the upgrader state in the umbracoKeyValue table with sqlcmd. Only when that looked fine, it did the same with a blank 18.3.1 site and the 18.x packages.
The upgrade to 17 took seconds. The only warning was about two legacy Media Picker data types that weren't migrated. Claude checked with SQL that no document type used them, so that was fine.
Upgrading the code
Everything went to net10.0. Then first delete what no longer exists in V14+, and after that let the compiler tell you what is left.
Umbraco.Cms.Web.BackOfficeis gone,Umbraco.Cms.Web.Websiteis what you need.Common had to reference
SEOChecker.Librarydirectly. In V13 it came in transitively.Deleted: the Client and Persistence projects, all my custom SimpleStats code (including an
UmbracoAuthorizedApiController, which no longer exists), Grid views, macro partials, old backoffice view overrides, App_Plugins, Smidge from _ViewImports and an unused ClosedXML reference.UseInstallerEndpoints()removed from Program.cs.
A few errors came from the ModelsBuilder files (SourceCodeManual mode), since IPublishedSnapshotAccessor is now IPublishedContentTypeCache. Claude patched those mechanically with sed just to get it compiling, and later I regenerated them in the backoffice.
The real API breaks were only in two helpers. GetAtRoot(), GetByContentType and .Children are gone and you use the navigation services instead. This is all described in the official Breaking Changes Overview and the Services and Helpers docs, so make sure you read those before you start.
public Home GetRoot()
{
if (_umbracoContextAccessor.TryGetUmbracoContext(out var context)
&& _navigationQueryService.TryGetRootKeysOfType(Home.ModelTypeAlias, out var rootKeys))
{
return rootKeys
.Select(key => context.Content?.GetById(key))
.OfType<Home>()
.FirstOrDefault();
}
return null;
}
And getting all blog posts, using IDocumentNavigationQueryService and IPublishedContentStatusFilteringService. Since I don't have that much content all is cached.
private IEnumerable<BlogPage> GetAll()
{
var root = _siteHelper.GetRoot();
if (root == null)
{
return [];
}
return root
.DescendantsOfType(_navigationQueryService, _statusFilteringService, BlogPage.ModelTypeAlias)
.OfType<BlogPage>();
}
Testing
Claude tested the front end only, with curl and SQL: home and paging, a blog post with image crops, a YouTube oEmbed block, RSS, sitemap.xml, robots.txt, the custom 404 page, the SEOChecker meta tags and media from Azure blob storage. SimpleStats counting was checked in the database after the 60 second commit, including that the 404 page is excluded.
Block list labels to UFM
The block list labels were still AngularJS syntax ({{title}}, and even a broken {{person}} on a block that has no person property). Claude wrote a small sql script that updates umbracoDataType.confi.
The new labels: {=url}, {umbLink: url}, {umbContentName: mediaItem}, {=title} and ${ title || author }.
There is one gotcha worth sharing. For the paragraph block I used ${ title || text | stripHtml | truncate:50 }, modelled on the docs example ${ title || rte | stripHtml | truncate: 38}. It showed the whole text with … at the end, so the truncate filter ran but ignored the length. Inside a ${ } expression the filter arguments didn't arrive. The UFM documentation doesn't describe filter arguments inside expressions. Outside an expression the piped syntax with arguments works fine, for example {umbValue: intro | truncate:30:...}.
What did work is calling the filters as functions inside the expression:
${ title || truncate(stripHtml(text), 50) }
Piped arguments still work in the normal, non-expression form, like {umbValue: intro | truncate:50}.
How I would do it again with Claude
Official docs only, from the start. At one point Claude went looking for API signatures and UFM behaviour in the Umbraco DLLs and bundled files. I told it to use the official Umbraco documentation only, and next time that goes in my first prompt.
Put backoffice configuration in the plan. The plan was all about the database, packages and code. I only found the AngularJS block list labels during my own backoffice test. Next time I ask Claude to check the data type configuration for old V13 syntax upfront, so the labels script is part of the first round.
Happy Upgrading!