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.formatbefore the arrow function? TypeScript'snoImplicitThisrule cannot infer the type ofthisinside the default-parameter expression of the nested arrow function. Capturing the value into aconstbefore 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.
defaultexport is required. Umbraco's extension loader expects adefaultexport from the file pointed to by theapimanifest 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 |
|---|---|
| Registers the extension with the editor. Appears in the Extensions tab of a Rich Text Editor data type so editors can enable/disable it. |
| Adds a button or menu to the Toolbar configuration of the data type. Only available when the parent |
Key properties
tiptapExtension
Property | Description |
|---|---|
| Unique identifier — used as the key in |
| Dynamic import returning the default-exported bridge class |
| Localisation key that groups the extension in the UI. Valid values: |
Do not add
kindto atiptapExtension. OnlytiptapToolbarExtensionuseskind. Adding it totiptapExtensionwill prevent the extension from appearing in the Data Type editor.
tiptapToolbarExtension
Property | Description |
|---|---|
|
|
| Array of |
| (menu only) Array of |
| 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
Build the client:
npm run buildRun the Umbraco site
Go to Settings → Data Types and open (or create) a Rich Text Editor data type
Under the Extensions tab, enable DateTime
Under the Toolbar tab, drag the DateTime button into a toolbar row
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 |
| Remove |
Toolbar item does not appear |
| Check the alias is identical in both manifests |
| Used | Use |
| Used | Capture |