Rich Text Extensions

Rich text extensions add Tiptap nodes, marks, and toolbar buttons to the WYSIWYG interface.

Rich text extensions add Tiptap nodes, marks, and toolbar buttons to the WYSIWYG interface. A field uses an extension only when you select it in the interface's Extensions option.

You write nodes and marks with the standard Tiptap API. Directus adds a small config around them, and a set of rules that keep content intact when users save, compare, or remove an extension.

This extension type is loaded into the Directus Data Studio. They are are built with Vue 3, and can use the provided composables exported by the @directus/extensions-sdk package. Extensions can be written in JavaScript or TypeScript.

Before You Start

  • You know how to write a Tiptap node or mark with parseHTML and renderHTML.
  • You have a Directus project to test in. Set EXTENSIONS_AUTO_RELOAD=true while you develop.

Create an Extension

Run the extensions CLI and select the richtext type:

npx create-directus-extension@latest

The template contains one mark and one toolbar button. It adds @tiptap/core as a dev dependency so your editor can type-check Tiptap imports. The build does not bundle it. See Shared Tiptap Packages.

The generated package.json declares the extension type in directus:extension. Before you publish, set its host field as described in Set the Host Range.

Rich text extensions load in the Data Studio only. You can also add one to a bundle as a richtext entry.

Rich Text Entrypoint

The index.js or index.ts file exports the config. It lists the Tiptap extensions to add to the editor schema, and the buttons that act on them.

Entrypoint Example

This example adds a callout block and a toolbar button that wraps the selection in it.

import { defineRichText } from '@directus/extensions-sdk';
import { Node, mergeAttributes } from '@tiptap/core';

const Callout = Node.create({
    name: 'callout',
    group: 'block',
    content: 'block+',
    parseHTML: () => [{ tag: 'div[data-callout]' }],
    renderHTML: ({ HTMLAttributes }) => ['div', mergeAttributes(HTMLAttributes, { 'data-callout': '' }), 0],
});

export default defineRichText({
    id: 'callout',
    name: 'Callout',
    extensions: [Callout],
    buttons: [
        {
            key: 'callout',
            icon: 'info',
            label: 'Callout',
            command: (editor) => editor.chain().focus().toggleWrap('callout').run(),
            isActive: (editor) => editor.isActive('callout'),
        },
    ],
});

Properties

PropertyTypeDescription
idstringA unique identifier for this extension. Lowercase letters, digits, and hyphens only.
namestringThe name shown in the field's Extensions option.
extensionsarrayTiptap extensions, nodes, and marks to add to the editor. Each node and mark must follow the round-trip rule.
buttonsarrayToolbar buttons. Admins add them to a field in the Toolbar option.
bubbleMenusarrayBubble menus. They show on every field that enables the extension.
Unique Identifiers
The extension id must not conflict with other extensions, so consider prefixing with author name.

Toolbar Buttons

A button runs a command, or opens a dropdown of items. It cannot have both.

PropertyTypeDescription
keystringUnique inside the extension. Lowercase letters, digits, and hyphens only.
iconstringAn icon name from the Google Material Icons set. An unknown name shows the extension icon.
labelstringPlain text shown for the button.
commandfunctionReceives the Tiptap editor and runs when the user clicks the button.
itemsarrayDropdown entries. Each has a key, label, command, and optional icon, isActive, and isDisabled.
isActivefunctionReceives the editor and returns true to show the button as active. A dropdown without it is active when one of its items is.
isDisabledfunctionReceives the editor and returns true to disable the button. A disabled or read-only field disables every button anyway.

Bubble Menus

A bubble menu floats next to the selection when shouldShow returns true.

PropertyTypeDescription
keystringUnique inside the extension.
shouldShowfunctionReceives the editor and returns true to show the menu.
buttonsarrayToolbar buttons with a command. Dropdowns with items are not supported.

The editor hides bubble menus while the field is not editable, and while the cursor is in a table. When several menus match, only the first one shows. See Load Order.

Enable an Extension on a Field

An admin chooses which fields use an extension. Content in other fields is not affected.

  1. Open the settings of a field that uses the WYSIWYG interface.
  2. In Extensions, select your extension. This option shows only when at least one rich text extension is installed.
  3. In Toolbar, select your extension's buttons. The list shows them after you select the extension.
  4. Save the field.

The stored toolbar key of a contributed button is <extension id>:<button key>, for example callout:callout. A contributed key never replaces a core button.

The editor builds its schema when it opens. After you change a field's extensions, open the item again to use them.

Load Order

Directus sorts rich text extensions by id. This order is the same on every deployment. It sets:

  • Which extension wins when two use the same node or mark name.
  • The order in which the editor tries their parseHTML rules.
  • The order of contributed buttons in the toolbar, and which bubble menu shows when several match.

