Building a Custom Tiptap Extension for Umbraco

This guide walks through building a real Tiptap extension for the Umbraco backoffice — a DateTime inserter that lets editors insert the current date, time, or both into a Rich Text Editor (RTE) field from a toolbar dropdown menu.

By the end you will understand:

  • How Tiptap extensions work and how to write one from scratch

  • How Umbraco's extension registry connects Tiptap to the backoffice

  • How to wire up a toolbar dropdown menu with multiple actions

  • How to register everything via a manifest bundle


Project structure

src/tiptap-extensions/
├── insert-datetime.extension.ts   # 1. The raw Tiptap extension
├── datetime.tiptap-api.ts         # 2. Umbraco bridge — registers the extension with the editor
├── datetime.tiptap-toolbar-api.ts # 3. Umbraco toolbar action handler
└── manifest.ts                    # 4. Manifest — tells Umbraco these extensions exist

These four files map to four distinct concepts. Each is explained in detail below.


Step 1 — The Tiptap extension (insert-datetime.extension.ts)

This is a plain Tiptap extension with no Umbraco dependency. It can be used in any Tiptap editor, inside or outside Umbraco.

Declaring options

export interface InsertDateTimeOptions {
  format: 'date' | 'time' | 'datetime' | 'iso';
}

Extension.create<T>() accepts a generic type parameter for options. Declaring a typed interface makes this.options fully type-safe throughout the extension.

Extending the Tiptap command type

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    insertDateTime: {
      insertDateTime: (format?: 'date' | 'time' | 'datetime' | 'iso') => ReturnType;
    };
  }
}

This is a TypeScript module augmentation. It adds insertDateTime to Tiptap's built-in Commands interface so that editor.commands.insertDateTime() and editor.chain().focus().insertDateTime().run() are fully typed everywhere.

addOptions

addOptions() {
  return {
    format: 'datetime',
  };
},

Returns the default options. These are merged with any options passed to InsertDateTime.configure({ format: 'date' }) when the extension is registered with an editor.

addCommands

addCommands() {
  const defaultFormat = this.options.format;

  return {
    insertDateTime:
      (format = defaultFormat) =>
      ({ commands }) => {
        // build formattedDateTime string from `format`...
        return commands.insertContent(formattedDateTime);
      },
  };
},

A command factory in Tiptap follows a curried pattern:

commandName(userArgs) => ({ editor, commands, tr, ... }) => boolean

The outer function receives the arguments the caller passes (here, format). The inner function receives the editor context and performs the actual work, returning true on success.

Why capture this.options.format before the arrow function? TypeScript's noImplicitThis rule cannot infer the type of this inside the default-parameter expression of the nested arrow function. Capturing the value into a const before the arrow function sidesteps the error cleanly.

addKeyboardShortcuts

addKeyboardShortcuts() {
  return {
    'Mod-Shift-d': () => this.editor.commands.insertDateTime('date'),
    'Mod-Alt-t':   () => this.editor.commands.insertDateTime('time'),
    'Mod-Alt-d':   () => this.editor.commands.insertDateTime('datetime'),
  };
},

Mod resolves to Ctrl on Windows/Linux and Cmd on macOS. Tiptap uses Prosemirror's keymap syntax. Each handler calls our own command, keeping the logic in one place.


Step 2 — The Umbraco extension bridge (datetime.tiptap-api.ts)

import { UmbTiptapExtensionApiBase } from '@umbraco-cms/backoffice/tiptap';
import { InsertDateTime } from './insert-datetime.extension';

export default class UmbTiptapDateTimeExtensionApi extends UmbTiptapExtensionApiBase {
    getTiptapExtensions = () => [InsertDateTime];
}

Umbraco's RTE does not accept raw Tiptap extensions directly. Instead it uses an API class as an adapter. UmbTiptapExtensionApiBase is the abstract base class provided by Umbraco that you must extend.

The only required method is getTiptapExtensions(), which returns the array of Tiptap Extension | Mark | Node objects to load into the editor. Umbraco calls this when building the editor instance.

This class is loaded lazily via the manifest (see Step 4), so it is only fetched when the editor that has this extension enabled actually renders.

default export is required. Umbraco's extension loader expects a default export from the file pointed to by the api manifest property.


Step 3 — The toolbar action handler (datetime.tiptap-toolbar-api.ts)

import { UmbTiptapToolbarElementApiBase } from '@umbraco-cms/backoffice/tiptap';
import type { Editor } from '@umbraco-cms/backoffice/tiptap';
import type { InsertDateTimeOptions } from './insert-datetime.extension.js';

export default class UmbTiptapToolbarDateTimeExtensionApi extends UmbTiptapToolbarElementApiBase {
    override execute(editor?: Editor, ...args: Array<unknown>): void {
        const item = args[0] as { data?: InsertDateTimeOptions['format'] } | undefined;
        editor?.chain().focus().insertDateTime(item?.data ?? 'datetime').run();
    }
}

UmbTiptapToolbarElementApiBase is the base class for toolbar buttons and menus. You must implement execute, which Umbraco calls when the user clicks a toolbar item.

Why ...args?

The base class signature is:

abstract execute(editor?: Editor, ...args: Array<unknown>): void;

