Extension API
The power of Tiptap lies in its flexibility. You can create your own extensions from scratch and build a unique editor experience tailored to your needs.
The base extension structure is the same for all extensions, whether you're creating a node, a mark, or a functionality change. And, everything in Tiptap is based on extensions.
Creating an extension
Extensions add new capabilities to Tiptap. You'll read the word "extension" a lot in the docs, even for nodes and marks. But there are literal extensions, too. These can't add to the schema (like marks and nodes do), but they can add functionality or change the behavior of the editor.
A good example would be something that listens to the editor's events and does something with them. Like this:
import { Extension } from '@tiptap/core'
const CustomExtension = Extension.create({
name: 'customExtension',
onUpdate() {
console.log(this.editor.getJSON())
},
})You can also use a callback function to create an extension. This is useful if you want to encapsulate the logic of your extension, for example when you want to define event handlers or other custom logic.
import { Extension } from '@tiptap/core'
const CustomExtension = Extension.create(() => {
// Define variables or functions to use inside your extension
const customVariable = 'foo'
function onCreate() {}
function onUpdate() {}
return {
name: 'customExtension',
onCreate,
onUpdate,
// Your code goes here.
}
})This extension listens to the editor's update event and logs the editor's current JSON representation to the console.
It is installed to the editor like this:
import { Editor } from '@tiptap/core'
const editor = new Editor({
extensions: [CustomExtension],
})
// Or if using React or Vue
const editor = useEditor({
extensions: [CustomExtension],
})This extensions array can contain any number of extensions. They will be installed in the order they are listed, or sorted by their priority property.
Now that we've seen the basic structure of an extension, let's dive into all of the extension options you can use to create your own extensions.
Extension options
When creating an extension, you can define a set of options that can be configured by the user. These options can be used to customize the behavior of the extension, or to provide additional functionality.
name
The name of the extension. This is used to identify the extension in the editor's extension manager.
const CustomExtension = Extension.create({
name: 'customExtension',
})If the extension is a node or a mark, the name is used to identify the node or mark in the editor's schema and therefore persisted to the JSON representation of the editor's content. See store your content as JSON for more information.
priority
The priority defines the order in which extensions are registered. The default priority is 100, that’s what most extension have. Extensions with a higher priority will be loaded earlier.
import Link from '@tiptap/extension-link'
const CustomLink = Link.extend({
priority: 1000,
})The order in which extensions are loaded influences two things:
- Plugin order (ProseMirror plugins of extensions with a higher priority will run first.)
- Schema order
The Link mark for example has a higher priority, which means it will be rendered as <a href="…"><strong>Example</strong></a> instead of <strong><a href="…">Example</a></strong>.
addOptions
The addOptions method is used to define the extension's options. This method should return an object with the options that can be configured by the user.
type CustomExtensionOptions = {
customOption: string
}
declare module '@tiptap/core' {
interface ExtensionOptions {
customOption: CustomExtensionOptions
}
}
const CustomExtension = Extension.create<CustomExtensionOptions>({
name: 'customExtension',
addOptions() {
return {
customOption: 'default value',
}
},
})This exposes configuration which can be set when installing the extension:
const editor = new Editor({
extensions: [CustomExtension.configure({ customOption: 'new value' })],
})addStorage
The addStorage method is used to define the extension's storage (essentially a simple state manager). This method should return an object with the storage that can be used by the extension.
type CustomExtensionStorage = {
customValue: string
}
declare module '@tiptap/core' {
interface ExtensionStorage {
customExtension: CustomExtensionStorage
}
}
const CustomExtension = Extension.create<any, CustomExtensionStorage>({
name: 'customExtension',
addStorage() {
return {
customValue: 'default value',
}
},
})This exposes storage which can be accessed within the extension:
const CustomExtension = Extension.create<any, CustomExtensionStorage>({
name: 'customExtension',
addStorage() {
return {
customValue: 'default value',
}
},
onUpdate() {
console.log(this.storage.customValue) // 'default value'
},
})Or, by the editor:
const editor = new Editor({
extensions: [CustomExtension],
})
editor.storage.customExtension.customValue // 'default value'Notice
The editor.storage is namespaced by the extension's name.
addCommands
The addCommands method is used to define the extension's commands. This method should return an object with the commands that can be executed by the user.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
customExtension: {
customCommand: () => ReturnType
}
}
}
const CustomExtension = Extension.create({
name: 'customExtension',
addCommands() {
return {
customCommand:
() =>
({ commands }) => {
return commands.setContent('Custom command executed')
},
}
},
})Use the commands parameter inside of addCommands
To access other commands inside addCommands use the commands parameter that’s passed to it.
This exposes commands which can be executed by the user:
const editor = new Editor({
extensions: [CustomExtension],
})
editor.commands.customCommand() // 'Custom command executed'
editor.chain().customCommand().run() // 'Custom command executed'addKeyboardShortcuts
The addKeyboardShortcuts method is used to define keyboard shortcuts. This method should return an object with the keyboard shortcuts that can be used by the user.
const CustomExtension = Extension.create({
name: 'customExtension',
addKeyboardShortcuts() {
return {
'Mod-k': () => {
console.log('Keyboard shortcut executed')
},
}
},
})This exposes keyboard shortcuts which can be used by the user.
addInputRules
With input rules you can define regular expressions to listen for user inputs. They are used for markdown shortcuts, or for example to convert text like (c) to a © (and many more) with the Typography extension. Use the markInputRule helper function for marks, and the nodeInputRule for nodes.
By default text between two tildes on both sides is transformed to striked text. If you want to think one tilde on each side is enough, you can overwrite the input rule like this:
import { Extension } from '@tiptap/core'
import { markInputRule } from '@tiptap/core'
const CustomExtension = Extension.create({
name: 'customExtension',
addInputRules() {
return [
markInputRule({
find: /(?:~)((?:[^~]+))(?:~)$/,
type: this.editor.schema.marks.strike,
}),
]
},
})Now, when you type ~striked text~, it will be transformed to striked text.
Want to learn more about input rules? Check out the Input Rules documentation.
addPasteRules
Paste rules work like input rules (see above) do. But instead of listening to what the user types, they are applied to pasted content.
There is one tiny difference in the regular expression. Input rules typically end with a $ dollar sign (which means “asserts position at the end of a line”), paste rules typically look through all the content and don’t have said $ dollar sign.
Taking the example from above and applying it to the paste rule would look like the following example.
import { Extension } from '@tiptap/core'
import { markPasteRule } from '@tiptap/core'
const CustomExtension = Extension.create({
name: 'customExtension',
addPasteRules() {
return [
markPasteRule({
find: /(?:~)((?:[^~]+))(?:~)/g,
type: this.editor.schema.marks.strike,
}),
]
},
})Want to learn more about paste rules? Check out the Paste Rules documentation.
transformPastedHTML
The transformPastedHTML method allows you to transform pasted HTML content before it's parsed and inserted into the editor. This is useful for cleaning up styles, removing dangerous content, or modifying pasted HTML to match your editor's requirements.
How it works
When users paste content into the editor:
- Extensions are sorted by priority (higher priority = runs first)
- Each extension's
transformPastedHTMLis called in order - Each transform receives the output from the previous transform
- The final transformed HTML is parsed and inserted into the editor
Basic example
import { Extension } from '@tiptap/core'
const CleanStylesExtension = Extension.create({
name: 'cleanStyles',
transformPastedHTML(html) {
// Remove all inline style attributes
return html.replace(/\s+style="[^"]*"/gi, '')
},
})Transform chain example
Multiple extensions can transform the same pasted content. Use the priority option to control the order:
// Extension 1: Remove scripts (runs first, priority: 110)
const SecurityExtension = Extension.create({
name: 'security',
priority: 110,
transformPastedHTML(html) {
let cleanHtml = html
// Remove script tags
cleanHtml = cleanHtml.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
''
)
// Remove event handler attributes (onclick, onerror, etc.)
cleanHtml = cleanHtml.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '')
// Remove javascript: protocol from href (handles double-quoted, single-quoted, and unquoted attributes)
cleanHtml = cleanHtml.replace(
/href\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]*)/gi,
'href="#"'
)
return cleanHtml
},
})
// Extension 2: Remove styles (runs second, priority: 100)
const CleanStylesExtension = Extension.create({
name: 'cleanStyles',
priority: 100,
transformPastedHTML(html) {
return html.replace(/\s+style="[^"]*"/gi, '')
},
})In this example:
SecurityExtensionruns first (priority 110) and removes dangerous contentCleanStylesExtensionreceives the output fromSecurityExtensionand removes styles- The final cleaned HTML is inserted into the editor
Using extension context
The method has access to extension context via this:
const ConditionalCleanup = Extension.create({
name: 'conditionalCleanup',
addOptions() {
return {
removeStyles: true,
removeClasses: false,
}
},
transformPastedHTML(html) {
let cleanHtml = html
// Access extension options
if (this.options.removeStyles) {
cleanHtml = cleanHtml.replace(/\s+style="[^"]*"/gi, '')
}
if (this.options.removeClasses) {
cleanHtml = cleanHtml.replace(/\s+class="[^"]*"/gi, '')
}
// Access editor instance
this.editor.emit('htmlTransformed', { original: html, transformed: cleanHtml })
return cleanHtml
},
})Common use cases
Remove Google Docs formatting:
const GoogleDocsCleanup = Extension.create({
name: 'googleDocsCleanup',
transformPastedHTML(html) {
return html
// Remove Google Docs spans with inline styles
.replace(/<span[^>]*style="[^"]*"[^>]*>(.*?)<\/span>/gi, '$1')
// Remove Google Docs IDs
.replace(/\s+id="docs-internal-[^"]*"/gi, '')
// Remove empty spans
.replace(/<span>(.*?)<\/span>/gi, '$1')
},
})Remove Microsoft Word formatting:
const WordCleanup = Extension.create({
name: 'wordCleanup',
transformPastedHTML(html) {
return html
// Remove Word-specific classes
.replace(/\s+class="Mso[^"]*"/gi, '')
// Remove Word-specific tags
.replace(/<o:p>.*?<\/o:p>/gi, '')
// Remove conditional comments
.replace(/<!--\[if.*?<!\[endif\]-->/gs, '')
},
})Events
You can even move your event listeners to a separate extension. Here is an example with listeners for all events:
import { Extension } from '@tiptap/core'
const CustomExtension = Extension.create({
onBeforeCreate() {
// The editor is about to be created.
},
onCreate() {
// The editor is ready.
},
onUpdate() {
// The content has changed.
},
onSelectionUpdate({ editor }) {
// The selection has changed.
},
onTransaction({ transaction }) {
// The editor state has changed.
},
onFocus({ event }) {
// The editor is focused.
},
onBlur({ event }) {
// The editor isn’t focused anymore.
},
onDestroy() {
// The editor is being destroyed.
},
})dispatchTransaction
This hook allows you to intercept and modify transactions before they are dispatched. It uses a middleware-like pattern similar to Koa or Express, where each extension receives the transaction and a next function.
How it works
- Middleware Chain: When a transaction is dispatched, Tiptap creates a chain of all extensions that define a
dispatchTransactionhook. - Order by Priority: The chain is sorted by extension priority. Extensions with a higher priority (e.g.,
1000) will be called earlier in the chain and wrap around extensions with a lower priority. - Passing Through: You must call
next(transaction)to pass the transaction to the next extension in the chain. Eventually, the lastnext()call will pass the transaction to the editor's base dispatch function (or your customeditorProps.dispatchTransactionif defined). - Blocking Transactions: If you don't call
next(transaction), the transaction will be blocked and never reach the editor.
import { Extension } from '@tiptap/core'
const LoggingExtension = Extension.create({
name: 'loggingExtension',
priority: 1000, // Runs early
dispatchTransaction({ transaction, next }) {
console.log('Intercepting transaction before others...')
// You can modify the transaction here if needed
// transaction.setMeta('customMeta', true)
// Call next to pass it to the next extension
next(transaction)
console.log('Transaction has been processed by the rest of the chain.')
},
})Middleware Lifecycle
Because each extension "wraps" the next one, you can execute code both before and after the transaction is processed by the rest of the chain (and the editor itself).
dispatchTransaction({ transaction, next }) {
// 1. Pre-processing: Code before next()
const start = performance.now()
next(transaction)
// 2. Post-processing: Code after next()
const end = performance.now()
console.log(`Dispatch took ${end - start}ms`)
}addProseMirrorPlugins
You can add ProseMirror plugins to your extension. This is useful if you want to extend the editor with ProseMirror plugins.
Using an existing ProseMirror plugin
You can wrap existing ProseMirror plugins in Tiptap extensions like shown in the example below.
import { history } from '@tiptap/pm/history'
const History = Extension.create({
addProseMirrorPlugins() {
return [
history(),
// …
]
},
})Creating a ProseMirror plugin
You can also create custom ProseMirror plugins. Here is an example of a custom ProseMirror plugin that logs a message to the console.
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Extension } from '@tiptap/core'
const CustomExtension = Extension.create({
name: 'customExtension',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('customPlugin'),
view() {
return {
update() {
console.log('Custom plugin updated')
},
}
},
}),
]
},
})To learn more about ProseMirror plugins, check out the ProseMirror documentation.
addGlobalAttributes
The addGlobalAttributes method is used to add attributes that can be applied to multiple extensions at once. This is useful for attributes like text alignment, line height, or other styling-related properties that should be available on many node and mark types.
You can specify the types property to control which extensions receive the attribute:
types: ['heading', 'paragraph']- Apply to specific type namestypes: '*'- Apply to all node types (excluding the built-in text node) and all mark typestypes: 'nodes'- Apply to all node types (excluding the built-in text node)types: 'marks'- Apply to all mark types
Here's an example applying attributes to specific types:
const TextAlign = Extension.create({
name: 'textAlign',
addGlobalAttributes() {
return [
{
types: ['heading', 'paragraph'],
attributes: {
textAlign: {
default: 'left',
renderHTML: (attributes) => ({
style: `text-align: ${attributes.textAlign}`,
}),
parseHTML: (element) => element.style.textAlign || 'left',
},
},
},
]
},
})Learn more about all the options, including the string shorthand syntax, in the Apply global attributes guide.
addExtensions
You can add more extensions to your extension. This is useful if you want to create a bundle of extensions that belong together.
import { Extension } from '@tiptap/core'
import CustomExtension1 from './CustomExtension1'
const CustomExtension = Extension.create({
name: 'customExtension',
addExtensions() {
return [
CustomExtension1.configure({
name: 'customExtension1',
}),
]
},
})extendNodeSchema
You can extend the editor's NodeConfig with the extendNodeSchema method. This is useful if you want to add additional attributes to the node schema.
import { Extension } from '@tiptap/core'
declare module '@tiptap/core' {
// This adds a new configuration option to the NodeConfig
interface NodeConfig {
customAttribute: {
default: null
}
}
}
const CustomExtension = Extension.create({
name: 'customExtension',
extendNodeSchema() {
return {
customAttribute: {
default: null,
},
}
},
})extendMarkSchema
You can extend the editor's MarkConfig with the extendMarkSchema method. This is useful if you want to add additional attributes to the mark schema.
import { Extension } from '@tiptap/core'
declare module '@tiptap/core' {
// This adds a new configuration option to the MarkConfig
interface MarkConfig {
customAttribute: {
default: null
}
}
}
const CustomExtension = Extension.create({
name: 'customExtension',
extendMarkSchema() {
return {
customAttribute: {
default: null,
},
}
},
})What’s available in this?
Those extensions aren’t classes, but you still have a few important things available in this everywhere in the extension.
// Name of the extension, for example 'bulletList'
this.name
// Editor instance
this.editor
// ProseMirror type (if a node or mark)
this.type
// Object with all settings
this.options
// Everything that’s in the extended extension
this.parent
// Storage object
this.storage