Contributed buttons go after the core buttons. When the toolbar is too narrow, they are the first to move into Show More.

Round-Trip Rule

The WYSIWYG interface stores HTML. When a field loads, the editor parses the stored HTML with each parseHTML rule. When it saves, it writes the HTML that renderHTML returns.

Every node and mark must be symmetric. parseHTML must read back exactly the markup that renderHTML writes. If not, the saved HTML differs from the loaded HTML, and every save of a field that uses the extension warns the user that saving alters the content.

  • Match each attribute and data attribute that renderHTML writes.
  • Raise the extension's priority when a core rule matches the same markup first. For example, the core textStyle mark claims any span with a data-* attribute, so the richtext template sets priority: 200 on its mark.

When the Data Studio loads, Directus checks each node and mark once and logs a console warning on a mismatch:

Richtext extension "callout": node "callout" does not survive a round trip. renderHTML writes <div data-callout="">...</div>, which parses back as ...

The check is best effort. It uses default attribute values only, and skips nodes that cannot stand alone, such as a list item. It does not detect a clash between two extensions. Test your extension by saving a field, and by opening the source code view and the comparison view.

Names and Validation

Directus validates each extension when the Data Studio loads. An extension that breaks a rule does not load, and the browser console shows the reason:

Richtext extension "callout" was not loaded: "paragraph" is a core node, mark or extension name

Other extensions and the editor keep working. An extension is rejected when:

  • Its id is not unique, or contains characters other than lowercase letters, digits, and hyphens.
  • A node, mark, or extension uses a core name, such as paragraph, heading, bold, link, table, or image.
  • A name starts with customFormat_. This prefix is reserved for the field's custom formats.
  • A name is already used by another rich text extension. The extension whose id sorts first keeps the name.
  • An attribute uses a name the core editor keeps on every element: class, id, title, role, lang, dir, dataAttributes, or ariaAttributes.
  • A button, menu item, or bubble menu key is not unique in its list.
  • It throws while Directus validates it or builds the editor schema with it.

Error Handling

A failing extension fails alone.

  • If the extension's code throws while it loads, Directus skips it.
  • If a command, isActive, isDisabled, or shouldShow callback throws, the editor disables that button or menu until the editor closes. The console shows the error. The core buttons keep working.

Node Views

A node can render with a Vue component in the editor. Use VueNodeViewRenderer, NodeViewWrapper, and NodeViewContent from the shared @tiptap/vue-3 package.

import { Node } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import CardView from './card-view.vue';

const Card = Node.create({
    name: 'card',
    group: 'block',
    content: 'block+',
    parseHTML: () => [{ tag: 'div[data-card]' }],
    renderHTML: () => ['div', { 'data-card': '' }, 0],
    addNodeView: () => VueNodeViewRenderer(CardView),
});

The field saves the renderHTML output, not the markup of the node view. Controls you add in the node view are never stored.

To hide edit controls when the field is not editable, use useRichTextEditable(). Do not use editor.isEditable. It does not update when the field later becomes read-only, disabled, or locked.

<template>
    <NodeViewWrapper class="card">
        <button v-if="editable" type="button" @click="deleteNode()">Remove</button>
        <NodeViewContent />
    </NodeViewWrapper>
</template>

