Theme:

Building Your Way: Extending Umbraco's New Backoffice

Extending the Umbraco backoffice has always been a key part of its developer-friendly ecosystem. With the new backoffice, Umbraco introduces modern tools like Lit, Vite, and a UI Component Library, enabling developers to create powerful extensions without being tied to outdated frameworks. Goodbye AngularJS, hello flexibility!

Embracing Flexibility with Lit and Beyond

Officially, Umbraco recommends using Lit for building extensions—and with good reason. Lit generates Custom Elements and fully adheres to the Custom Element API, making it an excellent choice for creating a highly extensible, complex CMS. Lit ensures all components work seamlessly together, maintaining consistency and scalability across the entire platform. For large and intricate extensions, Lit is undoubtedly the way to go.

But what about smaller, more focused extensions? Often, simplicity can be just as powerful. For extensions aimed at improving the content editor’s experience, leveraging tools like Vite with frameworks such as Vue or React can provide an effective alternative without the overhead of learning a new framework. This approach allows developers to enhance the backoffice with minimal friction, fostering creativity and accessibility for a broader range of developers.

Why Use Familiar Frameworks?

Countless front-end developers working with Umbraco already excel at building reactive UIs using their framework of choice. By leveraging Vite’s ability to integrate with Web Components, developers can skip the hurdle of learning Lit and instead use their existing expertise with Vue, React, or whatever Javascript framework of chocie. This enables developers to:

  • Build extensions with their preferred tools.

  • Reduce learning curves.

  • Focus on improving the content editor’s experience.

This approach lowers barriers, makes package development exciting, and ensures that developers feel empowered to create extensions tailored to their workflows.

In this article, we’ll explore how you can extend the Umbraco backoffice, starting with a simple Vanilla Javascript apporach, and then expanding into how other frameworks can fit seamlessly into the ecosystem—all while adhering to the standards of the Custom Element API.

Getting Started: Vanilla JavaScript

Umbraco should be extensible. And build how you want to build can also mean bring your own framework or stack. 
To demonstrate the possibilities, let’s start with a look at the simplest way to create a backoffice extension using vanilla JavaScript.

The vanilla component


import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api";
import { UMB_NOTIFICATION_CONTEXT } from "@umbraco-cms/backoffice/notification";

const template = document.createElement("template");
template.innerHTML = `
  <uui-box>
    <h1>Welcome to my dashboard</h1>
    <p>Example of vanilla JS code</p>

    <uui-button label="Click me" id="clickMe" look="secondary"></uui-button>
  </uui-box>
`;

export default class MyDashboardElement extends UmbElementMixin(HTMLElement) {
    #notificationContext;

    constructor() {
        super();
        this.attachShadow({ mode: "open" });
        this.shadowRoot.appendChild(template.content.cloneNode(true));

        this.shadowRoot
            .getElementById("clickMe")
            .addEventListener("click", this.onClick.bind(this));

        this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => {
            this.#notificationContext = instance;
        });
    }

    onClick = () => {
        this.#notificationContext?.peek("positive", {
            data: { headline: "Hello" },
        });
    };
}

customElements.define("my-vanilla-extension", MyDashboardElement);

/App_Plugins/my-vanilla-extension/vanilla-extension.js

In which by far the most power lies in extending the UmbElementMixin.
This ensures seamless integration with the Backoffice state and provides the functionality of the Context API.
Result of the Vanilla component

The Lit component

While the vanilla component provides a simple entry point, leveraging Lit unlocks more advanced features and scalability for complex extensions.

Using Lit is considered best practice in Umbraco, as it offers the cleanest and most efficient way to create extensions. However, for the sake of brevity, I won’t dive into a detailed example of this approach here.

If you’re interested in setting up a Lit-based extension, I recommend exploring the Vite Package Setup documentation provided by Umbraco, which offers a comprehensive guide.

Taking It Further: Using Other Frameworks

Utilizing the power of Vite

