Skip to main content
Extensions are at the heart of ProseKit’s flexibility and power. This guide will teach you how to create your own custom extensions to add exactly the functionality you need.

What are Extensions?

Extensions in ProseKit are modular pieces of functionality that you can add to your editor. They can provide:
  • Nodes (paragraphs, headings, lists, code blocks, etc.)
  • Marks (bold, italic, underline, etc.)
  • Commands (functions that modify the document)
  • Keyboard shortcuts
  • Input rules (automatic transformations as you type)
  • Custom behaviors and much more

Extension Composition

A key concept in ProseKit is that all extensions are composed of other extensions using the union function. This composability lets you create complex functionality by combining simpler building blocks.
import { union } from 'prosekit/core'
import { 
  defineBoldSpec,
  defineBoldCommands,
  defineBoldKeymap,
  defineBoldInputRule 
} from 'prosekit/extensions/bold'

function defineBold() {
  return union(
    defineBoldSpec(),      // Defines the mark schema
    defineBoldCommands(),  // Adds toggle command
    defineBoldKeymap(),    // Adds Mod-b shortcut
    defineBoldInputRule(), // Adds **text** input rule
  )
}

Creating a Custom Mark

Let’s create a custom highlight mark that highlights text with a yellow background.
1

Define the mark specification

import { defineMarkSpec, type Extension } from 'prosekit/core'
import type { Attrs } from '@prosekit/pm/model'

export type HighlightSpecExtension = Extension<{
  Marks: {
    highlight: Attrs
  }
}>

export function defineHighlightSpec(): HighlightSpecExtension {
  return defineMarkSpec({
    name: 'highlight',
    parseDOM: [
      {
        tag: 'mark',
        getAttrs: (node: string | HTMLElement) => {
          if (typeof node === 'string') return false
          return node.style.backgroundColor === 'yellow' ? {} : false
        },
      },
      { tag: 'span.highlight' },
    ],
    toDOM() {
      return ['mark', { style: 'background-color: yellow' }, 0]
    },
  })
}
2

Define commands

import { defineCommands, toggleMark, type Extension } from 'prosekit/core'

export type HighlightCommandsExtension = Extension<{
  Commands: {
    toggleHighlight: []
  }
}>

export function defineHighlightCommands(): HighlightCommandsExtension {
  return defineCommands({
    toggleHighlight: () => toggleMark({ type: 'highlight' }),
  })
}
3

Define keyboard shortcut

import { defineKeymap, toggleMark, type PlainExtension } from 'prosekit/core'

export function defineHighlightKeymap(): PlainExtension {
  return defineKeymap({
    'Mod-Shift-h': toggleMark({ type: 'highlight' }),
  })
}
4

Define input rule

import { defineMarkInputRule, type PlainExtension } from 'prosekit/core'

export function defineHighlightInputRule(): PlainExtension {
  return defineMarkInputRule({
    regex: /==([^=]+)==$/,
    type: 'highlight',
  })
}
5

Combine into a single extension

import { union, type Union } from 'prosekit/core'

export type HighlightExtension = Union<[
  HighlightSpecExtension,
  HighlightCommandsExtension
]>

export function defineHighlight(): HighlightExtension {
  return union(
    defineHighlightSpec(),
    defineHighlightCommands(),
    defineHighlightKeymap(),
    defineHighlightInputRule(),
  )
}
6

Use your custom extension

import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, union } from 'prosekit/core'
import { defineHighlight } from './highlight'

const extension = union(
  defineBasicExtension(),
  defineHighlight(),
)

const editor = createEditor({ extension })

// Use the command
editor.commands.toggleHighlight()

Creating a Custom Node

Let’s create a custom callout/admonition node.
1

Define the node specification

import { defineNodeSpec, type Extension } from 'prosekit/core'
import type { Attrs } from '@prosekit/pm/model'

export interface CalloutAttrs {
  type: 'info' | 'warning' | 'danger' | 'success'
}

export type CalloutSpecExtension = Extension<{
  Nodes: {
    callout: CalloutAttrs
  }
}>

export function defineCalloutSpec(): CalloutSpecExtension {
  return defineNodeSpec({
    name: 'callout',
    content: 'block+',
    group: 'block',
    attrs: {
      type: { default: 'info' },
    },
    parseDOM: [
      {
        tag: 'div.callout',
        getAttrs: (node: string | HTMLElement) => {
          if (typeof node === 'string') return false
          return {
            type: node.getAttribute('data-type') || 'info',
          }
        },
      },
    ],
    toDOM(node) {
      const type = node.attrs.type
      return [
        'div',
        {
          class: `callout callout-${type}`,
          'data-type': type,
        },
        0,
      ]
    },
  })
}
2

Define commands

import { defineCommands, setBlockType, type Extension } from 'prosekit/core'
import type { CalloutAttrs } from './callout-spec'

export type CalloutCommandsExtension = Extension<{
  Commands: {
    insertCallout: [attrs?: CalloutAttrs]
  }
}>

export function defineCalloutCommands(): CalloutCommandsExtension {
  return defineCommands({
    insertCallout: (attrs = { type: 'info' }) => {
      return setBlockType({ type: 'callout', attrs })
    },
  })
}
3

Define input rule

import { defineInputRule, type PlainExtension } from 'prosekit/core'
import { textblockTypeInputRule } from '@prosekit/pm/inputrules'

export function defineCalloutInputRule(): PlainExtension {
  return defineInputRule(() => {
    return textblockTypeInputRule(
      /^:::(info|warning|danger|success)\s$/,
      (schema) => schema.nodes.callout,
      (match) => ({ type: match[1] }),
    )
  })
}
4

