Skip to main content

Custom Nodes

Nodes are the building blocks for block-level content in Tiptap. They represent things like paragraphs, headings, blockquotes, code blocks, and more. Each node can have attributes, content rules, and custom rendering.

Understanding Nodes

The Node class extends the Extendable class and provides specific functionality for block-level content.
import { Node } from '@tiptap/core'

export const MyNode = Node.create({
  name: 'myNode',
  
  // Define content structure
  content: 'inline*',
  group: 'block',
  
  // Parse from HTML
  parseHTML() {
    return [{ tag: 'div' }]
  },
  
  // Render to HTML
  renderHTML({ HTMLAttributes }) {
    return ['div', HTMLAttributes, 0]
  },
  
  // Add attributes
  addAttributes() {
    return {}
  },
  
  // Add commands
  addCommands() {
    return {}
  },
})
Source: packages/core/src/Node.ts:340

Creating a Simple Node

Let’s start with a simple paragraph node.
import { Node, mergeAttributes } from '@tiptap/core'

export interface ParagraphOptions {
  HTMLAttributes: Record<string, any>
}

export const Paragraph = Node.create<ParagraphOptions>({
  name: 'paragraph',
  
  priority: 1000,
  
  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },
  
  group: 'block',
  
  content: 'inline*',
  
  parseHTML() {
    return [{ tag: 'p' }]
  },
  
  renderHTML({ HTMLAttributes }) {
    return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  
  addCommands() {
    return {
      setParagraph: () => ({ commands }) => {
        return commands.setNode(this.name)
      },
    }
  },
  
  addKeyboardShortcuts() {
    return {
      'Mod-Alt-0': () => this.editor.commands.setParagraph(),
    }
  },
})
Source: packages/extension-paragraph/src/paragraph.ts:41

Node with Attributes

Here’s a heading node that demonstrates attributes.
import { Node, mergeAttributes, textblockTypeInputRule } from '@tiptap/core'

export type Level = 1 | 2 | 3 | 4 | 5 | 6

export interface HeadingOptions {
  levels: Level[]
  HTMLAttributes: Record<string, any>
}