Vite is so powerful and easy to use.
It is truly an integration for your front-end bundle and it's very good at bringing front-end frameworks together. We as front-end developers love this tool. Lots of Umbraco developers also use it to bundle the front-end files for their Umbraco websites, like explained in this article. But the question is, can we also utilize the other templates of Vite for extending the backoffice?
Would be cool if we use these also, right?
The supported template presets of Vite

The supported template presets of Vite

The Vue component

Mounting an app should be easy, but the more fun part is: we can even use the Context API.
The setup will be fine as long as it comes together as a webcomponent (which gets referenced in the umbraco-package.json).
So let's start by running:

npm create vite@latest Client -- --template vue-ts
This generates this folder structure:
Folder structure after creating Vite with template vue-ts

Folder structure after creating Vite with template vue-ts

Now edit the vite.config.ts based on the Umbraco settings, but with some Vue specific settings for the compilor options. Which is at the bottom of this file.

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  build: {
    lib: {
      entry: "src/main.ts", // your web component source file
      name: "hello",
      formats: ["es"],
    },
    outDir: "../../App_Plugins/Vue", // all compiled files will be placed here
    emptyOutDir: true,
    sourcemap: true,
    rollupOptions: {
      external: [/^@umbraco/], // ignore the Umbraco Backoffice package in the build
    },
  },
  define: {
    'process.env.NODE_ENV': true
  },
  base: "/App_Plugins/Vue/", // the base path of the app in the browser (used for assets)
  plugins: [
    vue({
      template: {
        // this is important, because Vue needs to recognize the uui Umbraco components as Custom Elements and not as Vue components
        compilerOptions: {
          isCustomElement: tag => tag.startsWith("uui-"),
        },
      },
    })
  ],
});

vite.config.ts

We have correctly set the vite.config.ts settings for a Vue bundle and now continue with mounting the actual app within an UmbElementMixin web component.

import { createApp } from "vue";
import App from './App.vue'
import type {UmbPropertyEditorConfigCollection} from "@umbraco-cms/backoffice/property-editor";
import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api";

const appId = 'helloVue';

const template = document.createElement("template");
template.innerHTML = `
  <style>
    :host {
      padding: 20px;
      display: block;
      box-sizing: border-box;
    }
  </style>

  <div id="${appId}"></div>
`;

class HelloVue extends HTMLElement {
  config: UmbPropertyEditorConfigCollection | undefined;
  app: any;
  public value: any;

  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.shadowRoot?.appendChild(template.content.cloneNode(true));
  }

  connectedCallback() {
      const mountElem = this.shadowRoot?.querySelector(`#${appId}`);
      
      this.app = createApp(App, { mountElem });

      if (mountElem) {
          this.app.mount(mountElem);
      }
  }
  
  disconnectedCallback() {
    this.app.unmount()
  }
}

customElements.define('hello-vue', UmbElementMixin(HelloVue))

main.ts

We provide the mount element as a root property so we know on which <div /> the Vue app is mounted.
And because the Custom Element in which the Vue app is mounted is an extension of the UmbElementMixin we can utilize the Context API.
In the App.vue file below you can see the similarities with the vanilla component but with all the Vue syntax for states and templates.

<script setup lang="ts">
import { UMB_CURRENT_USER_CONTEXT } from "@umbraco-cms/backoffice/current-user";
import { UmbLitElement } from "@umbraco-cms/backoffice/lit-element";
import {
  UMB_NOTIFICATION_CONTEXT,
  UmbNotificationContext,
} from "@umbraco-cms/backoffice/notification";
import { onMounted, ref, toValue } from "vue";

const props = defineProps<{
  mountElem: HTMLElement;
}>();

const rootElement = ref(null);
const username = ref("");
const host = ref<UmbLitElement>();

const notificationInstance = ref<UmbNotificationContext>();

onMounted(() => {
  host.value = (props.mountElem.getRootNode() as any).host as UmbLitElement;

  toValue(host)?.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => {
    context.currentUser.subscribe((user: any) => {
      username.value = user?.name ?? "";
    });
  });

  toValue(host)?.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => {
    notificationInstance.value = instance;
  });
});