<script setup>
import { useRichTextEditable } from '@directus/extensions-sdk';
import { NodeViewContent, NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3';

const { deleteNode } = defineProps(nodeViewProps);
const editable = useRichTextEditable();
</script>

useRichTextEditable() returns a read-only ref. Outside the WYSIWYG interface, its value is always true.

Style Your Nodes

Import a CSS file in your entrypoint. The extensions SDK bundles it and adds it to the page when the extension loads.

Scope every rule under the .richtext-content class. The editor root has this class in edit mode, in read-only mode, and in the comparison view.

.richtext-content div[data-callout] {
    padding: 12px 16px;
    border-inline-start: 4px solid var(--theme--primary);
    background-color: var(--theme--primary-background);
}

The core editor styles use low specificity, so your rules override them. The comparison view's change highlights always stay visible.

These styles apply in the Data Studio only. Your frontend renders the stored HTML, so add matching styles there. See Rich Text for how to style the editor output on your frontend.

Remove an Extension

When an admin removes an extension from a field, or uninstalls it, the field's schema no longer contains its nodes and marks. Content that still uses them follows the same flow as other markup the editor does not support:

  1. The field opens read-only and shows a notice.
  2. The user selects the field and can view the changes as a diff.
  3. The user chooses Keep Read-only, Edit Anyway, or Edit Raw HTML.

Edit Anyway removes the extension's markup when the user saves. Edit Raw HTML edits the stored HTML as text and keeps it. Enable the extension again to edit the content in the editor without losing markup.

Saved content keeps the extension's markup until a user saves the field without it. If a user saves without it by mistake, restore the earlier version from Revisions.

Wrap an Existing Tiptap Package

You can add a published Tiptap extension package to Directus.

  1. Install the package as a dependency of your extension. Use a version from the same Tiptap major as Directus.
  2. Add it to extensions, with .configure() for its options.
  3. Add buttons that run its commands.
import { defineRichText } from '@directus/extensions-sdk';
import Youtube from '@tiptap/extension-youtube';

export default defineRichText({
    id: 'youtube',
    name: 'YouTube',
    extensions: [Youtube.extend({ priority: 1000 }).configure({ nocookie: true })],
    buttons: [
        {
            key: 'embed',
            icon: 'smart_display',
            label: 'Embed YouTube video',
            command: (editor) => {
                const src = window.prompt('YouTube URL');
                if (src) editor.commands.setYoutubeVideo({ src });
            },
        },
    ],
});

The extensions SDK bundles the package code but keeps its Tiptap and ProseMirror imports on the shared copy. Before you publish, check the package against the rules on this page:

  • If its name is a core name, rename it with .extend({ name: '...' }).
  • If a core rule claims its markup first, raise its priority with .extend(), as in the example. The core editor parses iframe, video, and audio elements as media.
  • Save a field that uses it, and check the console for a round-trip warning.

Tiptap Version Policy

A rich text extension uses the copy of Tiptap that ships with the Data Studio. The extensions SDK does not bundle the shared Tiptap packages, so the editor and your extension use the same ProseMirror classes. ProseMirror checks objects by class, so objects from a second copy of ProseMirror fail these checks and the editor behaves incorrectly.

This makes the Tiptap version part of the extension API. A new Tiptap major version can break every installed rich text extension. Directus shares vue, vue-router, vue-i18n, and pinia with app extensions in the same way.

Shared Tiptap Packages

Import the following packages directly in your extension. The extensions SDK marks them as external when it builds, so your bundle uses the Data Studio's copy and does not include them.

  • @tiptap/core
  • @tiptap/vue-3
  • These @tiptap/pm subpaths: commands, dropcursor, gapcursor, history, keymap, model, schema-list, state, tables, transform, and view. For example, @tiptap/pm/keymap.

Each @tiptap/pm subpath re-exports one ProseMirror package. For example, @tiptap/pm/keymap re-exports prosemirror-keymap. When your code or a package you install imports a ProseMirror package behind a shared subpath by its own name, the extensions SDK changes the import to that subpath. So you can install Tiptap packages that use ProseMirror, such as @tiptap/extension-table. The SDK bundles the code of the package itself, but it uses the Data Studio's copy of ProseMirror.

The Data Studio does not use @tiptap/pm/changeset or @tiptap/pm/inputrules, so the extensions SDK bundles them into your extension. This is safe. They do not contain ProseMirror classes or plugin keys that the editor checks, and their own ProseMirror imports go to the shared subpaths.

If you install other Tiptap packages, use a version from the same Tiptap major as Directus.

Version Guarantees

DirectusTiptap
12.x3.x
  • Directus pins the Tiptap major version. Tiptap minor and patch updates can ship in any Directus release.
  • A new Tiptap major version ships only in a new Directus major version.
  • The breaking changes page for that Directus version lists the Tiptap upgrade.

Set the Host Range

The host field in your extension's package.json declares the Directus versions it supports. Set it to a caret range on the Directus version you build against.

"directus:extension": {
    "type": "richtext",
    "path": "dist/index.js",
    "source": "src/index.js",
    "host": "^12.6.0"
}

A caret range stops at the next major version. When a Directus major version upgrades Tiptap, your range no longer matches it.

The Marketplace compares host with the project's Directus version. When the range does not match, the extension's page shows Compatibility not guaranteed.

Directus does not check host when it loads an extension. An extension that you install manually loads on any version. Read the breaking changes before you upgrade a project that uses rich text extensions.

Upgrade After a Tiptap Major Version

When a Directus major version upgrades Tiptap, update your extension before your users upgrade.

  1. Read the breaking changes for the new Directus version and Tiptap's upgrade notes.
  2. Update the Tiptap APIs your extension uses.
  3. Build the extension and test it on the new Directus version.
  4. Set host to the new major version, for example ^13.0.0.
  5. Publish the update as a new major version of your extension.

Next Steps

Read about publishing to the Marketplace, the WYSIWYG interface options, and custom formats for the editor.

Get once-a-month release notes & real‑world code tips...no fluff. 🐰