Nested node views

Editor

This guide shows how to create a parent node that contains multiple child nodes, each rendered with its own component. This pattern is useful when you need structured content with distinct visual representations inside a single block.

If you are new to node views, start with the Node views overview and the framework-specific guides for React and Vue.

The key idea

The most important part of this pattern is defining your schema correctly. The parent node must explicitly declare which child nodes are allowed, and each child node needs its own node view with a NodeViewContent component.

In this example, a multiNode parent contains two child nodes: firstNode and secondNode. Each node is rendered by its own component.

Schema first

If nested NodeViewContent does not render as expected, double-check the content expression on your parent and child node extensions before debugging your components.

Inserting the parent node

When inserting the parent node, create the full structure in one step so the document always contains the expected children:

editor
  .chain()
  .focus()
  .insertContent(
    editor.schema.node('multiNode', null, [
      editor.schema.node('firstNode', null, [editor.schema.node('paragraph')]),
      editor.schema.node('secondNode', null, [editor.schema.node('paragraph')]),
    ]),
  )
  .run()

Vue

Parent node extension

import MultiNode from './MultiNode.vue'
import { Node, mergeAttributes } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'

export default Node.create({
  name: 'multiNode',
  group: 'block',
  content: 'firstNode secondNode',
  parseHTML() {
    return [{ tag: 'multi-node' }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['multi-node', mergeAttributes(HTMLAttributes), 0]
  },
  addNodeView() {
    return VueNodeViewRenderer(MultiNode)
  },
})

Parent node view

The parent component provides the wrapper structure. Use <node-view-content /> to render the child node views inside it.

<template>
  <node-view-wrapper class="multi-node">
    <node-view-content />
  </node-view-wrapper>
</template>

<script setup>
  import { NodeViewContent, NodeViewWrapper } from '@tiptap/vue-3'
</script>

Child node extension

Child nodes keep the same group as the parent and define their own editable content.

import FirstNode from './FirstNode.vue'
import { Node, mergeAttributes } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'

export default Node.create({
  name: 'firstNode',
  group: 'block',
  content: 'block+',
  parseHTML() {
    return [{ tag: 'first-node' }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['first-node', mergeAttributes(HTMLAttributes), 0]
  },
  addNodeView() {
    return VueNodeViewRenderer(FirstNode)
  },
})

Child node view

<template>
  <node-view-wrapper class="first-node">
    <node-view-content />
  </node-view-wrapper>
</template>

<script setup>
  import { NodeViewContent, NodeViewWrapper } from '@tiptap/vue-3'
</script>

The secondNode extension and view follow the same pattern as firstNode.

React

The React implementation follows the same schema rules. The main difference is that you use ReactNodeViewRenderer, NodeViewWrapper, and NodeViewContent from @tiptap/react.

Parent node extension

import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import MultiNode from './MultiNode.jsx'

export default Node.create({
  name: 'multiNode',
  group: 'block',
  content: 'firstNode secondNode',
  parseHTML() {
    return [{ tag: 'multi-node' }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['multi-node', mergeAttributes(HTMLAttributes), 0]
  },
  addNodeView() {
    return ReactNodeViewRenderer(MultiNode)
  },
})

Parent node view

import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'

export default function MultiNode() {
  return (
    <NodeViewWrapper className="multi-node">
      <NodeViewContent />
    </NodeViewWrapper>
  )
}

Child node extension

import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import FirstNode from './FirstNode.jsx'

export default Node.create({
  name: 'firstNode',
  group: 'block',
  content: 'block+',
  parseHTML() {
    return [{ tag: 'first-node' }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['first-node', mergeAttributes(HTMLAttributes), 0]
  },
  addNodeView() {
    return ReactNodeViewRenderer(FirstNode)
  },
})

Child node view

import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'

export default function FirstNode() {
  return (
    <NodeViewWrapper className="first-node">
      <NodeViewContent />
    </NodeViewWrapper>
  )
}

The secondNode extension and view follow the same pattern as firstNode.