function sendNotification() {
  notificationInstance.value?.peek("positive", {
    data: {
      message: "Wow, I sent a notification from within a Vue app",
    },
  });
}
</script>

<template>
  <div ref="rootElement">
    <uui-box :headline="`Hello ${username} from Vue!`" headline-variant="h5">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        xmlns:xlink="http://www.w3.org/1999/xlink"
        aria-hidden="true"
        role="img"
        width="200px"
        height="200px"
        preserveAspectRatio="xMidYMid meet"
        viewBox="0 0 256 198"
      >
        <path
          fill="#41B883"
          d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"
        ></path>
        <path
          fill="#41B883"
          d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"
        ></path>
        <path
          fill="#35495E"
          d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"
        ></path>
      </svg>

      <p>This is an UUI Box element.</p>

      <button @click="sendNotification">Notification</button>
    </uui-box>
  </div>
</template>

App.vue

The code above results in the video below. As you can see, the notifications can be triggered through the Context API and the username can be displayed on the Vue template.

Because the rootNode of the mount element and it's host is an UmbLitElement we type it that way in the App and from that part on we even get all the Context API type definitions! 
Vue extension with similar functionality as the Vanilla component

Vue extension with similar functionality as the Vanilla component

Cool right? That's all that is needed to mount a Vue app and consume the Context API.

But this is a Dashboard App, another interesting use case for simple extensions is of course the creation of a custom Property Editor

The Vue property editor

For the property editor the same vite.config.ts and mounting mechanism applies. It's just that instead of consuming the Context API from the host. We set the value of the host and dispatch the UmbPropertyValueChangeEvent:

<script setup lang="ts">
import { UMB_CURRENT_USER_CONTEXT } from "@umbraco-cms/backoffice/current-user";
import { UmbLitElement } from "@umbraco-cms/backoffice/lit-element";
import { UmbPropertyValueChangeEvent } from "@umbraco-cms/backoffice/property-editor";
import { onMounted, ref, watch, toValue } from "vue";

const props = defineProps<{
  mountElem: HTMLElement;
}>();

const rootElement = ref(null);
const inputValue = ref("");
const host = ref<any>(null);

onMounted(() => {
  host.value = (props.mountElem.getRootNode() as any).host as UmbLitElement;

  // Sync initial value
  inputValue.value = toValue(host).value;
});

watch(inputValue, (newValue) => {
  toValue(host).value = newValue;
  toValue(host).dispatchEvent(new UmbPropertyValueChangeEvent());
});
</script>

