Integration Markdown in Custom Extensions

Beta

This guide shows you how to add Markdown support to your Tiptap extensions.

Tip: For standard patterns like Pandoc blocks (:::name) or shortcodes ([name]), check out the Utility Functions to generate Markdown specs with minimal code.

Basic Extension Integration

To add Markdown support to an extension, define a Markdown configuration in your extension configuration:

import { Node } from '@tiptap/core'

const MyNode = Node.create({
  name: 'myNode',

  // ... other configuration (parseHTML, renderHTML, etc.)

  parseMarkdown: (token, helpers) => {
    /* ... */
  },

  renderMarkdown: (node, helpers) => {
    /* ... */
  },
})

The Markdown Configuration Explained

The extension spec allows for the following options:

  • markdownTokenName - The token name to handle. Required if the token name differs from the extension name.
  • parseMarkdown - The function that converts tokens into Tiptap JSON.
  • renderMarkdown - The function that converts Tiptap JSON into Markdown strings.
  • markdownTokenizer - A custom tokenizer to recognize new Markdown syntax.
  • markdownOptions - A set of options to configure the tokenizer.
    • indentsContent - Controls if this node increases the indent level for nested content (e.g., lists).

Using markdownTokenName

markdownTokenName: 'strong' // for a Bold mark extension where the Markdown token is called "strong"

Node Extensions

Creating Markdown support for nodes is straightforward. Here are some common patterns.

Depending on the type of content you expect, you may need to use different helper functions in your parseMarkdown and renderMarkdown methods.

  • If your extension is a node with block-level content, use helpers.parseChildren and helpers.renderChildren.
  • If your extension is a node with block-level content and blank lines must roundtrip as empty paragraphs, use helpers.parseBlockChildren so implicit empty paragraphs are preserved.
  • If your extension is a node with inline-level content, use helpers.parseInline and helpers.renderChildren.
import { Node } from '@tiptap/core'

const Heading = Node.create({
  name: 'heading',

  group: 'block',
  content: 'inline*',

  parseHTML() {
    return [{ tag: 'p' }]
  },

  renderHTML() {
    return ['p', 0]
  },

  parseMarkdown: (token, helpers) => {
    const level = token.depth || 1

    const content = helpers.parseInline(token.tokens || []) // we parse inline here because headings contain inline content
    return {
      type: 'heading',
      attrs: { level },
      content,
    }
  },

  renderMarkdown: (node, helpers) => {
    const level = node.attrs?.level || 1
    const prefix = '#'.repeat(level)

    const content = helpers.renderChildren(node.content || [])
    return `${prefix} ${content}`
  },
})

Mark Extensions

Marks work differently because they wrap inline content. To add Markdown support to your mark extensions, use the applyMark and renderChildren helper functions.

import { Mark } from '@tiptap/core'

const Bold = Mark.create({
  name: 'bold',

  parseHTML() {
    return [{ tag: 'strong' }, { tag: 'b' }]
  },

  renderHTML() {
    return ['strong', 0]
  },

  markdownTokenName: 'strong',

  parseMarkdown: (token, helpers) => {
    // Parse the content and apply the bold mark
    const content = helpers.parseInline(token.tokens || [])
    return helpers.applyMark('bold', content)
  },

  renderMarkdown: (node, helpers) => {
    const content = helpers.renderChildren(node.content || [])
    return `**${content}**`
  },
})

If you want to apply attributes to your marks, use the applyMark helper with an attributes object.

const content = helpers.applyMark('link', helpers.parseInline(token.tokens || []), {
  href: token.href || '',
  title: token.title || null,
})

Testing Your Extension

Unit Test Parse Handler

import { describe, it, expect } from 'vitest'

describe('Heading Markdown', () => {
  it('should parse heading tokens', () => {
    const token = {
      type: 'heading',
      depth: 2,
      tokens: [{ type: 'text', text: 'Hello' }],
    }

    const helpers = {
      parseInline: tokens => [{ type: 'text', text: 'Hello' }],
    }

    const result = Heading.configuration.parseMarkdown(token, helpers)

    expect(result).toEqual({
      type: 'heading',
      attrs: { level: 2 },
      content: [{ type: 'text', text: 'Hello' }],
    })
  })
})

Unit Test Render Handler

import { describe, it, expect } from 'vitest'

describe('Heading Markdown', () => {
  it('should render heading nodes', () => {
    const node = {
      type: 'heading',
      attrs: { level: 2 },
      content: [{ type: 'text', text: 'Hello' }],
    }

    const helpers = {
      renderChildren: () => 'Hello',
    }

    const result = Heading.configuration.renderMarkdown(node, helpers, {})

    expect(result).toBe('## Hello\n\n')
  })
})

Integration Test

import { describe, it, expect } from 'vitest'

import { Editor } from '@tiptap/core'
import { Markdown } from '@tiptap/markdown'
import MyExtension from './MyExtension'

describe('MyExtension integration', () => {
  it('should parse and serialize correctly', () => {
    const editor = new Editor({
      extensions: [Document, Paragraph, Text, MyExtension, Markdown],
    })

    const markdown = '# Hello World'
    editor.commands.setContent(markdown, { contentType: 'markdown' })

    const json = editor.getJSON()
    expect(json.content[0].type).toBe('heading')

    const result = editor.getMarkdown()
    expect(result).toBe('# Hello World\n\n')

    editor.destroy()
  })
})

Common Patterns

Preserve Empty Paragraphs In Block Content

If your node contains other block nodes and you want consecutive empty paragraphs to survive markdown roundtrips, prefer parseBlockChildren() over parseChildren().

parseMarkdown: (token, helpers) => {
  return helpers.createNode('myContainer', undefined, helpers.parseBlockChildren(token.tokens || []))
}

This preserves the first empty paragraph in a run as normal blank markdown spacing, and later empty paragraphs in the same run as   markers.

Use Previous Sibling Context During Rendering

renderMarkdown() receives a context object with previousNode, which lets you make sibling-aware rendering decisions without hard-coding parent node names.

renderMarkdown: (node, helpers, context) => {
  const previousNode = context.previousNode
  const previousWasEmptyParagraph =
    previousNode?.type === 'paragraph' && (!previousNode.content || previousNode.content.length === 0)

  if (node.type === 'paragraph' && (!node.content || node.content.length === 0)) {
    return previousWasEmptyParagraph ? ' ' : ''
  }

  return helpers.renderChildren(node.content || [])
}

This pattern works equally well at the document level and inside nested containers like blockquotes or list items.

Handle Optional Token Properties

parse: (token, helpers) => {
  return {
    type: 'myNode',
    attrs: {
      level: token.depth || 1, // Default if missing
      id: token.id ?? null, // Nullish coalescing
      text: token.text?.trim() || '', // Optional chaining
    },
    content: helpers.parseInline(token.tokens || []),
  }
}

Conditional Parsing

parse: (token, helpers) => {
  // Only handle specific types
  if (token.ordered) {
    return null // Let another handler process it
  }

  // Or use different logic based on token properties
  if (token.depth > 6) {
    // Treat as paragraph instead
    return {
      type: 'paragraph',
      content: helpers.parseInline(token.tokens || []),
    }
  }

  return {
    type: 'heading',
    attrs: { level: token.depth },
    content: helpers.parseInline(token.tokens || []),
  }
}

Context-Aware Rendering

render: (node, helpers, context) => {
  const content = helpers.renderChildren(node.content || [])

  // Adjust rendering based on context
  if (context.level > 0) {
    // Nested - add extra indentation
    return helpers.indent(`- ${content}`) + '\n'
  }

  // Top-level
  return `- ${content}\n`
}