Skip to main content
In Tiptap (and ProseMirror), your document is made up of nodes and marks. Understanding the difference is crucial for working effectively with the editor.

Nodes vs Marks

Nodes

Nodes are structural pieces of your document. They define the content type and can contain other nodes or text.Examples: Paragraph, Heading, Image, CodeBlock, BulletList

Marks

Marks are formatting applied to text. They annotate nodes without changing the structure.Examples: Bold, Italic, Link, Code, Highlight

Key Differences

AspectNodesMarks
PurposeDefine structureDefine formatting
Can containOther nodes, text, marksNothing (they wrap text)
Examples<p>, <h1>, <img><strong>, <em>, <a>
Can overlapNoYes
Can nestYesNo
Rule of thumb: If it’s a block element or defines structure, it’s a node. If it’s formatting that can be applied to text, it’s a mark.

Creating Nodes

Nodes are created using Node.create():
import { Node, mergeAttributes } from '@tiptap/core'

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

export const Paragraph = Node.create<ParagraphOptions>({
  name: 'paragraph',
  
  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)
      },
    }
  },
})
Source: /home/daytona/workspace/source/packages/extension-paragraph/src/paragraph.ts:41

Node Configuration

name
string
required
The unique name of the node.
name: 'paragraph'
group
string
The group(s) this node belongs to. Used in content expressions.
group: 'block'
// or multiple groups
group: 'block list'
Common groups:
  • 'block' - Block-level content
  • 'inline' - Inline content
  • 'list' - List items
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:83
content
string
What content this node can contain. Uses ProseMirror’s content expression syntax.
// Can contain any number of inline elements
content: 'inline*'

// Must contain at least one block
content: 'block+'

// Specific content types
content: 'heading paragraph block*'