<template>
  <div ref="rootElement">
    <input
      style="display: block; width: 100%"
      type="text"
      v-model="inputValue"
    />
    <div
      :style="`text-align: right; ${
        inputValue.length > 80 ? 'color: red;' : ''
      }`"
    >
      {{ inputValue.length }} / 80
    </div>
  </div>
</template>

<style>
input {
  font-family: inherit;
  padding: var(--uui-size-1, 3px) var(--uui-size-space-3, 9px);
  font-size: inherit;
  color: inherit;
  border-radius: 0;
  box-sizing: border-box;
  border: var(--uui-input-border-width, 1px) solid
    var(--uui-input-border-color, var(--uui-color-border, #d8d7d9));
  background: none;
  width: 100%;
  height: var(--uui-input-height, var(--uui-size-11, 33px));
  text-align: inherit;
  outline: none;
}
</style>

App.vue

Which results in this field with a character counter that turns red after 80 characters:
Vue Property Editor with a character counter

Vue Property Editor with a character counter

The Vue example demonstrates how easily you can mount your favorite JavaScript framework while ensuring you have access to the host component—the web component extending the UmbLitElement. This approach isn’t limited to Vue; the same principles can be applied to any framework supported by Vite templates, such as React or Svelte. The flexibility provided by this setup allows developers to integrate their preferred tools seamlessly, making the new Umbraco backoffice a truly open and adaptable environment for modern web development.

The React component

So what would a React component look like? You can use the Vite template for React. The same mounting mechanism and vite.config.ts applies as Vue. Only the App.vue will be different of course:


import React, { useEffect, useRef, useState } from 'react';
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
import { UUIBoxElement } from '@umbraco-cms/backoffice/external/uui';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UMB_NOTIFICATION_CONTEXT, UmbNotificationContext } from '@umbraco-cms/backoffice/notification';

interface Props {
  mountElem: HTMLElement;
}

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'uui-box': React.DetailedHTMLProps<any, any>;
    }
  }
}

const ReactApp: React.FC<Props> = ({ mountElem }) => {
  const rootElement = useRef<HTMLDivElement>(null);
  const [username, setUsername] = useState<string>('');

  const [notificationInstance, setNotificationInstance] = useState<UmbNotificationContext>();

  useEffect(() => {
    const host = (mountElem.getRootNode() as any).host as UmbLitElement;

    host.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => {
      context.currentUser.subscribe((user) => {
        setUsername(user?.name ?? '');
      });
    });

    host.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => {
      setNotificationInstance(instance);
    })
  }, [mountElem]);

  function sendNotification() {
    notificationInstance?.peek("positive", {
      data: {
        message: "Wow, I sent a notification from within a React sapp",
      }
    })
  }

  return (
    <div ref={rootElement}>
      <uui-box headline={`Hello ${username} from React!`} headline-variant="h5">
      <svg width="200px" height="200px" viewBox="-10.5 -9.45 21 18.9" style={{ color: '#58c4dc' }} fill="#58c4dc" xmlns="http://www.w3.org/2000/svg" ><circle cx="0" cy="0" r="2" fill="currentColor"></circle><g stroke="currentColor" strokeWidth="1" fill="none"><ellipse rx="10" ry="4.5"></ellipse><ellipse rx="10" ry="4.5" transform="rotate(60)"></ellipse><ellipse rx="10" ry="4.5" transform="rotate(120)"></ellipse></g></svg>

        <p>
          This is a UUI Box element. Unfortunately, it doesn’t work to use
          uui-button element, for example, because the extension of UmbElement
          causes issues within React.
        </p>

        <button onClick={() => { sendNotification() }}>Notification</button>
      </uui-box>
    </div>
  );
};

export default ReactApp;

App.tsx

The App.tsx mounted as a React component will result in the following:

 

The React Component

The React Component

I think you get the point and I think it's facinating how frameworks can come together in a Web Components world in which the Context API is a truely native Javascript state management machine.
As an extra bonus we can even put this idea into overdrive. In the video below Vue, React, Angular and (yes I know) even jQuery can come together in the same screen.
Awesome right?
Vue React Angular and jQuery in the same backoffice

Vue React Angular and jQuery in the same backoffice

If you're curious for the full code of these Umbraco Extensions you can find them here.

Pros and Cons of this flexible approach

Of course, this approach deviates from the officially recommended path and may feel a bit unconventional. However, in my experience as a front-end web developer, there’s often room for creative solutions that may not strictly adhere to best practices. These so-called 'hacky' approaches can serve a purpose, especially when they make life easier for content editors or make developers excited to make awesome extensions.

Pros:

  • Attracting more developers to the Umbraco ecosystem by lowering barriers to entry
  • Empowering developers to use the tools they already know and love, reducing learning curves and increasing enthusiasm
  • Enhancing the content editor experience with creative, custom extensions tailored to specific needs

Cons:

  • Increased bundle sizes, for each extension the framework specific bundle will be included within its javsacript bundle. This can be mediated to make the framework specific versions external source for Vite. But still this comes with a lot of complexity.
  • Potentially fragmented ecosystem if too many varied approaches are used, making maintainability a challenge for community-shared packages

Conclusion

When creating a large package that can be used by others in the community or even extended, then of course it's probably not so smart to go this route. But the truth is. We have lots of internal packages, custom dashboarding or custom client specific property editors. Therefore it's all about empowering the Content Editor.
Developers should be happy to empower the Content Editor and extend their experience within the backoffice. And if the developers can use the framework they like (and also use for the front-end of the Umbraco websites), then they will be more excited to create useful extensions.
So my message is simple: Build how you want to build.