Skip to content

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.

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 in the editor (renderBlock or renderChild prop)
  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.

Use the renderBlock prop on PortableTextEditable to control how block objects appear in the editor. The function receives props with schemaType.name (the block type) and value (the block data):

import type {PortableTextBlock, RenderBlockFunction} from '@portabletext/editor'
const renderBlock: RenderBlockFunction = (props) => {
// Image block
if (props.schemaType.name === 'image' && isImage(props.value)) {
return (
<div
style={{border: '1px solid #ccc', padding: '0.5em', margin: '0.5em 0'}}
>
<img
src={props.value.src}
alt={props.value.alt || ''}
style={{maxWidth: '100%'}}
/>
{props.value.caption && (
<p style={{fontSize: '0.875em', color: '#666'}}>
{props.value.caption}
</p>
)}
</div>
)
}
// Code block
if (props.schemaType.name === 'code' && isCodeBlock(props.value)) {
return (
<pre
style={{
background: '#f5f5f5',
padding: '1em',
borderRadius: '4px',
overflow: 'auto',
}}
>
<code>{props.value.text}</code>
</pre>
)
}
// Default: render text blocks with children
return <div style={{marginBlockEnd: '0.25em'}}>{props.children}</div>
}
// Type guards for block data
function isImage(
value: PortableTextBlock,
): value is PortableTextBlock & {src: string; alt?: string; caption?: string} {
return 'src' in value
}
function isCodeBlock(
value: PortableTextBlock,
): value is PortableTextBlock & {text: string; language?: string} {
return 'text' in value
}

Pass the function to PortableTextEditable:

<PortableTextEditable
renderBlock={renderBlock}
// ... other render props
/>

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('open dialog')}
disabled={!blockObjectButton.snapshot.matches('enabled')}
>
{props.schemaType.icon && <props.schemaType.icon />}
{props.schemaType.title}
</button>
)
}

When the user clicks the button, the editor opens a dialog for that block type. You control what happens in the dialog: collect the data, then insert the block.

For blocks that need user input (like images), use the dialog flow:

// In the dialog's submit handler:
blockObjectButton.send({
type: 'insert',
value: {src: imageUrl, alt: altText},
placement: undefined,
})

For blocks that don’t need initial data, insert directly:

onClick={() => blockObjectButton.send({
type: 'insert',
value: {},
placement: undefined,
})}

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

Use the renderChild prop. Inline objects receive props.value with the object data:

import type {PortableTextChild, RenderChildFunction} from '@portabletext/editor'
const renderChild: RenderChildFunction = (props) => {
if (props.schemaType.name === 'stock-ticker' && isStockTicker(props.value)) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25em',
border: '1px solid #e0e0e0',
borderRadius: '4px',
padding: '0 0.5em',
fontSize: '0.875em',
background: '#f9f9f9',
}}
>
📈 {props.value.symbol}
{props.value.exchange && (
<span style={{color: '#999', fontSize: '0.75em'}}>
{props.value.exchange}
</span>
)}
</span>
)
}
// Default: render inline children
return <>{props.children}</>
}
function isStockTicker(
value: PortableTextChild,
): value is PortableTextChild & {symbol: string; exchange?: string} {
return 'symbol' in value
}

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('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.