When the toolbar item uses kind: 'menu', Umbraco passes the selected menu item as the first argument. Spreading args and reading args[0] is the correct way to receive it while still satisfying the abstract method signature.

The menu item shape

Each item in the manifest's items array has a data property. Umbraco passes the entire item object to execute, so args[0].data gives us the value we defined — in this case the format string ('date', 'time', 'datetime', or 'iso').


Step 4 — The manifest (manifest.ts)

The manifest is how Umbraco discovers and registers extensions. It is a plain JavaScript object — no class, no decorator.

export const manifests: Array<UmbExtensionManifest> = [
  {
    type: 'tiptapExtension',
    alias: 'My.Tiptap.DateTime',
    name: 'My DateTime Tiptap Extension',
    api: () => import('./datetime.tiptap-api.js'),
    meta: {
      icon: 'icon-alarm-clock',
      label: 'DateTime',
      group: '#tiptap_extGroup_formatting',
    },
  },
  {
    type: 'tiptapToolbarExtension',
    kind: 'menu',
    alias: 'My.Tiptap.Toolbar.DateTime',
    name: 'My DateTime Tiptap Toolbar Extension',
    api: () => import('./datetime.tiptap-toolbar-api.js'),
    forExtensions: ['My.Tiptap.DateTime'],
    items: [
      { label: 'Date',       data: 'date',     appearance: { icon: 'icon-calendar' } },
      { label: 'Time',       data: 'time',      appearance: { icon: 'icon-time' } },
      { label: 'Date & Time', data: 'datetime', appearance: { icon: 'icon-alarm-clock' }, separatorAfter: true },
      { label: 'ISO',        data: 'iso',       appearance: { icon: 'icon-code' } },
    ],
    meta: {
      alias: 'datetime',
      icon: 'icon-calendar-alt',
      label: 'DateTime',
    },
  },
];

Two manifest types, two purposes

Type

Purpose

tiptapExtension

Registers the extension with the editor. Appears in the Extensions tab of a Rich Text Editor data type so editors can enable/disable it.

tiptapToolbarExtension

Adds a button or menu to the Toolbar configuration of the data type. Only available when the parent tiptapExtension is enabled.

Key properties

tiptapExtension

Property

Description

alias

Unique identifier — used as the key in forExtensions

api

Dynamic import returning the default-exported bridge class

meta.group

Localisation key that groups the extension in the UI. Valid values: #tiptap_extGroup_formatting, #tiptap_extGroup_interactive, #tiptap_extGroup_media, #tiptap_extGroup_html

Do not add kind to a tiptapExtension. Only tiptapToolbarExtension uses kind. Adding it to tiptapExtension will prevent the extension from appearing in the Data Type editor.

tiptapToolbarExtension

Property

Description

kind

'button' for a single action, 'menu' for a dropdown with multiple items

forExtensions

Array of tiptapExtension aliases — the toolbar item only appears when all listed extensions are enabled

items

(menu only) Array of { label, data, appearance?, separatorAfter? } objects passed to execute on selection

api

Dynamic import returning the default-exported toolbar handler class


Step 5 — Wiring it all into the bundle

Umbraco discovers extensions by scanning App_Plugins folders for umbraco-package.json files. The package registers a single bundle entry point:

{
  "extensions": [
    {
      "type": "bundle",
      "alias": "MediaWiz.Extension.Bundle",
      "js": "/App_Plugins/MediaWizExtension/media-wiz-extension.js"
    }
  ]
}

The bundle loader imports the JS file at runtime and calls registerMany() on every array it finds exported from the module. The bundle entry point (bundle.manifests.ts) collects and re-exports all manifest arrays from across the project:

import { manifests as tiptapExtensions } from './tiptap-extensions/manifest.js';

export const manifests: Array<UmbExtensionManifest> = [
  // ...other manifests
  ...tiptapExtensions,
];

Vite builds this into a single media-wiz-extension.js file with lazy chunks for each api import:

// vite.config.ts
rollupOptions: {
  external: [/^@umbraco/], // Umbraco packages are provided by the backoffice at runtime
},

Marking @umbraco/* as external keeps the bundle small — the backoffice provides those modules via import maps and they do not need to be bundled into your output.


Enabling the extension in Umbraco

  1. Build the client: npm run build

  2. Run the Umbraco site

  3. Go to Settings → Data Types and open (or create) a Rich Text Editor data type

  4. Under the Extensions tab, enable DateTime
    tiptap extension

  5. Under the Toolbar tab, drag the DateTime button into a toolbar row
    tiptap button

  6. Save the data type

The toolbar will now show a DateTime dropdown with four options: Date, Time, Date & Time, and ISO.


Common pitfalls

Problem

Cause

Fix

Extension does not appear in the Extensions tab

kind was set on the tiptapExtension manifest

Remove kind — it only belongs on tiptapToolbarExtension

Toolbar item does not appear

forExtensions alias does not match the tiptapExtension alias exactly

Check the alias is identical in both manifests

api class is not loaded

Used js: instead of api: in the manifest

Use api: () => import(...) for both extension types

this implicitly has type any

Used this.options inside a nested arrow function default parameter

Capture this.options.x into a const before the arrow function