Rendering
The editor renders every kind of Portable Text node, text blocks, spans, decorators, annotations, block objects, and inline objects, through the same mechanism: a node registration. This page covers the model that every registration shares. Containers covers the container-specific factory and how of scopes a registration to positions inside one.
Markup ownership
Section titled “Markup ownership”Every registration owns its markup: your render gets children to render where the editable content goes, and the engine adds nothing around what you return (with one exception: annotations compose inside an engine-owned anchor span). Every kind except decorators and annotations also receives attributes to spread on the outer element, so the engine can track the node; decorators and annotations wrap inline content rather than owning an element the engine tracks, so their render receives no attributes.
import {defineTextBlock} from '@portabletext/editor'
const paragraph = defineTextBlock({ type: 'block', render: ({attributes, children, node}) => ( <p {...attributes} data-style={node.style}> {children} </p> ),})Spread attributes onto your outer element when your registration receives one, and render children where the editable content goes. That’s the whole interface: no hidden classes, no wrapper the engine adds behind your back.
Register a node
Section titled “Register a node”The schema declares what the editor allows; a registration only renders what the schema already permits. defineDecorator({type: 'strong'}) and defineAnnotation({type: 'link'}) below render because the schema declares a strong decorator and a link annotation:
import {defineSchema} from '@portabletext/editor'
const schemaDefinition = defineSchema({ decorators: [{name: 'strong'}], annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}],})Each node kind has a factory: defineTextBlock, defineBlockObject, defineInlineObject, defineSpan, defineDecorator, defineAnnotation, and defineContainer (see Containers for that one). Every factory returns a plain registration object; mount them together through one NodePlugin:
import { defineAnnotation, defineDecorator, defineTextBlock, EditorProvider, PortableTextEditable,} from '@portabletext/editor'import {NodePlugin} from '@portabletext/editor/plugins'
// Module scope: a new array identity re-registers the nodes on every render.const nodes = [ defineTextBlock({ type: 'block', render: ({attributes, children}) => <p {...attributes}>{children}</p>, }), defineDecorator({ type: 'strong', render: ({children}) => <strong>{children}</strong>, }), defineAnnotation({ type: 'link', render: ({annotation, children}) => typeof annotation.href === 'string' ? ( <a href={annotation.href}>{children}</a> ) : ( children ), }),]
function App() { return ( <EditorProvider initialConfig={{schemaDefinition}}> <NodePlugin nodes={nodes} /> <PortableTextEditable /> </EditorProvider> )}annotation is the markDef object from the block’s markDefs: {_key, _type, ...fields}. Its fields type as unknown because they depend on the schema, so narrow with typeof before use; the fallback renders children untouched, matching the engine default. Each render callback is a plain function call, not a component: when a render needs hooks, return a component instead, render: (props) => <MyAnnotation {...props} />.
Keep nodes at module scope, as above: a fresh array identity on every render makes NodePlugin unregister and re-register every keystroke.
Subtree ownership
Section titled “Subtree ownership”A block-level registration (a text block, block object, or container) owns everything rendered inside it, its rendering subtree. Unregistered node types inside it fall back to the engine defaults: a registered text block holding an unregistered inline object still renders that inline object through the engine’s default wrapper, not through markup you control. Register a node type when you need to own its markup, wherever it appears:
const nodes = [ defineTextBlock({ type: 'block', render: ({attributes, children}) => <p {...attributes}>{children}</p>, }), defineInlineObject({ type: 'stock-ticker', render: (props) => typeof props.node.symbol === 'string' ? ( <span {...props.attributes}> {props.children} <span draggable={!props.readOnly} style={{display: 'inline-block'}}> {props.node.symbol} </span> </span> ) : ( props.renderDefault(props) ), }),]Dispatch precedence
Section titled “Dispatch precedence”At a given position, a positional registration (one scoped through a container or text block’s own of, see Containers) beats a global registration, which beats the engine default. Within a level, an exact type match beats a '*' catch-all:
const nodes = [ // `strong` hits the exact match... defineDecorator({ type: 'strong', render: ({children}) => <strong>{children}</strong>, }), // ...every other decorator hits the catch-all. defineDecorator({ type: '*', render: ({decorator, children}) => ( <span data-decorator={decorator}>{children}</span> ), }),]Narrow rendering with of covers how far a positional entry’s scope reaches and what it falls back to when it omits render.
renderDefault
Section titled “renderDefault”Every render receives renderDefault, a function that renders the engine’s minimal wrapper for that position. Call it to fall back, or to wrap the default instead of replacing it:
render: (props) => props.renderDefault(props)Its most common use is the fallback branch of a field-presence check: a document can carry a partially filled value, and the engine default beats rendering nothing:
defineBlockObject({ type: 'image', render: (props) => typeof props.node.src === 'string' ? ( <figure {...props.attributes}> <div contentEditable={false} draggable={!props.readOnly}> <img src={props.node.src} alt="" /> </div> {props.children} </figure> ) : ( props.renderDefault(props) ),})The contentEditable={false}/draggable wrapper is the block-object render contract; the custom blocks guide covers it in full.
renderDefault is the engine default at any position: it never chains back to a global registration’s render, even from inside a positional one. PTE has one user layer plus positional overrides, and the engine default is the canonical fallback everywhere. For block-level, span, and inline-object registrations the default is a minimal wrapper; for decorators the default is identity, the engine applies no decorator markup of its own. For annotations the default is identity too, but every known annotation renders inside an engine-owned anchor span the engine adds regardless of which render fires.
Plugins for lists and drag-and-drop
Section titled “Plugins for lists and drag-and-drop”Two rendering concerns ship as plugins instead of registration props:
- List numbering: your text-block render reads
node.listItemandnode.levelfor list markup, and@portabletext/plugin-list-indexcomputes the 1-based list index at any path, correct across nesting, remote edits, and non-list blocks interrupting a list. - Drop indicators: pointer-driven UI rendered by you.
@portabletext/plugin-dndtracks the drop position from the editor’s publicdrag.*events.
Both plugins follow the same pattern: a provider inside EditorProvider, and a hook read from a component your render returns, not inline in the render callback, since hooks can’t run there:
import type {TextBlockRenderProps} from '@portabletext/editor'import {useListIndex} from '@portabletext/plugin-list-index'
const nodes = [ defineTextBlock({ type: 'block', render: (props) => <TextBlock {...props} />, }),]
function TextBlock(props: TextBlockRenderProps) { const listIndex = useListIndex(props.path)
return ( <p {...props.attributes}> {listIndex !== undefined ? ( <span contentEditable={false}>{listIndex}. </span> ) : null} {props.children} </p> )}useListIndex reads from ListIndexProvider, mounted inside EditorProvider. Each plugin’s README carries the full recipe, including a reference DropIndicator implementation.