Migrate from v7 to v8
v7 introduced containers and, alongside them, a new rendering API: node registrations. In v7 you rendered through one callback per node kind, switching on types inside it. With registrations, each node type is defined once, with its own render:
// v7: a render prop on <PortableTextEditable><PortableTextEditable renderDecorator={(props) => props.value === 'strong' ? ( <strong>{props.children}</strong> ) : ( props.children ) }/>
// v8: a node registration, mounted once inside <EditorProvider>const strong = defineDecorator({ type: 'strong', render: ({children}) => <strong>{children}</strong>,})
<NodePlugin nodes={[strong]} />v8 makes registrations the only rendering API: the six render props on <PortableTextEditable>, one callback per node kind, are removed, along with the Slate-era data-slate-* attributes and the default CSS classes the old rendering emitted.
Prepare on your latest v7 release first: move render props to node registrations and swap data-slate-* selectors for data-pt-*, both work on v7. Then bump the @portabletext/* packages together and sweep the rest of the checklist. This page covers every removal and rename of the editor’s public surface, in upgrade order; if a piece of surface is not mentioned here, v8 does not remove or rename it.
The breaking changes at a glance:
- Node 22.12 or later is required.
- The six render props are removed; rendering goes through node registrations.
- The
data-slate-*attributes and the default CSS classes are gone; style and query ondata-pt-*. - The built-in drop indicator is gone;
@portabletext/plugin-dndtracks the drop position for your own. - The built-in
text/markdownclipboard converter is removed; a recipe restores it. - The
loading,done loading, anderrorevents are removed. - Packages resolve through
exportsonly; themainandmodulefields are gone. resolveContainerAtis no longer exported.
v8 also ships behavior improvements that need no migration work. Dropping a block object mid-paragraph now splits the paragraph at the drop point, where v7 snapped the drop to the nearest block edge. See the changelog for the full list.
Node 22.12 or later
Section titled “Node 22.12 or later”Every package now requires Node 22.12 or later, since Node 20 reached end of life in April 2026. @portabletext/to-html and @portabletext/toolkit take a major for the same Node requirement.
Render props become node registrations
Section titled “Render props become node registrations”If your editor mounts any of these six props on <PortableTextEditable>, each has a registration waiting for it:
| Removed prop | Removed types | Replacement |
|---|---|---|
renderStyle |
RenderStyleFunction, BlockStyleRenderProps |
defineTextBlock |
renderListItem |
RenderListItemFunction, BlockListItemRenderProps |
defineTextBlock + @portabletext/plugin-list-index |
renderBlock |
RenderBlockFunction, BlockRenderProps |
defineTextBlock (text blocks) / defineBlockObject (block objects) |
renderChild |
RenderChildFunction, BlockChildRenderProps |
defineInlineObject (inline objects) / defineSpan (spans) |
renderDecorator |
RenderDecoratorFunction, BlockDecoratorRenderProps |
defineDecorator |
renderAnnotation |
RenderAnnotationFunction, BlockAnnotationRenderProps |
defineAnnotation |
Registrations mount through NodePlugin and the engine dispatches per type (decorator name, annotation _type, block or object _type), so you can migrate one prop at a time on the latest v7 release, where both paths still work, and bump to v8 when the props are gone from your code. The render props migration guide has a worked example for each, and Rendering covers the model they all share.
Two props you might expect on the list are not on it. renderPlaceholder stays, the placeholder is empty-editor chrome with no type to register under, and rangeDecorations stays, it wraps a span’s output from the outside.
If your render callbacks read schemaType or block from their payload, both are derivable inside a registered render: getPathSubSchema and getEnclosingBlock from @portabletext/editor/traversal, called in a selector. The one field with no equivalent is editorElementRef, the engine’s own DOM node; put a ref on your own rendered element instead, it reaches the same position in the DOM.
Style and query on data-pt-*
Section titled “Style and query on data-pt-*”The Slate-era data-slate-* attributes are gone from the engine’s DOM. If you have CSS or DOM queries keyed on the editor’s markup, this section is your checklist, and the surface to key on is the data-pt-* family, present on v7 too, so the selector swap below can land before you bump to v8:
| Attribute | Carried by | Value |
|---|---|---|
data-pt-editor |
the root editable | true |
data-read-only |
the root editable | true or false, always present |
data-pt-path |
every rendered node | the node’s serialized path |
data-pt-block |
block-level nodes | text, object, or container |
data-pt-inline |
inline nodes | span or object |
data-pt-marks |
every leaf, marked or not | true |
data-pt-text |
the text node wrapper | true |
data-pt-spacer |
the engine’s void spacers | true |
data-pt-zero-width |
zero-width fillers | true |
data-pt-line-break |
zero-width line-break fillers | true |
Query read-only state with [data-read-only="true"]: React always emits the attribute, so a bare [data-read-only] selector matches whether the editor is read-only or not. Line breaks are zero-width fillers too, so they carry both data-pt-zero-width and data-pt-line-break.
Selectors on the Slate-era aliases map row for row, but check match breadth before a mechanical find-and-replace: the bare [data-pt-path] matches more nodes than [data-slate-node="element"] did, since data-pt-path sits on every rendered node, not only elements.
| Removed | Replacement |
|---|---|
data-slate-editor |
data-pt-editor |
data-slate-node="element" |
data-pt-path (blocks also carry data-pt-block) |
data-slate-node="text" |
data-pt-inline="span" |
data-slate-leaf |
data-pt-marks |
data-slate-string |
data-pt-text |
data-slate-zero-width |
data-pt-zero-width (line breaks also carry data-pt-line-break) |
data-slate-void |
data-pt-block="object" / data-pt-inline="object" |
data-slate-spacer |
data-pt-spacer |
The old default render’s CSS classes and data attributes have no one-to-one replacements, because your registered render owns the markup now. Where you need a styling hook, emit your own attribute:
- Classes:
pt-block,pt-text-block,pt-text-block-style-*,pt-list-item,pt-list-item-*,pt-list-item-level-*,pt-object-block,pt-inline-object, andpt-editableon the root editable (a consumer-passedclassNamestill applies) - Data attributes:
data-block-key,data-block-name,data-block-type,data-child-key,data-child-name,data-child-type,data-style,data-list-item,data-level, anddata-list-index(re-emittable via@portabletext/plugin-list-index: wrap the editor inListIndexProviderand readuseListIndexin your render)
That does mean this bucket depends on the previous section: you need a registered render before you can emit anything from it.
Draw your own drop indicator
Section titled “Draw your own drop indicator”The engine no longer draws its own drop indicator during block drags. Block drag-and-drop itself keeps working: @portabletext/plugin-dnd tracks the drop position from the editor’s public drag.* events, and your render draws whatever indicator your design wants.
Restore the text/markdown clipboard behavior
Section titled “Restore the text/markdown clipboard behavior”The editor no longer ships a built-in text/markdown converter: pasting Markdown-formatted text no longer auto-parses it into Portable Text blocks, and copying no longer writes a text/markdown entry to the clipboard alongside text/html and text/plain. The built-in ran the markdown package’s defaults with no way to configure renderers or type mappings, and markdown copy/paste is something most consumers want to configure for their own schema. text/markdown is also mostly a dead clipboard channel: almost nothing writes it, and markdown usually arrives as text/plain, so a converter keyed on text/markdown rarely saw a real paste. Now only apps that want markdown interop carry the dependency, configured their way; every other editor stops bundling markdown-it (about 145 KB minified).
To keep markdown interop, add @portabletext/markdown as your own dependency and register two Behaviors (via BehaviorPlugin or editor.registerBehavior). Both sides are a faithful restore: text/markdown goes back on the clipboard on copy, and deserialize.data for text/markdown parses it back into Portable Text blocks on paste. Since markdown usually arrives as text/plain, most consumers will want to widen the guard to accept that too.
text/markdown keeps its place ahead of text/html and text/plain in the paste priority. With no converter and no Behavior answering deserialize.data for it, a paste doesn’t dead-end: it falls through to the next available format on the clipboard.
Table copies from @portabletext/plugin-table lose their text/markdown entry with the converter. Copying and pasting between two Portable Text Editors is unaffected: that round-trips through application/x-portable-text.
import {defineBehavior, raise} from '@portabletext/editor/behaviors'import {getFragment} from '@portabletext/editor/selectors'import { markdownToPortableText, portableTextToMarkdown,} from '@portabletext/markdown'
const deserializeMarkdown = defineBehavior({ on: 'deserialize.data', guard: ({snapshot, event}) => { if (event.mimeType !== 'text/markdown') { return false } const blocks = markdownToPortableText(event.data, { schema: snapshot.context.schema, keyGenerator: snapshot.context.keyGenerator, }) return blocks.length > 0 ? {blocks} : false }, actions: [ ({event}, {blocks}) => [ raise({...event, type: 'deserialization.success', data: blocks}), ], ],})
const serializeMarkdown = defineBehavior({ on: 'serialize.data', guard: ({event}) => event.mimeType === 'text/markdown', actions: [ ({snapshot, event}) => [ raise({ type: 'serialization.success', mimeType: 'text/markdown', data: portableTextToMarkdown( getFragment(snapshot).map((entry) => entry.node), ), originEvent: event.originEvent, }), ], ],})Removed editor events
Section titled “Removed editor events”Three members leave EditorEmittedEvent: loading, done loading, and error. error was never emitted, so its listeners were dead code. loading and done loading were real: they fired around an async onPaste resolution. onPaste stays in v8, but nothing signals around it anymore; if you showed paste progress from these events, track it around your own onPaste promise instead. An exhaustive switch over event.type loses three cases.
Packaging
Section titled “Packaging”Published packages resolve through exports only; the legacy main and module fields are gone. Tooling old enough to ignore exports predates the packages’ Node requirement.
resolveContainerAt is no longer exported
Section titled “resolveContainerAt is no longer exported”The @alpha API has been removed. If you use it, snapshot.context.containers exposes the registered container map, and getContainerChildren covers per-node descent; the full path walk builds from that primitive.