Theme:

Extending the new Backoffice

Discover the full potential of Umbraco 14/15’s new backoffice! This guide walks you through every step of extending the frontend-focused architecture. Learn to set up custom dashboards, create intuitive menus, use Context API, and build dynamic routes all through hands on examples.

Intro

As you know, the new backoffice is finally here, and it brings a lot of exciting changes! Where extending the backoffice used to involve a mix of backend and frontend development, it’s now entirely a frontend-focused process. AngularJS has been replaced with modern TypeScript and Lit / Web Components, which I think is a fantastic step forward!

The new backoffice is also designed with modularity at its core. Every small component of the site is built to be modular, making it incredibly easy to extend and customize.

In this post, I’ll walk you through how to extend the new backoffice, using Pokémon as the subject for our example, by leveraging the PokeAPI. We’ll cover everything from setting up your extension to creating a custom dashboard, section complete with a menu, actions, and dynamic routes. We’ll also dive into building custom property editors. Let’s get started!

Setup

Umbraco

Set up and launch your new Umbraco 14 / 15 website by starting the project and creating a user using the following commands.


dotnet new install Umbraco.Templates
dotnet new umbraco --name MySite.Umbraco
cd MySite.Umbraco
dotnet run

Extension project

In the past, building extensions meant relying on AngularJS. However, with the new backoffice, you can now create extensions using any JavaScript framework capable of building to web components, such as React, Vue, Lit, and more. For this guide, we’ll use Lit with TypeScript to demonstrate how to get started.

Setting up a project for extensions is straightforward, it’s essentially a standard Vite project. In this example, I’m using Node.js version v20.18.0.


cd ../
npm create vite@latest MySite.BackofficeExtension

Next, select the package name, framework, and variant you want to use. Choose Lit as the framework and TypeScript as the variant.


√ Package name: ... mysite-backofficeextension
√ Select a framework: » Lit
√ Select a variant: » TypeScript

Complete the setup by adding the Umbraco CMS npm backoffice package. In this example, I’m using version 14.3.1.


npm i && npm install -D @umbraco-cms/backoffice@14.3.1

To set up the build configuration for your Vite project, create a vite.config.ts file in the root directory. This file defines critical settings for the build process, including specifying the entry file path, the output directory, and rollup options to manage dependencies. Here's an example configuration:



import { defineConfig } from "vite";

export default defineConfig({
    build: {
        lib: {
            entry: "src/index.ts",
            formats: ["es"]
        },
        outDir: "../MySite.Umbraco/App_Plugins/BackofficeExtension",
        emptyOutDir: true,
        sourcemap: true,
        rollupOptions: {
            external: [/^@umbraco/]
        },
    },
    base: "/App_Plugins/Client/"
});

vite.config.ts

Configuration Details:

  • Entry File Path:

    • The entry property in lib specifies the main TypeScript file that serves as the entry point for your extension. In this example, it's set to src/index.ts.
  • Output Directory (outDir):

    • Defines where the built files will be stored. Here, the output is directed to the App_Plugins/BackofficeExtension directory within your Umbraco project.
  • Rollup Options:

    • Use rollupOptions.external to exclude dependencies (e.g., @umbraco modules) that should not be bundled, ensuring they are referenced as external resources.

Next, let’s create our entry file: src/index.ts. To keep things clean, remove all unused files from the src & public folder.

From here, you can build your project using the command npm run build, which utilizes your vite.config.ts.

For more details about setting up Vite, check out the official documentation 😉.

Our extension

Let’s begin by creating a umbraco-package.json file in the public folder of our Vite project. This file replaces the package.manifest from older versions and is used to define information about the extension.


{
    "$schema": "..\\..\\MySite.Umbraco\\umbraco-package-schema.json",
    "name": "MySite",
    "id": "MySite",
    "version": "1.0.0",
    "allowTelemetry": true,
    "extensions": []
}

umbraco-package.json

This is also where we register our extensions, each defined with properties like name, alias, type, and more. To ensure our setup is working correctly, let’s add a dashboard to the extensions array.


