The Old World: AngularJS Comfort
In Umbraco 13, a custom property editor was made up of three loosely coupled pieces: a manifest (package.manifest), a controller, and a view.
{
"propertyEditors": [
{
"alias": "My.RatingEditor",
"name": "Rating Editor",
"editor": {
"view": "~/App_Plugins/RatingEditor/rating.html"
}
}
],
"javascript": [
"~/App_Plugins/RatingEditor/rating.controller.js"
]
}
angular.module("umbraco").controller("My.RatingEditorController", function ($scope) {
$scope.stars = [1, 2, 3, 4, 5];
$scope.setRating = function (value) {
$scope.model.value = value;
};
});
★
There was no build step. No TypeScript compiler. No bundler config to fight with. You edited rating.controller.js, refreshed the browser, and saw your change. $scope gave you two-way data binding almost for free, and Angular's ng-repeat, ng-click, and ng-class directives did a lot of heavy lifting without much ceremony.
It was forgiving in a way that let a backend-leaning .NET developer dabble in front-end work without ever really learning front-end engineering. That comfort was also its biggest weakness — loosely typed $scope objects, global Angular modules, and manifest-driven wiring that failed silently more often than I'd like to admit.
The New World: TypeScript Discipline
Umbraco 14 replaced the AngularJS backoffice with one built on Lit (a lightweight Web Components library) and TypeScript, registered through a structured extension manifest system. By the time v17 arrived as the LTS release, this new architecture had matured — better documentation, more stable extension APIs, and tooling that assumes you're running a real front-end build pipeline (Vite, by default).
Here's the same rating editor, rebuilt for Umbraco 17.
{
"name": "My.RatingEditor",
"version": "1.0.0",
"extensions": [
{
"type": "propertyEditorUi",
"alias": "My.PropertyEditorUi.RatingEditor",
"name": "Rating Editor",
"js": "/App_Plugins/RatingEditor/rating-editor.element.js",
"elementName": "my-rating-editor",
"meta": {
"label": "Rating Editor",
"icon": "icon-favorite",
"group": "common"
}
}
]
}
import { LitElement, html, css, customElement, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbPropertyValueChangeEvent } from '@umbraco-cms/backoffice/property-editor';
@customElement('my-rating-editor')
export class MyRatingEditorElement extends LitElement {
@property({ type: Number })
value = 0;
#onStarClick(rating: number) {
this.value = rating;
this.dispatchEvent(new UmbPropertyValueChangeEvent());
}
override render() {
return html`
`;
}
static override styles = css`
.rating-container {
display: flex;
gap: 4px;
}
.star {
cursor: pointer;
font-size: 24px;
color: var(--uui-color-disabled, #ccc);
}
.star.active {
color: var(--uui-color-warning, #f5a623);
}
`;
}
export default MyRatingEditorElement;
declare global {
interface HTMLElementTagNameMap {
'my-rating-editor': MyRatingEditorElement;
}
}
At first glance, this looks like more code for the same result — and it is. But the extra ceremony buys you things AngularJS never gave me: compile-time type checking, a component model that's a genuine web standard (not framework-specific), and an explicit contract for how the property value flows in and out via UmbPropertyValueChangeEvent instead of an implicit $scope.model.value binding that worked mostly by convention.
Where the Mindset Shift Actually Hurts
1. No More "Just Refresh the Browser"
In v13, editing a controller file and refreshing the browser was the entire feedback loop. In v17, you're working inside a real front-end toolchain — Vite for local development, TypeScript compilation, and a package manifest that needs to be correctly registered before anything shows up.
# Typical v17 App_Plugin local dev workflow
npm install
npm run dev
The first time my custom editor didn't appear in the Data Type picker, my instinct was to check for a JavaScript error like I would have in v13. Instead, the real issue was a mismatched alias between umbraco-package.json and the @customElement decorator — a class of bug that TypeScript's tooling would have caught if I'd been paying attention to the compiler warnings instead of ignoring them out of habit.
2. Scope Is Gone — Long Live Explicit State
AngularJS's $scope meant you rarely thought about how data got from the model into your template — it just happened. Lit forces you to be explicit: properties are declared with @property(), reactivity is triggered by property changes, and anything you want persisted back to Umbraco has to be dispatched as an event.
This was the single biggest adjustment for me. I'd spent years relying on Angular's binding magic, and now I had to actually reason about component lifecycle — connectedCallback, reactive property updates, and when render() gets called again.
// v13: implicit, "just works" binding
$scope.setRating = function (value) {
$scope.model.value = value; // Umbraco picks this up automatically
};
// v17: explicit, event-driven state change
#onStarClick(rating: number) {
this.value = rating;
this.dispatchEvent(new UmbPropertyValueChangeEvent()); // you must say so
}
3. Extension Manifests Replace Loosely Structured Plugin Files
In the old world, a package.manifest file was a grab-bag of arrays — propertyEditors, javascript, css, dashboards — that Umbraco parsed somewhat leniently. In v17, every extension you register (property editor, dashboard, section, menu item, workspace view, and more) follows the same strict, typed manifest contract, all declared under a single extensions array in umbraco-package.json.
{
"extensions": [
{
"type": "propertyEditorUi",
"alias": "My.PropertyEditorUi.RatingEditor",
"name": "Rating Editor",
"js": "/App_Plugins/RatingEditor/rating-editor.element.js",
"elementName": "my-rating-editor",
"meta": {
"label": "Rating Editor",
"icon": "icon-favorite",
"group": "common"
}
},
{
"type": "dashboard",
"alias": "My.Dashboard.RatingStats",
"name": "Rating Stats Dashboard",
"js": "/App_Plugins/RatingEditor/rating-stats-dashboard.element.js",
"elementName": "my-rating-stats-dashboard",
"weight": 10,
"meta": {
"label": "Rating Stats",
"pathname": "rating-stats"
}
}
]
}
Every extension type — dashboards, property editors, sections, workspace views — now shares one predictable shape: type, alias, name, an entry point, and a meta block. Once that clicked for me, the new system actually became easier to reason about than the old grab-bag manifest, just after a steeper initial climb.
4. Direct Migration Is Supported, But the Extensions Aren't
One thing worth calling out clearly: Umbraco officially supports jumping straight from the v13 LTS to the v17 LTS, with data migrations applied automatically. Your content, Document Types, and Compositions carry over cleanly. What does not carry over automatically is any custom AngularJS-based backoffice extension you built for v13 — those need to be rewritten against the new architecture, since the underlying UI framework changed entirely starting in v14.
If your project has custom dashboards, property editors, or section extensions, budget real time for this rewrite. It's not a find-and-replace job.
What I'd Tell Past Me
- Learn Lit before you touch Umbraco's extension API. Half my early confusion wasn't Umbraco-specific — it was not understanding Web Components and reactive properties in general.
- Set up the Vite dev server early, even for a trivial editor. Fighting the build tooling on your first real component is a bad first impression that isn't representative of how smooth the workflow becomes once it's configured.
- Read the extension type reference before guessing. The
typefield in your manifest (propertyEditorUi,dashboard,workspaceView,section, and dozens more) determines the entire contract your component needs to satisfy — guessing at properties from an old AngularJS mental model wastes time. - Treat
UmbPropertyValueChangeEvent-style patterns as the new normal. Once I stopped looking for implicit two-way binding and started explicitly dispatching state changes, the rest of the component model made much more sense. - Budget rewrite time, not just upgrade time, if your v13 project has meaningful custom backoffice extensions. The CMS upgrade is largely automated; your extensions are not.
Was It Worth It?
Yes — but not because TypeScript and Lit are inherently "better" than AngularJS in the abstract. It's worth it because the new backoffice is built on real web standards instead of a framework that Google itself moved away from years ago. Type safety catches entire categories of bugs before they ever reach a content editor's screen. And a manifest system that treats every extension type consistently is, once you're past the learning curve, genuinely easier to extend and maintain than the old grab-bag of loosely typed JavaScript files.
The AngularJS days were comfortable. The TypeScript days are disciplined. For a CMS that both developers and non-technical editors rely on daily, I'll take discipline.