export const Heading = Node.create<HeadingOptions>({
  name: 'heading',
  
  addOptions() {
    return {
      levels: [1, 2, 3, 4, 5, 6],
      HTMLAttributes: {},
    }
  },
  
  content: 'inline*',
  group: 'block',
  defining: true,
  
  addAttributes() {
    return {
      level: {
        default: 1,
        rendered: false,
      },
    }
  },
  
  parseHTML() {
    return this.options.levels.map((level: Level) => ({
      tag: `h${level}`,
      attrs: { level },
    }))
  },
  
  renderHTML({ node, HTMLAttributes }) {
    const hasLevel = this.options.levels.includes(node.attrs.level)
    const level = hasLevel ? node.attrs.level : this.options.levels[0]
    
    return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  
  addCommands() {
    return {
      setHeading: (attributes) => ({ commands }) => {
        if (!this.options.levels.includes(attributes.level)) {
          return false
        }
        return commands.setNode(this.name, attributes)
      },
      toggleHeading: (attributes) => ({ commands }) => {
        if (!this.options.levels.includes(attributes.level)) {
          return false
        }
        return commands.toggleNode(this.name, 'paragraph', attributes)
      },
    }
  },
  
  addKeyboardShortcuts() {
    return this.options.levels.reduce(
      (items, level) => ({
        ...items,
        [`Mod-Alt-${level}`]: () => this.editor.commands.toggleHeading({ level }),
      }),
      {},
    )
  },
  
  addInputRules() {
    return this.options.levels.map(level => {
      return textblockTypeInputRule({
        find: new RegExp(`^(#{${Math.min(...this.options.levels)},${level}})\\s$`),
        type: this.type,
        getAttributes: { level },
      })
    })
  },
})
Source: packages/extension-heading/src/heading.ts:47

Wrapping Nodes

Some nodes wrap other content, like blockquotes.
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'

export interface BlockquoteOptions {
  HTMLAttributes: Record<string, any>
}

export const Blockquote = Node.create<BlockquoteOptions>({
  name: 'blockquote',
  
  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },
  
  content: 'block+',
  group: 'block',
  defining: true,
  
  parseHTML() {
    return [{ tag: 'blockquote' }]
  },
  
  renderHTML({ HTMLAttributes }) {
    return ['blockquote', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  
  addCommands() {
    return {
      setBlockquote: () => ({ commands }) => {
        return commands.wrapIn(this.name)
      },
      toggleBlockquote: () => ({ commands }) => {
        return commands.toggleWrap(this.name)
      },
      unsetBlockquote: () => ({ commands }) => {
        return commands.lift(this.name)
      },
    }
  },
  
  addKeyboardShortcuts() {
    return {
      'Mod-Shift-b': () => this.editor.commands.toggleBlockquote(),
    }
  },
  
  addInputRules() {
    return [
      wrappingInputRule({
        find: /^\s*>\s$/,
        type: this.type,
      }),
    ]
  },
})
Source: packages/extension-blockquote/src/blockquote.tsx:41

Content Expressions

Content expressions define what content a node can contain.
// No content (leaf node)
content: ''

// Inline content (like paragraph)
content: 'inline*'

// One or more block nodes
content: 'block+'

// Specific node types
content: 'heading paragraph+'

// Alternation
content: '(paragraph | heading)+'

// Optional content
content: 'heading? paragraph+'
Source: packages/core/src/Node.ts:32

Node Groups

Groups allow you to reference multiple node types in content expressions.
import { Node } from '@tiptap/core'

// Belongs to 'block' group
export const Paragraph = Node.create({
  name: 'paragraph',
  group: 'block',
  content: 'inline*',
})

// Belongs to 'inline' group
export const HardBreak = Node.create({
  name: 'hardBreak',
  group: 'inline',
  inline: true,
})

// Custom group
export const CustomBlock = Node.create({
  name: 'customBlock',
  group: 'customGroup',
  content: 'block+',
})
Source: packages/core/src/Node.ts:72

Node Attributes

Attributes store data on nodes and control rendering.
import { Node } from '@tiptap/core'

export const MyNode = Node.create({
  name: 'myNode',
  
  addAttributes() {
    return {
      // Simple attribute with default
      id: {
        default: null,
      },
      
      // Attribute with parsing and rendering
      color: {
        default: 'black',
        parseHTML: element => element.getAttribute('data-color'),
        renderHTML: attributes => {
          if (!attributes.color) {
            return {}
          }
          return {
            'data-color': attributes.color,
            style: `color: ${attributes.color}`,
          }
        },
      },
      
      // Don't render in HTML
      internalState: {
        default: null,
        rendered: false,
      },
      
      // Keep attribute on split
      keepOnSplit: {
        default: false,
        keepOnSplit: true,
      },
    }
  },
})
Source: packages/core/src/Node.ts:323

Node Properties

Nodes have several important properties that control their behavior.
import { Node } from '@tiptap/core'

export const MyNode = Node.create({
  name: 'myNode',
  
  // Top-level node (like document)
  topNode: false,
  
  // Inline node (flows with text)
  inline: false,
  
  // Treat as single unit (like image)
  atom: false,
  
  // Can be selected as node
  selectable: true,
  
  // Can be dragged
  draggable: false,
  
  // Contains code
  code: false,
  
  // Whitespace handling
  whitespace: 'normal', // or 'pre'
  
  // Defines context boundaries
  defining: false,
  
  // Creates editing boundaries
  isolating: false,
})
Source: packages/core/src/Node.ts:25

Input Rules

Input rules allow you to trigger node creation from text patterns.
import { Node, textblockTypeInputRule, wrappingInputRule } from '@tiptap/core'

// Create a text block node
export const Heading = Node.create({
  name: 'heading',
  
  addInputRules() {
    return [
      textblockTypeInputRule({
        find: /^(#{1,6})\s$/,
        type: this.type,
        getAttributes: match => ({ level: match[1].length }),
      }),
    ]
  },
})

// Wrap content in a node
export const Blockquote = Node.create({
  name: 'blockquote',
  
  addInputRules() {
    return [
      wrappingInputRule({
        find: /^>\s$/,
        type: this.type,
      }),
    ]
  },
})

TypeScript Commands

Add type-safe commands to your node.
import { Node } from '@tiptap/core'

export interface HeadingOptions {
  levels: Level[]
}

export type Level = 1 | 2 | 3 | 4 | 5 | 6

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    heading: {
      setHeading: (attributes: { level: Level }) => ReturnType
      toggleHeading: (attributes: { level: Level }) => ReturnType
    }
  }
}

export const Heading = Node.create<HeadingOptions>({
  name: 'heading',
  
  addCommands() {
    return {
      setHeading: (attributes) => ({ commands }) => {
        return commands.setNode(this.name, attributes)
      },
      toggleHeading: (attributes) => ({ commands }) => {
        return commands.toggleNode(this.name, 'paragraph', attributes)
      },
    }
  },
})
Source: packages/extension-heading/src/heading.ts:24

Extending Nodes

You can extend existing nodes to customize behavior.
import { Paragraph } from '@tiptap/extension-paragraph'

export const CustomParagraph = Paragraph.extend({
  addAttributes() {
    return {
      ...this.parent?.(),
      class: {
        default: 'custom-paragraph',
        parseHTML: element => element.getAttribute('class'),
        renderHTML: attributes => ({
          class: attributes.class,
        }),
      },
    }
  },
})
Source: packages/core/src/Node.ts:357
Nodes must have unique names within the schema. Make sure your custom node name doesn’t conflict with built-in nodes.

Next Steps

Custom Marks

Create custom inline formatting

Custom Extensions

Add editor-wide functionality

Build docs developers (and LLMs) love