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.
Parts of the editor
Section titled “Parts of 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.
Add the library to your project
Section titled “Add the library to your project”Start by installing the editor (it requires React 19.2.8 or later):
npm i @portabletext/editorpnpm add @portabletext/editoryarn add @portabletext/editorNext, import the components and types you’ll need:
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.
Define your schema
Section titled “Define your schema”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.
// ...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: [],})Render the editor
Section titled “Render the editor”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:
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 AppInclude the App component in your application and run it. You should see an outlined editor that accepts text, but doesn’t do much else.
Set up rendering for schema elements
Section titled “Set up rendering for schema elements”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
typeand the node itself. - They return JSX that renders
childrensomewhere 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.
Create a toolbar
Section titled “Create a toolbar”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:
- Add the
@portabletext/toolbarand@portabletext/keyboard-shortcutslibraries to your project. - Create a
Toolbarcomponent, along with any sub-components in the same file. - Configure
useToolbarSchemato access the editor schema, then loop over the schema types to create buttons for each style and decorator. - Enhance the schema with any icons, labels, or descriptions you want to display in the toolbar.
- Create buttons for each schema group (styles, decorators, annotations, etc.).
- Add the
Toolbarto your render function inside theEditorProvider.
npm i @portabletext/toolbar @portabletext/keyboard-shortcutspnpm add @portabletext/toolbar @portabletext/keyboard-shortcutsyarn add @portabletext/toolbar @portabletext/keyboard-shortcutsThis example shows a minimal toolbar:
// ...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 typeconst 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 togglefunction 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 buttonThe 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.
Bring it all together
Section titled “Bring it all together”With the registrations created and a toolbar in place, you can fully render the editor. Add the Toolbar inside the EditorProvider.
// ...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.
View the Portable Text data
Section titled “View the Portable Text data”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.
Behavior API
Section titled “Behavior API”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.