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

Getting started

This guide walks you through installing and configuring the Portable Text Editor. By the end, you’ll have a working block content editor with custom styles, decorators, and a toolbar.

You’ll need to:

  • Create a schema that defines your content elements.
  • Create a toolbar to toggle and insert these elements.
  • Set up rendering for your text blocks and inline formatting, like bold and italic.
  • Render the editor.

Before starting, it helps to understand the components that make up the editor.

  • Schema: Describes the type of content the editor accepts. Think of this as the foundation for configuring the editor.
  • EditorProvider: Supplies the schema and initial state to the editor.
  • EventListenerPlugin: Listens to events emitted by the editor. Commonly used to update application state.
  • Node registrations: Own how each content element renders, mounted through NodePlugin. Rendering covers the model.
  • Toolbars: UI elements that interact with the editor.
  • PortableTextEditable: The core editor component. Hosts the editable surface and manages behavior.

Start by installing the editor (it requires React 19.2.8 or later):

Terminal window
npm i @portabletext/editor

Next, import the components and types you’ll need:

App.tsx
import {
defineDecorator,
defineSchema,
defineTextBlock,
EditorProvider,
PortableTextEditable,
} from '@portabletext/editor'
import type {PortableTextBlock} from '@portabletext/editor'
import {EventListenerPlugin, NodePlugin} from '@portabletext/editor/plugins'

You won’t need all of these right away, but you can add them now.

Before you can render the editor, you need a schema. The editor schema configures the types of content rendered by the editor.

Start with a schema that includes some common formatting elements.

App.tsx
// ...
const schemaDefinition = defineSchema({
// Decorators are simple marks that don't hold any data
decorators: [{name: 'strong'}, {name: 'em'}, {name: 'underline'}],
// Styles apply to entire text blocks
// There's always a 'normal' style that can be considered the paragraph style
styles: [
{name: 'normal'},
{name: 'h1'},
{name: 'h2'},
{name: 'h3'},
{name: 'blockquote'},
],
// The types below are left empty for this example.
// See the rendering guide to learn more about each type.
// Annotations are more complex marks that can hold data (for example, hyperlinks).
annotations: [],
// Lists apply to entire text blocks as well (for example, bullet, numbered).
// Lists render inside your registered text-block render; ordered-list
// numbering comes from `@portabletext/plugin-list-index`.
lists: [],
// Inline objects hold arbitrary data that can be inserted into the text (for example, custom emoji).
inlineObjects: [],
// Block objects hold arbitrary data that live side-by-side with text blocks (for example, images, code blocks, and tables).
blockObjects: [],
})

With a schema defined, you have enough to render the editor. It won’t do much yet, but you can confirm your progress.

Add react and useState, then scaffold out a basic application component:

app.tsx
import {
defineDecorator,
defineSchema,
defineTextBlock,
EditorProvider,
PortableTextEditable,
} from '@portabletext/editor'
import type {PortableTextBlock} from '@portabletext/editor'
import {EventListenerPlugin, NodePlugin} from '@portabletext/editor/plugins'
import {useState} from 'react'
const schemaDefinition = defineSchema({
/* your schema from the previous step */
})
function App() {
// Set up the initial state getter and setter. Leave the starting value as undefined for now.
const [value, setValue] = useState<Array<PortableTextBlock> | undefined>(
undefined,
)
return (
<>
<EditorProvider
initialConfig={{
schemaDefinition,
initialValue: value,
}}
>
<EventListenerPlugin
on={(event) => {
if (event.type === 'mutation') {
setValue(event.value)
}
}}
/>
<PortableTextEditable
// Add an optional style to see it more easily on the page
style={{border: '1px solid black', padding: '0.5em'}}
/>
</EditorProvider>
</>
)
}
export default App

Include the App component in your application and run it. You should see an outlined editor that accepts text, but doesn’t do much else.

At this point the editor renders every text block as plain text, whatever its style. Fix that by registering a defineTextBlock node for the text blocks and defineDecorator nodes for the marks. The Rendering page covers the model these registrations share.

If you’re maintaining an editor that rendered through the renderStyle, renderBlock, renderListItem, renderDecorator, and renderAnnotation props, removed in this major, see the migration guide to move to node registrations instead of following this section from scratch.

Start by registering the text block render with defineTextBlock. The editor dispatches every text block to this callback. Your callback owns the block’s wrapper element, so spread props.attributes on the outermost element you return, and use the block’s style to pick the element.

const textBlock = defineTextBlock({
type: 'block',
render: (props) => {
if (props.node.style === 'h1') {
return <h1 {...props.attributes}>{props.children}</h1>
}
if (props.node.style === 'h2') {
return <h2 {...props.attributes}>{props.children}</h2>
}
if (props.node.style === 'h3') {
return <h3 {...props.attributes}>{props.children}</h3>
}
if (props.node.style === 'blockquote') {
return <blockquote {...props.attributes}>{props.children}</blockquote>
}
return <div {...props.attributes}>{props.children}</div>
},
})

Marks (decorators and annotations) join the same nodes array. Registrations all follow the same shape:

  • They take in props and return JSX elements.
  • They decide what to render from the registration’s type and the node itself.
  • They return JSX that renders children somewhere inside it, the editable content the registration wraps.

With this in mind, continue for the remaining schema types.

Register a decorator with defineDecorator, one per decorator name:

const strong = defineDecorator({
type: 'strong',
render: ({children}) => <strong>{children}</strong>,
})
const em = defineDecorator({
type: 'em',
render: ({children}) => <em>{children}</em>,
})
const underline = defineDecorator({
type: 'underline',
render: ({children}) => <u>{children}</u>,
})
const nodes = [textBlock, strong, em, underline]

