Nested node views
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.