Combine and use

import { union, type Union } from 'prosekit/core'

export type CalloutExtension = Union<[
  CalloutSpecExtension,
  CalloutCommandsExtension
]>

export function defineCallout(): CalloutExtension {
  return union(
    defineCalloutSpec(),
    defineCalloutCommands(),
    defineCalloutInputRule(),
  )
}

// Use it
const extension = union(
  defineBasicExtension(),
  defineCallout(),
)

const editor = createEditor({ extension })
editor.commands.insertCallout({ type: 'warning' })

Creating a Plugin Extension

Let’s create a custom plugin that tracks word count.
import { definePlugin, type PlainExtension } from 'prosekit/core'
import { Plugin, PluginKey } from '@prosekit/pm/state'

export interface WordCountState {
  words: number
  characters: number
}

const wordCountPluginKey = new PluginKey<WordCountState>('wordCount')

export function defineWordCount(): PlainExtension {
  return definePlugin(() => {
    return new Plugin<WordCountState>({
      key: wordCountPluginKey,
      state: {
        init(_, state) {
          const text = state.doc.textContent
          return {
            words: text.split(/\s+/).filter(Boolean).length,
            characters: text.length,
          }
        },
        apply(tr, value, oldState, newState) {
          if (!tr.docChanged) return value
          
          const text = newState.doc.textContent
          return {
            words: text.split(/\s+/).filter(Boolean).length,
            characters: text.length,
          }
        },
      },
    })
  })
}

// Access the word count
export function getWordCount(editor): WordCountState | undefined {
  const plugin = wordCountPluginKey.get(editor.state)
  return plugin?.spec.state?.init(undefined, editor.state)
}

Creating a Node View Extension

Create interactive node views with custom rendering:
import { defineNodeView, type PlainExtension } from 'prosekit/core'
import type { NodeViewConstructor } from '@prosekit/pm/view'

export function defineCodeBlockView(): PlainExtension {
  return defineNodeView({
    type: 'codeBlock',
    view: (() => {
      return (node, view, getPos) => {
        const dom = document.createElement('div')
        dom.className = 'code-block-wrapper'
        
        // Add line numbers
        const lineNumbers = document.createElement('div')
        lineNumbers.className = 'line-numbers'
        const lines = node.textContent.split('\n').length
        lineNumbers.textContent = Array.from(
          { length: lines },
          (_, i) => i + 1
        ).join('\n')
        
        // Add code content
        const pre = document.createElement('pre')
        const code = document.createElement('code')
        code.textContent = node.textContent
        pre.appendChild(code)
        
        dom.appendChild(lineNumbers)
        dom.appendChild(pre)
        
        return {
          dom,
          contentDOM: code,
        }
      }
    }) as NodeViewConstructor,
  })
}

Customizing Existing Extensions

You can customize existing extensions by omitting parts you don’t want:
import { union } from 'prosekit/core'
import {
  defineCodeCommands,
  defineCodeInputRule,
  defineCodeSpec,
  // Omitting defineCodeKeymap - no keyboard shortcut
} from 'prosekit/extensions/code'

function defineMyCode() {
  return union(
    defineCodeSpec(),
    defineCodeCommands(),
    defineCodeInputRule(),
    // No keyboard shortcut
  )
}

Adding Custom Attributes

Extend nodes or marks with custom attributes:
import { defineNodeSpec, type Extension } from 'prosekit/core'
import type { Attrs } from '@prosekit/pm/model'

export interface ImageAttrs {
  src: string
  alt?: string
  title?: string
  width?: number
  height?: number
  align?: 'left' | 'center' | 'right'
}

export type ImageSpecExtension = Extension<{
  Nodes: {
    image: ImageAttrs
  }
}>

export function defineImageSpec(): ImageSpecExtension {
  return defineNodeSpec({
    name: 'image',
    inline: true,
    attrs: {
      src: { default: '' },
      alt: { default: null },
      title: { default: null },
      width: { default: null },
      height: { default: null },
      align: { default: 'left' },
    },
    group: 'inline',
    draggable: true,
    parseDOM: [
      {
        tag: 'img[src]',
        getAttrs: (node: string | HTMLElement) => {
          if (typeof node === 'string') return false
          return {
            src: node.getAttribute('src'),
            alt: node.getAttribute('alt'),
            title: node.getAttribute('title'),
            width: node.getAttribute('width'),
            height: node.getAttribute('height'),
            align: node.getAttribute('data-align') || 'left',
          }
        },
      },
    ],
    toDOM(node) {
      return [
        'img',
        {
          src: node.attrs.src,
          alt: node.attrs.alt,
          title: node.attrs.title,
          width: node.attrs.width,
          height: node.attrs.height,
          'data-align': node.attrs.align,
        },
      ]
    },
  })
}

Troubleshooting

Extension not working

If your extension isn’t working:
  1. Verify it’s included in the union call
  2. Check the extension is created before the editor
  3. Verify TypeScript types are correct
  4. Check the browser console for errors
  5. Ensure required dependencies are installed

Commands not available

If your commands aren’t available:
  1. Verify the command extension is included
  2. Check TypeScript types show the command
  3. Ensure the extension is properly typed
  4. Check command returns a Command function

Nodes/marks not rendering

If nodes or marks aren’t rendering:
  1. Verify parseDOM and toDOM are correct
  2. Check the schema includes the node/mark
  3. Ensure content expression is valid
  4. Verify attributes have default values
  5. Check CSS is loaded for styling

Next Steps