{
    //Other config here
    "extensions": [
        {
            "type": "dashboard",
            "alias": "My.Dashboard.MyExtension",
            "name": "My Dashboard",
            "meta": {
                "label": "My Dashboard",
                "pathname": "my-dashboard"
            }
        }
    ]
}

umbraco-package.json

When we run this and log into the backoffice, we’ll see our new dashboard tab appear. It doesn’t have any functionality yet, but don’t worry, that’s completely normal!

The newly created dashboard is now visible in the Umbraco Backoffice.

Entry point

To avoid writing a lot of JSON to register all of our extensions, we’ll use an entryPoint extension. This allows us to register all extensions using TypeScript through our entryPoint.

The entryPoint extension type enables JavaScript code to execute during backoffice startup. It points to a single JavaScript file that loads and runs when the Backoffice starts, essentially serving as the entry point for a package or extension.

Update our index.ts file to set up the entryPoint logic.


import { UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api';
import { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';

const manifests: Array<ManifestTypes> = [

];

export const onInit: UmbEntryPointOnInit = (_host, extensionRegistry) => {
    extensionRegistry.registerMany(manifests);
};

index.ts

Let's update the umbraco-package.json file to register the entryPoint extension. This extension must be of type "entryPoint", and the js property should point to the path of the output bundle generated by our Vite build.


{
		//Other config here
    "extensions": [
        {
            "name": "MySite.entrypoint",
            "alias": "MySite.EntryPoint",
            "type": "entryPoint",
            "js": "/app_plugins/BackofficeExtension/mysite-backofficeextension.js"
        }
    ]
}

umbraco-package.json

Now, we can add the same dashboard, but this time using TypeScript. To achieve this, we’ll create a new manifest file in our project and define our typed extension manifest.

 

I recommend always defining the alias of a extension as a constant. This practice allows you to reuse the alias in other parts of your project, reducing errors and making your code more maintainable.


import { ManifestDashboard } from "@umbraco-cms/backoffice/extension-registry";

export const DASHBOARD_ALIAS = 'MySite.Dashboard';

export const DashboardManifest: ManifestDashboard = {
    type: 'dashboard',
    name: 'My New Dashboard',
    alias: DASHBOARD_ALIAS,
    meta: {
        label: 'My Dashobard',
        pathname: 'my-dashboard'
    }
};

/src/dashboard/manifest.ts

Next, we’ll register our extension in the index file. We can do this by adding the objects to the manifests array.


//imports here

const manifests: Array<ManifestTypes> = [
		//Add manifests to register here
    DashboardManifest
];

//export here

/src/index.ts

Great! After building our Vite project and starting the Umbraco project, we can see that the new dashboard is now successfully registered.

The Umbraco Backoffice displays the newly created dashboard using our specified entry point.

However, as you navigate to other sections, you’ll quickly notice that our new dashboard is now visible across all sections.

The newly created dashboard is now visible in the Media section.

We can resolve this by adding conditions to the dashboard extension to control where it is displayed. For instance, we can restrict the dashboard to only show in the content section.

This can be achieved by adding a conditions property to the extension, where you define the alias and the matching value.

In this case, to restrict the dashboard to the content section, we’ll use the Umb.Condition.SectionAlias alias and set the match property to the alias of the section where we want the dashboard to appear.


export const DASHBOARD_ALIAS = 'MySite.Dashboard';

export const DashboardManifest: ManifestDashboard = {
    type: 'dashboard',
    name: 'My Dashboard',
    alias: DASHBOARD_ALIAS,
    meta: {
        label: 'My New Dashobard',
        pathname: 'my-dashboard'
    },
    conditions: [
        {
            alias: 'Umb.Condition.SectionAlias',
            match: "Umb.Section.Content"
        }
    ]
};

The customizing can begin!

Now that our project is successfully set up, we can start working on our customizations.

Setting up a section

Let’s begin by adding a custom section for our extension. To do this, we’ll need to create a ManifestSection.

First, we’ll define a constant for the alias of our section. This allows us to easily reuse the alias later in the project. After that, we’ll register the section extension to ensure it’s recognized by Umbraco.


//export this because we will need it later!
export const SECTION_ALAIS = 'MySite.PokedexSection';

export const SectionManifest: ManifestSection = {
    type: 'section',
    alias: SECTION_ALAIS,
    name: 'Pokedex Section',
    weight: 10,
    meta: {
        label: 'Pokedex',
        pathname: 'pokedex'
    }
};

Tip

Don’t forget to register this manifest using the entrypoint!

Upon startup, you'll notice that the section tab isn’t visible yet. Just like in the old backoffice, we need to grant access to specific user groups for the section. Once that’s done, simply refresh the page, and you'll see the new section tab!

The backoffice now displays our newly created section tab.

Adding a sidebar app with menu

Next, we’ll add a sidebar with a menu to display an overview of all the Pokémon.

As mentioned, we’ll be using the PokeAPI for this. I’ll create two types to parse the response from the Pokémon endpoint. Additionally, I’ll set up a service to access the API, which will include a GetPokemon method that returns an array of Pokemon.

For reference, here is the response model.

Sidebar

In the new backoffice, this part of the section is referred to as the "Sidebar":

Extending the sidebar

To extend the sidebar, we can use Sidebar Apps. The sidebar app offers various types, and for this example, we’ll use menuWithEntityActions since we want to add a menu along with entity actions. To add a menu to the sidebar, we’ll need a Section Sidebar App, Menu, and Menu Item extension.

We’ll use the Menu Item component to fetch the data and render the menu items. For this, we’ll create a Lit element that implements UmbMenuItemElement. For more details about the menu-item component, check out the uui library.

Here’s a simple example of how to set up the menu items element to render a menu item for each Pokémon using Lit's functionalities:


const elementName = 'pokedex-menu-items';

@customElement(elementName)
class PokedexMenuItems extends UmbLitElement implements UmbMenuItemElement {
    @state()
    private _items: Pokemon[] = []; // Store fetched items
    @state()
    private _loading: boolean = true; // Track loading state
    @state()
    private _error: string | null = null; // Track any errors

    constructor() {
        super();
        this.fetchPokemon(); // Start fetching on component load
    }

    // Fetch tree items
    async fetchPokemon() {
        try {
            this._loading = true;
            this._items = (await PokemonService.GetPokemon()); // Fetch root-level items
        } catch (e) {
            this._error = 'Error fetching items';
        } finally {
            this._loading = false;
        }
    }

    // Render items
    renderItems(items: Pokemon[]): TemplateResult {
        return html`
            ${items.map(element => html`
                <uui-menu-item label="${element.name}">
                </uui-menu-item>
            `)}
        `;
    }

    // Main render function
    render() {
        //Showing loading state
        if (this._loading) {
            return html`<uui-loader></uui-loader>`;
        }

        //Showing error state
        if (this._error) {
            return html`<uui-menu-item active disabled label="Could not load pokemon!">
        </uui-menu-item>`;
        }

        // Render items if loading is done and no error occurred
        return html`${this.renderItems(this._items)}`;
    }
}


//Export and declare the element
export { PokedexMenuItems as element };

declare global {
    interface HTMLElementTagNameMap {
        [elementName]: PokedexMenuItems;
    }
}

src/section/menu/menu-items.ts

Now that we have our menu items component, we can add the manifests and link our element to the menu item manifest. We’ll then link the menu items Lit element to the element property of the ManifestMenuItem.

 

For this, we need a ManifestSectionSidebarApp, ManifestMenu, and as mentioned, our ManifestMenuItem:


const sidebarAppManifest: ManifestSectionSidebarApp =
{
    type: 'sectionSidebarApp',
    kind: 'menuWithEntityActions',
    alias: SIDEBARAPP_ALIAS,
    name: 'Pokedex Sidebar App',
    meta: {
        label: "Pokemon",
        menu: MENU_ALIAS
    },
    conditions: [
        {
            alias: "Umb.Condition.SectionAlias",
            match: SECTION_ALAIS
        }
    ]
};

const menuManifest: ManifestMenu =
{
    type: 'menu',
    alias: MENU_ALIAS,
    name: 'Pokedex Menu',
};

const menuItemManifest: ManifestMenuItem = {
    type: 'menuItem',
    kind: 'tree',
    alias: MENUITEM_ALIAS,
    name: 'Pokedex Menu Items',
    meta: {
        label: 'Pokemon',
        menus: [MENU_ALIAS]
    },
    //Link the menu items lit element
    element: () => import('./menu-items.ts')
};

export const menuManifests = [sidebarAppManifest, menuManifest, menuItemManifest];

src/section/menu/manifest.ts

When we start our Umbraco project and navigate to the Pokedex section, we can now see our newly added menu displaying the Pokémon from the Pokémon endpoint!

Adding dynamic routes

Suppose we want to create screens to access specific information about Pokémon, such as:

  • General Info
  • Moves

To achieve this, we can use a SectionView to manage the routing logic and render the appropriate components.

Representation of a section view in the Umbraco Backoffice

This image shows the component we refer to as a section view.

For our SectionView, we can create an element that extends UmbLitElement:


import { UmbLitElement } from "@umbraco-cms/backoffice/lit-element";
import { html, customElement } from '@umbraco-cms/backoffice/external/lit';

const element = "pokedex-sectionview";
@customElement(element)
export class PokedexSectionView extends UmbLitElement {

    render() {
        return html`
        <h2>Test</h2>
    `
    }
}

export default PokedexSectionView;

declare global {
    interface HTMLElementTagNameMap {
        [element]: PokedexSectionView;
    }
}

src/section/sectionView/manifest.ts

Let’s test this by creating and registering the manifest. For this, we’ll need a ManifestSectionView.

We can then link our section view component to the element property of the extension manifest.


const SECTIONVIEW_ALIAS = "Pokedex.SectionView";

export const sectionViewManifest: ManifestSectionView =
{
    type: "sectionView",
    alias: SECTIONVIEW_ALIAS,
    name: "Pokedex Section View",
    element: () => import('./sectionview.ts'),
    meta: {
        label: "",
        pathname: "pokemon",
        icon: ""
    },
    conditions: [
        {
            alias: 'Umb.Condition.SectionAlias',
            match: SECTION_ALAIS,
        }
    ]
};

src/section/sectionView/manifest.ts

After building the project and navigating to our section, we can see the newly created section view being rendered!

Next, let’s add routes to display the desired data. We’ll start by creating two Lit elements (similar to the one we made for the SectionView): one for the root, one for general info, and one for moves. These will be placed in /src/section/sectionView/routes.

By using the @property decorator, we can define properties for the elements. In this example, we only need an id property.


const element = "pokedex-moves";

@customElement(element)
export class Moves extends UmbLitElement{
    @property({ type: String })
    public idProp!: string;

    protected render = () => {
        return html`<div>
	        //Code for rendering moves here
        </div>`;
    }
}

export default Moves;

declare global {
    interface HTMLElementTagNameMap {
        [element]: Moves;
    }
}

/src/section/sectionView/routes/moves.ts

We’ll render an unordered list with all the moves from the API and set up the routing. In the root component, we can add some basic content for the section’s root.

Take a look at the elements I used in this example here.

To configure the routing, we’ll use UmbRoute. Each route will require a path and a component, and we’ll use the setup property to pass any necessary properties. Additionally, we can include a redirect route for empty paths and a handler for routes that aren’t found.


 #routes: UmbRoute[] = [
        {
            path: 'root',
            component: () => import('./routes/root'),
        },
        {
            path: 'general-info/:id',
            component: () => import('./routes/generalInfo'),
            setup: (component, info) => {
                const element = component as GeneralInfo;
                element.idProp = info.match.params.id;
            }
        },
        {
            path: 'moves/:id',
            component: () => import('./routes/moves'),
            setup: (component, info) => {
                const element = component as Moves;
                element.idProp = info.match.params.id;
            }
        },
        {
            path: '',
            redirectTo: 'root',
        },
        {
            path: `**`,
            component: async () => (await import('@umbraco-cms/backoffice/router')).UmbRouteNotFoundElement,
        },
    ];

/src/section/sectionView/sectionview.ts

To render the appropriate component based on the route, we simply use an umb-router-slot.


    render() {
        return html`
        <umb-router-slot id="router-slot" .routes=${this.#routes}></umb-router-slot>
    `
    }

/src/section/sectionView/sectionview.ts

When navigating to the Pokédex section, we see the root information for our section. Updating the URL to /moves/1 displays the dynamic text as expected. Now we could use the API service to fetch the data end render the base information.

To enable routing, we can add a href attribute to the menu items. This will render our general information component when the item is clicked.


renderItems(items: Pokemon[]): TemplateResult {
    return html`
        ${items.map(element => html`
            <uui-menu-item  href="/section/pokedex/view/pokemon/general-info/${element.id}" label="${element.name}">
            </uui-menu-item>
        `)}
    `;
}

/src/section/menu/menu-items.ts

Adding Entity Actions

The next step is to add an entity action that navigates to our "moves" route.

To begin, create a new element that extends UmbEntityActionBase. This element will contain the logic executed when the entity action button is clicked.

In this example, we’ll extract the id from the arguments and use it to navigate to the "moves" route associated with that ID.


export class ShowMoves extends UmbEntityActionBase<ManifestEntityActionDefaultKind> {
    override async execute() {
        let id = parseInt(this.args.unique);

        window.history.pushState({}, '', `section/pokedex/view/pokemon/moves/${id}`);
    }
}

export default ShowMoves;

src/section/menu/entityActions/show-moves.api.ts

To register the entity action, use ManifestEntityAction and link your custom element via the api property. You can also define an icon for the action button.

To control which types of entities the action applies to, use the forEntityTypes property.


const entityActionManifests : Array<ManifestEntityAction> = [
    {
        type: 'entityAction',
        alias: 'showMoves',
        name: 'Show Moves',
        kind: 'default',
        api: () => import('./entityActions/show-moves.api.ts'),
        forEntityTypes: [POKEMON_ENTITY_TYPE],
        meta: {
            icon: 'icon-bird',
            label: 'Moves'
        },
    },
]

src/section/menu/menifest.ts

To render the action buttons, place an umb-entity-actions-bundle element within the actions slot of the menu-item element and specify the entityType. Additionally, you can pass parameters to customize its behavior.


renderItems(items: Pokemon[]): TemplateResult {
    return html`
        ${items.map(element => html`
            <uui-menu-item ?active=${this._active === element.id} 
            href="/section/pokedex/view/pokemon/general-info/${element.id}" 
            label="${element.name}" @click=${() => this._active = element.id}>
      <umb-entity-actions-bundle
				slot="actions"
				.entityType=${POKEMON_ENTITY_TYPE}
				.unique=${element.id}
				.label=${"label"}>
			</umb-entity-actions-bundle>
            </uui-menu-item>
        `)}
    `;
}

When we run this, we can see our bird icon button, which navigates to the "moves" route when clicked.

Adding Modals to Enhance Functionality

The final topic I want to discuss in this post is modals. Modals are useful for displaying additional information or requesting confirmation for an action.

Modals can function as either a dialog or a sidebar. For more details, check out the documentation.

So far, we’ve displayed the Pokémon’s general information and moves. Now, let’s use a modal to showcase the Pokémon’s stats.

To set up a modal, we’ll need the following:

  1. ManifestModal: Used for registration.
  2. UmbModalToken: Defines the modal with a value type and additional parameters, such as size.
  3. Web Component: In this case, I’m using a LitElement that extends UmbModalBaseElement.

Let’s begin by defining the value type for the modal. This will specify the model that the modal requires.


export interface StatsModalType {
    id: number;
}

src/section/menu/stats-modal/stats-modal.type.ts

Next, we need to configure the modal token. This defines the modal and links it to a specific value type and alias.

Additionally, we can specify the modal’s size and type. For instance, a dialog appears as a popup, while a sidebar displays as a popover on the side.


export const STATS_MODAL_ALIAS = 'POKEDEX.STATS.MODAL';

export const STATS_MODAL = new UmbModalToken<StatsModalType, boolean>(STATS_MODAL_ALIAS, {
        modal: {
            type: 'dialog',
            size: 'medium',
        },
    });
    

src/section/menu/stats-modal/stats-modal.element.ts

Tip

For your reference: the size parameter is applicable only to dialog.

Finally, let’s create the element that will display the data and register the modal. This element will appear within the modal and handle fetching the necessary data.

 

Element:


const elementName = 'pokedex-stats-modal';

@customElement(elementName)
export class StatsModalElement extends UmbModalBaseElement<StatsModalType, boolean> {

    @state()
    private _pokemonData: PokemonDetail | undefined;

    constructor() {
        super();
    }

    firstUpdated(): void {
        if (!this.data)
            throw new Error('Id is required to show pokemon stats');

        PokemonService.GetPokemonById(this.data.id).then(x => {
            this._pokemonData = x;
        });
    }

    render() {
        return this._pokemonData && html`
        <uui-dialog-layout>
            <h3>
                ${this._pokemonData?.name ?? "Not found"}
            </h3>
            <hr />
            <div>
                    <img src=${this._pokemonData.sprites.front_default} alt=${this._pokemonData?.name} />
            </div>
            <h2>Stats</h2>
                <ul>
                    ${this._pokemonData.stats.map(x => html`<li>
                        <b>${x.stat.name}</b>
                        <span>${x.base_stat}</span>
                    </li>`)}
                </ul>
        </uui-dialog-layout>
    `;
    }
}

export default StatsModalElement;

declare global {
    interface HTMLElementTagNameMap {
        [elementName]: StatsModalElement;
    }
}

src/section/menu/stats-modal/stats-modal.element.ts

To register this modal, we use the ManifestModal and link the element through the element property.


const modalManifests: Array<ManifestModal> = [
    {
        type: 'modal',
        alias: STATS_MODAL_ALIAS,
        name: 'Stats Modal',
        element: () => import('./stats-modal/stats-modal.element.ts'),
    }
];

src/section/menu/manifest.ts

We’ll now add functionality to display the modal when the entity action is clicked by creating an element that extends the UmbEntityActionBase. In this element, we can consume the UMB_MODAL_MANAGER_CONTEXT to access the modalManager and open a modal using its token, passing the value type data to this method.

 

For more detailed information about the Context API, be sure to explore the documentation!


export class ShowStats extends UmbEntityActionBase<ManifestEntityActionDefaultKind> {
    override async execute() {
        const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);

        let id = 1;

         modalManager.open(this._host, STATS_MODAL, {
            data: {
                id: id
            },
        });
    }
}

export default ShowStats;

src/section/menu/entityActions/show-stats.api.ts

To complete the modal setup, we need an entity action manifest to link the entity type and assigning an icon.


{
    type: 'entityAction',
    alias: 'showStats',
    name: 'Show Stats',
    kind: 'default',
    api: () => import('./entityActions/show-stats.api.ts'),
    forEntityTypes: [POKEMON_ENTITY_TYPE],
    meta: {
        icon: 'icon-pulse',
        label: 'Stats'
    },
}

src/section/menu/manifest.ts

Clicking the new entity action will open a modal displaying the stats of the selected Pokémon.

Conclusion

I hope this post provided valuable insights into setting up an extension for the new backoffice. If you have any questions, suggestions, or would like to discuss further, feel free to connect with me (for example, on LinkedIn)!

For more detailed guidance, check out the official documentation or explore other related posts on this topic. I'd love to hear about your experiences, so feel free to share what you’ve learned!

 

Check out the source code of this example here!