Skip to content
This page is available as Markdown at /editor/concepts/rendering.md. For the full documentation index, see /llms.txt, or the complete corpus at /llms-full.txt.

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.

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.

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.

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)
),
}),
]

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.

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.

Two rendering concerns ship as plugins instead of registration props:

  • List numbering: your text-block render reads node.listItem and node.level for list markup, and @portabletext/plugin-list-index computes 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-dnd tracks the drop position from the editor’s public drag.* 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.