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.
How custom blocks work
Section titled “How custom blocks work”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:
- Define the type in your schema (
blockObjectsorinlineObjects) - Render it as a node registration (
defineBlockObjectordefineInlineObject), mounted throughNodePlugin - Insert it via the toolbar (
useBlockObjectButtonoruseInlineObjectButtonhook)
Adding block objects
Section titled “Adding block objects”Step 1: define in schema
Section titled “Step 1: define in schema”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.
Step 2: render it
Section titled “Step 2: render it”Register each block object type with defineBlockObject. The registration’s render owns the block’s wrapper element entirely:
- Spread
attributesonto 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 withdraggable={!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 toprops.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} />Step 3: insert via toolbar
Section titled “Step 3: insert via toolbar”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 toolbarconst 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> )}Adding inline objects
Section titled “Adding inline objects”Inline objects work the same way as block objects, but they appear inside text blocks rather than alongside them.
Step 1: define in schema
Section titled “Step 1: define in schema”const schemaDefinition = defineSchema({ // ... styles, decorators, annotations, lists, blockObjects inlineObjects: [{name: 'stock-ticker'}],})Step 2: render it
Section titled “Step 2: render it”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} />Step 3: insert via toolbar
Section titled “Step 3: insert via toolbar”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> )}The Portable Text output
Section titled “The Portable Text output”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.