// No content allowed (leaf node)
content: undefined
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:42
inline
boolean
Whether this is an inline node.
inline: true
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:96
atom
boolean
If true, the node is treated as a single unit (cursor cannot enter it).
atom: true // e.g., for images, videos
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:113
selectable
boolean
default:"true"
Whether the node can be selected.
selectable: false
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:131
draggable
boolean
default:"false"
Whether the node can be dragged.
draggable: true
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:147
isolating
boolean
default:"false"
If true, boundaries of this node are treated as boundaries for editing operations.
isolating: true // e.g., for table cells
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:236
parseHTML
() => ParseRule[]
Rules for parsing HTML into this node.
parseHTML() {
  return [
    { tag: 'p' },
    { tag: 'div', attrs: { 'data-type': 'paragraph' } },
  ]
}
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:256
renderHTML
({ node, HTMLAttributes }) => DOMOutputSpec
How to render this node to HTML.
renderHTML({ node, HTMLAttributes }) {
  return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
The 0 indicates where child content should be inserted.Source: /home/daytona/workspace/source/packages/core/src/Node.ts:284
addAttributes
() => Attributes
Define custom attributes for the node.
addAttributes() {
  return {
    level: {
      default: 1,
      rendered: false,
    },
    textAlign: {
      default: 'left',
      renderHTML: attributes => ({
        style: `text-align: ${attributes.textAlign}`,
      }),
      parseHTML: element => element.style.textAlign || 'left',
    },
  }
}
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:326

Creating Marks

Marks are created using Mark.create():
import { Mark, mergeAttributes } from '@tiptap/core'

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

export const Bold = Mark.create<BoldOptions>({
  name: 'bold',
  
  parseHTML() {
    return [
      { tag: 'strong' },
      { tag: 'b' },
      { style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null },
    ]
  },
  
  renderHTML({ HTMLAttributes }) {
    return ['strong', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  
  addCommands() {
    return {
      toggleBold: () => ({ commands }) => {
        return commands.toggleMark(this.name)
      },
    }
  },
})
Source: /home/daytona/workspace/source/packages/extension-bold/src/bold.tsx:56

Mark Configuration

name
string
required
The unique name of the mark.
name: 'bold'
inclusive
boolean | ((config) => boolean)
Whether the mark should be included when typing at the edge.
inclusive: true
Source: /home/daytona/workspace/source/packages/core/src/Mark.ts:32
excludes
string
Which other marks this mark excludes. Use '_' to exclude all marks.
// Exclude specific marks
excludes: 'bold italic'

// Exclude all other marks (for code)
excludes: '_'
Source: /home/daytona/workspace/source/packages/core/src/Mark.ts:45
exitable
boolean
Whether the user can exit the mark by pressing the right arrow at the end.
exitable: true // e.g., for links
Source: /home/daytona/workspace/source/packages/core/src/Mark.ts:58
keepOnSplit
boolean
Whether the mark should persist when a node is split.
keepOnSplit: false // e.g., for links
Source: /home/daytona/workspace/source/packages/core/src/Mark.ts:27
parseHTML
() => ParseRule[]
Rules for parsing HTML into this mark.
parseHTML() {
  return [
    { tag: 'strong' },
    { tag: 'b' },
    { style: 'font-weight=bold' },
  ]
}
Source: /home/daytona/workspace/source/packages/core/src/Mark.ts:102
renderHTML
({ mark, HTMLAttributes }) => DOMOutputSpec
How to render this mark to HTML.
renderHTML({ mark, HTMLAttributes }) {
  return ['strong', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
Source: /home/daytona/workspace/source/packages/core/src/Mark.ts:113
addAttributes
() => Attributes
Define custom attributes for the mark.
addAttributes() {
  return {
    href: {
      default: null,
      parseHTML: element => element.getAttribute('href'),
      renderHTML: attributes => {
        if (!attributes.href) {
          return {}
        }
        return { href: attributes.href }
      },
    },
  }
}
Source: /home/daytona/workspace/source/packages/core/src/Mark.ts:132

Content Expressions

Content expressions define what a node can contain. They use a simple syntax:
// Zero or more inline elements
content: 'inline*'

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

// Exactly one heading followed by zero or more blocks
content: 'heading block*'

// Either a paragraph or a blockquote
content: '(paragraph | blockquote)'

// A heading followed by a paragraph, then any number of blocks
content: 'heading paragraph block*'

// No content (leaf node)
content: undefined

Common Patterns

PatternMeaning
inline*Zero or more inline elements
block+One or more block elements
text*Zero or more text nodes
paragraph+One or more paragraphs
`(blockparagraph)*`Zero or more blocks or paragraphs

Node Examples

Block Node: Heading

import { Node, mergeAttributes } from '@tiptap/core'

export const Heading = Node.create({
  name: 'heading',
  
  group: 'block',
  
  content: 'inline*',
  
  defining: true,
  
  addOptions() {
    return {
      levels: [1, 2, 3, 4, 5, 6],
      HTMLAttributes: {},
    }
  },
  
  addAttributes() {
    return {
      level: {
        default: 1,
        rendered: false,
      },
    }
  },
  
  parseHTML() {
    return this.options.levels.map(level => ({
      tag: `h${level}`,
      attrs: { level },
    }))
  },
  
  renderHTML({ node, HTMLAttributes }) {
    const level = this.options.levels.includes(node.attrs.level)
      ? node.attrs.level
      : this.options.levels[0]
    
    return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  
  addCommands() {
    return {
      setHeading: attributes => ({ commands }) => {
        return commands.setNode(this.name, attributes)
      },
      toggleHeading: attributes => ({ commands }) => {
        return commands.toggleNode(this.name, 'paragraph', attributes)
      },
    }
  },
})

Inline Node: Mention

import { Node, mergeAttributes } from '@tiptap/core'

export const Mention = Node.create({
  name: 'mention',
  
  group: 'inline',
  
  inline: true,
  
  atom: true,
  
  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: element => element.getAttribute('data-id'),
        renderHTML: attributes => {
          if (!attributes.id) {
            return {}
          }
          return { 'data-id': attributes.id }
        },
      },
      label: {
        default: null,
        parseHTML: element => element.getAttribute('data-label'),
        renderHTML: attributes => {
          if (!attributes.label) {
            return {}
          }
          return { 'data-label': attributes.label }
        },
      },
    }
  },
  
  parseHTML() {
    return [{ tag: 'span[data-mention]' }]
  },
  
  renderHTML({ node, HTMLAttributes }) {
    return [
      'span',
      mergeAttributes(
        { 'data-mention': '' },
        this.options.HTMLAttributes,
        HTMLAttributes
      ),
      `@${node.attrs.label ?? node.attrs.id}`,
    ]
  },
})

Leaf Node: Image

import { Node, mergeAttributes } from '@tiptap/core'

export const Image = Node.create({
  name: 'image',
  
  group: 'block',
  
  inline: false,
  
  atom: true,
  
  draggable: true,
  
  addAttributes() {
    return {
      src: {
        default: null,
      },
      alt: {
        default: null,
      },
      title: {
        default: null,
      },
    }
  },
  
  parseHTML() {
    return [{ tag: 'img' }]
  },
  
  renderHTML({ HTMLAttributes }) {
    return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
  },
  
  addCommands() {
    return {
      setImage: options => ({ commands }) => {
        return commands.insertContent({
          type: this.name,
          attrs: options,
        })
      },
    }
  },
})

Mark Examples

Simple Mark: Italic

import { Mark, mergeAttributes } from '@tiptap/core'

export const Italic = Mark.create({
  name: 'italic',
  
  parseHTML() {
    return [
      { tag: 'em' },
      { tag: 'i' },
      { style: 'font-style=italic' },
    ]
  },
  
  renderHTML({ HTMLAttributes }) {
    return ['em', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  
  addCommands() {
    return {
      toggleItalic: () => ({ commands }) => {
        return commands.toggleMark(this.name)
      },
    }
  },
  
  addKeyboardShortcuts() {
    return {
      'Mod-i': () => this.editor.commands.toggleItalic(),
      'Mod-I': () => this.editor.commands.toggleItalic(),
    }
  },
})
import { Mark, mergeAttributes } from '@tiptap/core'

export const Link = Mark.create({
  name: 'link',
  
  inclusive: false,
  
  addOptions() {
    return {
      openOnClick: true,
      HTMLAttributes: {
        target: '_blank',
        rel: 'noopener noreferrer nofollow',
      },
    }
  },
  
  addAttributes() {
    return {
      href: {
        default: null,
      },
      target: {
        default: this.options.HTMLAttributes.target,
      },
    }
  },
  
  parseHTML() {
    return [{ tag: 'a[href]' }]
  },
  
  renderHTML({ HTMLAttributes }) {
    return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  
  addCommands() {
    return {
      setLink: attributes => ({ commands }) => {
        return commands.setMark(this.name, attributes)
      },
      toggleLink: attributes => ({ commands }) => {
        return commands.toggleMark(this.name, attributes)
      },
      unsetLink: () => ({ commands }) => {
        return commands.unsetMark(this.name)
      },
    }
  },
})

Accessing Node/Mark Types

You can access the ProseMirror types from the schema:
// Get node type
const paragraphType = editor.schema.nodes.paragraph
const headingType = editor.schema.nodes.heading

// Get mark type
const boldType = editor.schema.marks.bold
const linkType = editor.schema.marks.link

// Use in commands
editor.commands.setNode(paragraphType)
editor.commands.toggleMark(boldType)

Best Practices

Choose the Right Type

  • Use nodes for structural content (paragraphs, headings, images)
  • Use marks for text formatting (bold, italic, links)
  • If it can overlap with other formatting, it should be a mark
  • If it defines a block or structure, it should be a node

Define Content Carefully

Think carefully about what content a node should accept:
  • Most block nodes use content: 'inline*'
  • Container nodes use content: 'block+'
  • Leaf nodes (images, etc.) have no content expression

Use Groups

Group similar nodes together:
group: 'block list'
This makes it easier to reference them in content expressions.

Parse and Render Correctly

Ensure your parseHTML and renderHTML methods handle all variations:
  • Different HTML tags (<b> vs <strong>)
  • Inline styles
  • Data attributes

Extensions

Learn about the extension system

Schema

Learn how nodes and marks create the schema

Commands

Learn how to manipulate nodes and marks

Editor

Learn about the Editor class