# TipTap Editor in React: Custom Extensions Without Losing Your Mind
By Govind Bajaj · 2025

Tags: tiptap, react, rich-text, editor, extensions
I needed a mention system (@user) and embed blocks in TipTap. The docs cover bold and italic. Here's how to build real custom extensions that work.
The Sans Herbals admin dashboard uses TipTap for product descriptions.

Basic formatting — bold, italic, links — worked out of the box. Then the requirement came: we need @mentions for tagging team members in internal notes, and embed blocks for inserting product cards directly into descriptions.

The TipTap documentation covers bold and italic beautifully. Custom extensions? Not so much. It took three days of reading source code to figure out the pattern. Here it is distilled.

## How TipTap Extensions Actually Work

TipTap is built on ProseMirror. Every extension defines three things: schema (what the node looks like in the document), commands (how users create or modify it), and render logic (how it displays in the editor).

Understanding this three-part structure makes custom extensions predictable instead of magical.

## Extension 1: Mention (@user)

The Mention extension creates an inline node that references a user:

```tsx
// extensions/Mention.ts
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'

// The React component that renders the mention
function MentionComponent(props: any) {
  return (
    <NodeViewWrapper className="inline-flex">
      <span
        className="bg-blue-100 text-blue-800 px-1.5 py-0.5 rounded text-sm"
        contentEditable={false}
      >
        @{props.node.attrs.label}
      </span>
    </NodeViewWrapper>
  )
}

export const CustomMention = Node.create({
  name: 'mention',
  group: 'inline',
  inline: true,
  selectable: false,
  atom: true,

  // Schema: what the node stores
  addAttributes() {
    return {
      id: { default: null },
      label: { default: null },
    }
  },

  parseHTML() {
    return [{ tag: 'span[data-mention]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['span', mergeAttributes(HTMLAttributes, { 'data-mention': '' }), 0]
  },

  // Render logic: use React component
  addNodeView() {
    return ReactNodeViewRenderer(MentionComponent)
  },

  // Commands: how to insert a mention
  addCommands() {
    return {
      insertMention: (options: { id: string; label: string }) =>
        ({ chain }) => {
          return chain()
            .insertContent({
              type: this.name,
              attrs: options,
            })
            .run()
        },
    }
  },
})
```

Usage in your editor component:

```tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { CustomMention } from './extensions/Mention'

export function RichEditor() {
  const editor = useEditor({
    extensions: [StarterKit, CustomMention],
    content: '<p>Start typing...</p>',
  })

  const insertMention = (user: { id: string; name: string }) => {
    editor?.chain().focus().insertMention({ id: user.id, label: user.name }).run()
  }

  return <EditorContent editor={editor} />
}
```

## Extension 2: Product Embed Block

```tsx
// extensions/ProductEmbed.ts
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'

function ProductEmbedComponent(props: any) {
  const { productId } = props.node.attrs

  return (
    <NodeViewWrapper contentEditable={false}>
      <div className="border rounded-lg p-4 my-4 bg-gray-50">
        <ProductCard productId={productId} />
      </div>
    </NodeViewWrapper>
  )
}

export const ProductEmbed = Node.create({
  name: 'productEmbed',
  group: 'block',
  atom: true,

  addAttributes() {
    return {
      productId: { default: null },
    }
  },

  parseHTML() {
    return [{ tag: 'div[data-product-embed]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { 'data-product-embed': '' })]
  },

  addNodeView() {
    return ReactNodeViewRenderer(ProductEmbedComponent)
  },

  addCommands() {
    return {
      insertProduct: (productId: string) =>
        ({ chain }) => {
          return chain()
            .insertContent({
              type: this.name,
              attrs: { productId },
            })
            .run()
        },
    }
  },
})
```

## Key Concepts

contentEditable={false} on NodeViewWrapper prevents the user from editing inside the node. This is essential for embed blocks and mentions — they should behave as atomic units.

atom: true means the node is treated as a single unit. Backspace deletes the whole thing, not character by character.

group: 'inline' vs group: 'block' controls where the node can appear. Mentions are inline (they flow with text). Product embeds are block-level (they sit on their own line).

## Storing and Rendering HTML

TipTap stores content as JSON. Convert to HTML for storage:

```ts
const html = editor.getHTML()     // Store this in your database
const json = editor.getJSON()     // Or store JSON for more flexibility
```

Render stored HTML safely:

```tsx
<div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
```

Always sanitize HTML before rendering. TipTap's output is safe by default, but defense in depth matters.

## Takeaways

- TipTap extensions have three parts: schema, commands, and render logic
- ReactNodeViewRenderer lets you use React components for custom nodes
- Set contentEditable={false} for nodes users should not edit inside
- atom: true treats the node as a single unit for selection and deletion
- Store editor content as HTML or JSON — both work, JSON is more flexible
- Always sanitize rendered HTML, even if TipTap's output is safe by default
- The Mention pattern applies to any inline embed: hashtags, dates, links
- The ProductEmbed pattern applies to any block embed: videos, maps, galleries