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

Custom blocks and inline objects

The Portable Text Editor handles text blocks (paragraphs, headings, lists) by default. To add structured content like images, code blocks, or calls to action, you define custom block types in your schema and tell the editor how to render and insert them. If your editor still renders block objects and inline objects through the deprecated renderBlock/renderChild props, see the migration guide to move to node registrations.

You should be familiar with the getting started guide and custom rendering first.

There are two kinds of custom content in Portable Text:

  • Block objects sit alongside text blocks in the document array. An image, a code block, or a call-to-action are block objects.
  • Inline objects sit inside text blocks, within the text flow. A stock ticker, a product reference, or a custom emoji are inline objects.

Both follow the same three-step pattern:

  1. Define the type in your schema (blockObjects or inlineObjects)
  2. Render it as a node registration (defineBlockObject or defineInlineObject), mounted through NodePlugin
  3. Insert it via the toolbar (useBlockObjectButton or useInlineObjectButton hook)

Add your block type to the blockObjects array in defineSchema. Each block object has a name and optional fields:

import {defineSchema} from '@portabletext/editor'
const schemaDefinition = defineSchema({
// ... styles, decorators, annotations, lists
blockObjects: [{name: 'image'}, {name: 'code'}],
inlineObjects: [],
})

The schema tells the editor which block types are valid. The field data (image URL, code language, etc.) is stored on the block object itself.

Register each block object type with defineBlockObject. The registration’s render owns the block’s wrapper element entirely:

  • Spread attributes onto the outer element.
  • Always render children, the engine’s caret spacer that lets the browser place the cursor next to the block.
  • Wrap the visible content in a contentEditable={false} element with draggable={!readOnly}. Block objects are void, and this makes them drag-movable while the outer element stays the caret anchor.
  • Check for a field before using it: the registration already guarantees node._type, but individual fields can be absent on a partially-filled value. Fall back to props.renderDefault(props), the engine’s placeholder, when a field the render needs is missing.
import {defineBlockObject} from '@portabletext/editor'
const imageBlock = defineBlockObject({
type: 'image',
render: (props) =>
typeof props.node.src === 'string' ? (
<div {...props.attributes}>
{props.children}
<div
contentEditable={false}
draggable={!props.readOnly}
style={{
border: '1px solid #ccc',
padding: '0.5em',
margin: '0.5em 0',
}}
>
<img
src={props.node.src}
alt={typeof props.node.alt === 'string' ? props.node.alt : ''}
style={{maxWidth: '100%'}}
/>
{typeof props.node.caption === 'string' ? (
<p style={{fontSize: '0.875em', color: '#666'}}>
{props.node.caption}
</p>
) : null}
</div>
</div>
) : (
props.renderDefault(props)
),
})
const codeBlock = defineBlockObject({
type: 'code',
render: (props) =>
typeof props.node.text === 'string' ? (
<div {...props.attributes}>
{props.children}
<pre
contentEditable={false}
draggable={!props.readOnly}
style={{
background: '#f5f5f5',
padding: '1em',
borderRadius: '4px',
overflow: 'auto',
}}
>
<code>{props.node.text}</code>
</pre>
</div>
) : (
props.renderDefault(props)
),
})

Mount registrations through NodePlugin, alongside a defineTextBlock registration for your text blocks (a bare wrapper is enough if you don’t customize them further):

import {defineTextBlock} from '@portabletext/editor'
import {NodePlugin} from '@portabletext/editor/plugins'
const textBlock = defineTextBlock({
type: 'block',
render: ({attributes, children}) => <div {...attributes}>{children}</div>,
})
const nodes = [textBlock, imageBlock, codeBlock]
;<NodePlugin nodes={nodes} />

Use the useBlockObjectButton hook from @portabletext/toolbar to create an insert button. The hook follows the same pattern as useDecoratorButton and useStyleSelector:

import {
useBlockObjectButton,
useToolbarSchema,
type ExtendBlockObjectSchemaType,
type ToolbarBlockObjectSchemaType,
} from '@portabletext/toolbar'
// Extend the schema to add icons and titles for the toolbar
const extendBlockObject: ExtendBlockObjectSchemaType = (blockObject) => {
if (blockObject.name === 'image') {
return {...blockObject, title: 'Image', icon: () => <span>🖼</span>}
}
if (blockObject.name === 'code') {
return {...blockObject, title: 'Code', icon: () => <span>{'</>'}</span>}
}
return blockObject
}
function Toolbar() {
const toolbarSchema = useToolbarSchema({extendBlockObject})
return (
<div>
{/* ... decorator and style buttons */}
{toolbarSchema.blockObjects?.map((blockObject) => (
<BlockObjectButton key={blockObject.name} schemaType={blockObject} />
))}
</div>
)
}
function BlockObjectButton(props: {schemaType: ToolbarBlockObjectSchemaType}) {
const blockObjectButton = useBlockObjectButton(props)
return (
<button
type="button"
onClick={() => blockObjectButton.send({type: 'open dialog'})}
disabled={!blockObjectButton.snapshot.matches('enabled')}
>
{props.schemaType.icon && <props.schemaType.icon />}
{props.schemaType.title}
</button>
)
}

Clicking the button moves the hook into its showing dialog state. Render the dialog for that state, and send insert from the dialog’s submit handler:

import {useState} from 'react'
function ImageButton(props: {schemaType: ToolbarBlockObjectSchemaType}) {
const blockObjectButton = useBlockObjectButton(props)
const [imageUrl, setImageUrl] = useState('')
return (
<>
<button
type="button"
onClick={() => blockObjectButton.send({type: 'open dialog'})}
disabled={!blockObjectButton.snapshot.matches('enabled')}
>
{props.schemaType.icon && <props.schemaType.icon />}
{props.schemaType.title}
</button>
{blockObjectButton.snapshot.matches({enabled: 'showing dialog'}) ? (
<dialog open>
<input
value={imageUrl}
onChange={(event) => setImageUrl(event.target.value)}
placeholder="Image URL"
/>
<button
type="button"
onClick={() => {
blockObjectButton.send({
type: 'insert',
value: {src: imageUrl},
placement: undefined,
})
setImageUrl('')
}}
>
Insert
</button>
<button
type="button"
onClick={() => blockObjectButton.send({type: 'close dialog'})}
>
Cancel
</button>
</dialog>
) : null}
</>
)
}

For a block type that needs no user input at all, skip the dialog states and insert straight from useEditor:

import {useEditor} from '@portabletext/editor'
function InsertButton(props: {schemaType: ToolbarBlockObjectSchemaType}) {
const editor = useEditor()
return (
<button
type="button"
onClick={() =>
editor.send({
type: 'insert.block object',
blockObject: {name: props.schemaType.name, value: {}},
placement: 'auto',
})
}
>
{props.schemaType.icon && <props.schemaType.icon />}
{props.schemaType.title}
</button>
)
}

Inline objects work the same way as block objects, but they appear inside text blocks rather than alongside them.

const schemaDefinition = defineSchema({
// ... styles, decorators, annotations, lists, blockObjects
inlineObjects: [{name: 'stock-ticker'}],
})

defineInlineObject follows the same shape as defineBlockObject, with two differences. Inline objects need no contentEditable={false} on the visible content: the engine’s outer attributes already carry non-editability, and your content inherits it. They do need the visible content wrapped in a draggable={!readOnly}, display: inline-block element, which makes the object drag-movable and keeps its text unselectable (a draggable element starts a drag instead of a text selection).

import {defineInlineObject} from '@portabletext/editor'
const stockTicker = defineInlineObject({
type: 'stock-ticker',
render: (props) =>
typeof props.node.symbol === 'string' ? (
<span {...props.attributes}>
{props.children}
<span
draggable={!props.readOnly}
style={{
display: 'inline-block',
border: '1px solid #e0e0e0',
borderRadius: '4px',
padding: '0 0.5em',
fontSize: '0.875em',
background: '#f9f9f9',
}}
>
📈 {props.node.symbol}
{typeof props.node.exchange === 'string' ? (
<span style={{color: '#999', fontSize: '0.75em'}}>
{props.node.exchange}
</span>
) : null}
</span>
</span>
) : (
props.renderDefault(props)
),
})

Add it to the same NodePlugin:

const nodes = [textBlock, imageBlock, codeBlock, stockTicker]
;<NodePlugin nodes={nodes} />

Use useInlineObjectButton, which works identically to useBlockObjectButton:

import {
useInlineObjectButton,
type ExtendInlineObjectSchemaType,
type ToolbarInlineObjectSchemaType,
} from '@portabletext/toolbar'
const extendInlineObject: ExtendInlineObjectSchemaType = (inlineObject) => {
if (inlineObject.name === 'stock-ticker') {
return {...inlineObject, title: 'Stock', icon: () => <span>📈</span>}
}
return inlineObject
}
function InlineObjectButton(props: {
schemaType: ToolbarInlineObjectSchemaType
}) {
const inlineObjectButton = useInlineObjectButton(props)
return (
<button
type="button"
onClick={() => inlineObjectButton.send({type: 'open dialog'})}
disabled={!inlineObjectButton.snapshot.matches('enabled')}
>
{props.schemaType.icon && <props.schemaType.icon />}
{props.schemaType.title}
</button>
)
}

Add the inline object buttons to your toolbar alongside the block object buttons:

function Toolbar() {
const toolbarSchema = useToolbarSchema({
extendBlockObject,
extendInlineObject,
})
return (
<div>
{/* ... decorator and style buttons */}
{toolbarSchema.blockObjects?.map((blockObject) => (
<BlockObjectButton key={blockObject.name} schemaType={blockObject} />
))}
{toolbarSchema.inlineObjects?.map((inlineObject) => (
<InlineObjectButton key={inlineObject.name} schemaType={inlineObject} />
))}
</div>
)
}

When a user inserts a block object, the editor produces a block in the Portable Text array with the custom _type:

[
{
"_type": "block",
"_key": "abc123",
"style": "normal",
"children": [{"_type": "span", "text": "Here is a photo:"}],
"markDefs": []
},
{
"_type": "image",
"_key": "def456",
"src": "https://example.com/photo.jpg",
"alt": "A mountain landscape"
},
{
"_type": "block",
"_key": "ghi789",
"style": "normal",
"children": [
{"_type": "span", "text": "The current price of "},
{
"_type": "stock-ticker",
"_key": "jkl012",
"symbol": "AAPL",
"exchange": "NASDAQ"
},
{"_type": "span", "text": " is rising."}
],
"markDefs": []
}
]

The image block sits between text blocks. The stock ticker sits inside a text block’s children array. Both carry structured data that your rendering serializers can use.