Mount every registration through one NodePlugin, inside the EditorProvider. Keep the nodes array itself at module scope, as above: a fresh array on every render would make NodePlugin unregister and re-register on every keystroke.

<>
<NodePlugin nodes={nodes} />
<PortableTextEditable style={{border: '1px solid black', padding: '0.5em'}} />
</>

Before you can see if anything changed, you need a way to interact with the editor.

A toolbar is a collection of UI elements for interacting with the editor. The @portabletext/toolbar library exposes hooks and types that allow you to create a toolbar however you like. The @portabletext/keyboard-shortcuts library provides drop-in shortcut access to link toolbar buttons to key commands.

Building a custom toolbar differs with each project, but in this example:

  1. Add the @portabletext/toolbar and @portabletext/keyboard-shortcuts libraries to your project.
  2. Create a Toolbar component, along with any sub-components in the same file.
  3. Configure useToolbarSchema to access the editor schema, then loop over the schema types to create buttons for each style and decorator.
  4. Enhance the schema with any icons, labels, or descriptions you want to display in the toolbar.
  5. Create buttons for each schema group (styles, decorators, annotations, etc.).
  6. Add the Toolbar to your render function inside the EditorProvider.
Terminal window
npm i @portabletext/toolbar @portabletext/keyboard-shortcuts

This example shows a minimal toolbar:

App.tsx
// ...
import {bold} from '@portabletext/keyboard-shortcuts'
import {
useDecoratorButton,
useStyleSelector,
useToolbarSchema,
type ExtendDecoratorSchemaType,
type ExtendStyleSchemaType,
type ToolbarDecoratorSchemaType,
type ToolbarStyleSchemaType,
} from '@portabletext/toolbar'
function Toolbar() {
// useToolbarSchema provides access to the PTE schema
// optionally, pass in updated schemas to override the default
const toolbarSchema = useToolbarSchema({
extendDecorator, // see declarations below
extendStyle, // see declarations below
})
return (
<div>
{toolbarSchema.decorators?.map((decorator) => (
<DecoratorButton key={decorator.name} schemaType={decorator} />
))}
{toolbarSchema.styles ? (
<StyleButtons schemaTypes={toolbarSchema.styles} />
) : null}
</div>
)
}
// Extend the schema with icons, titles, and keyboard shortcuts
const extendStyle: ExtendStyleSchemaType = (style) => {
// Apply updates to the schema, if needed
if (style.name === 'h1') {
return {
...style,
title: 'Title',
}
}
// ...repeat for each style type, or return the original style
return style
}
const extendDecorator: ExtendDecoratorSchemaType = (decorator) => {
if (decorator.name === 'strong') {
return {
...decorator,
// Optional: add a react component as an icon and unset the title
icon: () => <strong>B</strong>,
// Optional: connect to a keyboard shortcut from the keyboard-shortcuts library
shortcut: bold,
title: '',
}
}
// ...repeat for each decorator type, or return the original decorator
return decorator
}
// Create a button for each decorator type
const DecoratorButton = (props: {schemaType: ToolbarDecoratorSchemaType}) => {
const decoratorButton = useDecoratorButton(props)
return (
<button
type="button"
onClick={() => decoratorButton.send({type: 'toggle'})}
className={
decoratorButton.snapshot.matches({enabled: 'active'}) ? 'active' : ''
}
>
{props.schemaType.icon ? <props.schemaType.icon /> : null}
{props.schemaType.title}
</button>
)
}
// One `useStyleSelector` drives all style buttons: a block has one style at
// a time, so the hook is a selector, not a per-button toggle
function StyleButtons(props: {
schemaTypes: ReadonlyArray<ToolbarStyleSchemaType>
}) {
const styleSelector = useStyleSelector(props)
const activeStyle = styleSelector.snapshot.context.activeStyle ?? 'normal'
return props.schemaTypes.map((schemaType) => (
<button
key={schemaType.name}
type="button"
onClick={() =>
styleSelector.send({type: 'toggle', style: schemaType.name})
}
className={activeStyle === schemaType.name ? 'active' : ''}
>
{schemaType.icon ? <schemaType.icon /> : null}
{schemaType.title}
</button>
))
}
// ... and so on for each schema type, or create a generic button

The useStyleSelector and useDecoratorButton hooks give you access to the active editor. send lets you send events to the editor, and snapshot lets you read the current state of the editor.

In the next step, you’ll add the toolbar to the editor.

With the registrations created and a toolbar in place, you can fully render the editor. Add the Toolbar inside the EditorProvider.

App.tsx
// ...
function App() {
const [value, setValue] = useState<Array<PortableTextBlock> | undefined>(
undefined,
)
return (
<>
<EditorProvider
initialConfig={{
schemaDefinition,
initialValue: value,
}}
>
<EventListenerPlugin
on={(event) => {
if (event.type === 'mutation') {
setValue(event.value)
}
}}
/>
<NodePlugin nodes={nodes} />
<Toolbar />
<PortableTextEditable
style={{border: '1px solid black', padding: '0.5em'}}
/>
</EditorProvider>
</>
)
}
// ...

You can now enter text and interact with the toolbar buttons to toggle the styles and decorators. These are only a small portion of the types of things you can do. Check out the custom rendering guide and the toolbar customization guide for options.

You can preview the Portable Text from the editor by reading the state. Add the following after the EditorProvider:

<pre style={{border: '1px dashed black', padding: '0.5em'}}>
{JSON.stringify(value, null, 2)}
</pre>

This displays the raw Portable Text. To customize how Portable Text renders in your apps, explore the serializers.

The Behavior API lets you customize how users interact with the editor by hooking into events:

  • Declaratively hook into editor events and define new behaviors.
  • Imperatively trigger events.
  • Derive editor state using pure functions.
  • Subscribe to emitted editor events.

Learn more about behaviors and how to create your own.