Create the editor with its initial document and plugin tuple. Configure editor-wide runtime policy here; configure feature behavior on its owning plugin.
To create a basic Plate editor, you can use the createPlateEditor function, or usePlateEditor in a React component:
import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [HeadingPlugin],
});import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [HeadingPlugin],
});Set the initial content of the editor:
const editor = createPlateEditor({
initialValue: [
{
type: 'paragraph',
children: [{ text: 'Hello, Plate!' }],
},
],
});const editor = createPlateEditor({
initialValue: [
{
type: 'paragraph',
children: [{ text: 'Hello, Plate!' }],
},
],
});Pass an EditorDocumentValue with children, roots, and persisted meta
when one editor owns content roots or other named regions. Plate publishes the
same complete shape through onValueChange.
Use a synchronous initializer when decoding requires the compiled plugin model:
const editor = createPlateEditor({
plugins: [HtmlPlugin, BoldPlugin, ItalicPlugin],
initialValue: ({ editor }) =>
editor.api.html.deserialize({
element: '<p>This is <b>bold</b> and <i>italic</i> text!</p>',
}),
});const editor = createPlateEditor({
plugins: [HtmlPlugin, BoldPlugin, ItalicPlugin],
initialValue: ({ editor }) =>
editor.api.html.deserialize({
element: '<p>This is <b>bold</b> and <i>italic</i> text!</p>',
}),
});For a comprehensive list of plugins that support HTML string deserialization, refer to the Plugin Deserialization Rules section.
Plate compiles the document schema from the installed plugins. Omit schema
for ordinary editors. editor.read.schema.identity() returns a derived
identity with the exact compiled fingerprint:
const editor = createPlateEditor({
plugins: [HeadingPlugin],
});
editor.read.schema.identity();
// { kind: 'derived', fingerprint: '...' }const editor = createPlateEditor({
plugins: [HeadingPlugin],
});
editor.read.schema.identity();
// { kind: 'derived', fingerprint: '...' }Put id and version in schema when a document participates in durable
persistence, history serialization, schema migration, or collaboration peer
negotiation. The same object owns application schema overrides and named
lineage. The fingerprint remains tied only to the compiled schema:
const editor = createPlateEditor({
plugins: [HeadingPlugin],
schema: { id: 'acme-document', version: 3 },
});
editor.read.schema.identity();
// { kind: 'named', id: 'acme-document', version: 3, fingerprint: '...' }const editor = createPlateEditor({
plugins: [HeadingPlugin],
schema: { id: 'acme-document', version: 3 },
});
editor.read.schema.identity();
// { kind: 'named', id: 'acme-document', version: 3, fingerprint: '...' }Bump version whenever the named lineage changes schema semantics. Reusing an
ID and version with a different fingerprint is an identity mismatch.
Omit schema.root for Plate's standard nonempty paragraph root. Declare it
only when the application needs a different top-level grammar.
import { schema } from 'platejs';
import {
createPlateEditor,
definePlatePlugin,
ParagraphPlugin,
} from 'platejs/react';
const SectionPlugin = definePlatePlugin('section', {
schema: {
element: {
content: schema.content.element(ParagraphPlugin, { min: 1 }),
},
},
});
const editor = createPlateEditor({
plugins: [SectionPlugin],
schema: {
root: schema.content.element(SectionPlugin, { min: 1 }),
},
});import { schema } from 'platejs';
import {
createPlateEditor,
definePlatePlugin,
ParagraphPlugin,
} from 'platejs/react';
const SectionPlugin = definePlatePlugin('section', {
schema: {
element: {
content: schema.content.element(ParagraphPlugin, { min: 1 }),
},
},
});
const editor = createPlateEditor({
plugins: [SectionPlugin],
schema: {
root: schema.content.element(SectionPlugin, { min: 1 }),
},
});root.min is required and must be a positive integer. Root descriptors must
match the installed plugin family: use Plate descriptors with
createPlateEditor and Base descriptors with a headless Base plugin tuple.
For schema.content.elements([...]), the first descriptor constructs the
default root child.
A custom root changes the compiled fingerprint. For a named persisted schema,
increment version and add the document migration before loading documents
that use the earlier root grammar. Generated Value types include every legal
root variant; min and max remain runtime validation rules.
Persist schema identity beside the document, then declare one application-owned migration chain. Target-version steps run in ascending order before installed plugin preparation and schema fitting.
import { ParagraphPlugin } from 'platejs/react';
import {
defineDocumentMigrations,
migratePlateV54,
migratePlateV55,
} from 'platejs/migrations';
import { fingerprint as v53Fingerprint } from './migrations/v54-upgrade-plate/from';
import { fingerprint as v54Fingerprint } from './migrations/v55-upgrade-plate/from';
export const EditorKit = [ParagraphPlugin] as const;
export const EditorSchema = {
id: 'acme-document',
version: 55,
} as const;
export const EditorMigrations = defineDocumentMigrations(EditorSchema, {
sourceFingerprints: { 53: v53Fingerprint, 54: v54Fingerprint },
steps: { 54: migratePlateV54, 55: migratePlateV55 },
unversioned: 53,
});import { ParagraphPlugin } from 'platejs/react';
import {
defineDocumentMigrations,
migratePlateV54,
migratePlateV55,
} from 'platejs/migrations';
import { fingerprint as v53Fingerprint } from './migrations/v54-upgrade-plate/from';
import { fingerprint as v54Fingerprint } from './migrations/v55-upgrade-plate/from';
export const EditorKit = [ParagraphPlugin] as const;
export const EditorSchema = {
id: 'acme-document',
version: 55,
} as const;
Pass the same three exports to the editor. unversioned is an explicit support
floor for stored documents without schema metadata; remove it when that data is
no longer supported.
import { Plate, usePlateEditor } from 'platejs/react';
import { EditorKit, EditorMigrations, EditorSchema } from './editor';
export function DocumentEditor({ persisted }) {
const editor = usePlateEditor({
initialValue: persisted,
migrations: EditorMigrations,
plugins: EditorKit,
schema: EditorSchema,
});
return <Plate editor={editor} />;
}import { Plate, usePlateEditor } from 'platejs/react';
import { EditorKit, EditorMigrations, EditorSchema } from './editor';
export function DocumentEditor({ persisted }) {
const editor = usePlateEditor({
initialValue: persisted,
migrations: EditorMigrations,
plugins: EditorKit,
schema: EditorSchema,
});
return <Plate editor={editor} />;
}Store the complete envelope returned by your application boundary:
const persisted = {
document: value,
schema: editor.read.schema.identity(),
};const persisted = {
document: value,
schema: editor.read.schema.identity(),
};A document at version 53 loaded by a version 55 editor runs steps 54 and 55.
Plate rejects a missing step, a different schema ID, a future version, or a
fingerprint mismatch instead of fitting incompatible data. Every historical
envelope version needs its exact generated fingerprint in
sourceFingerprints; raw documents use the explicit unversioned floor.
Document migrations do not rewrite serialized history or a populated Yjs room. Invalidate or migrate stored history offline, and connect upgraded clients to a new schema-versioned room after migrating its snapshot. Do not mix peers from different schema versions.
Fetch remote content before constructing the editor. The loader owns aborts, stale responses, retries, and errors; the editor receives one synchronous initial document.
import type { Value } from 'platejs';
import { Plate, usePlateEditor } from 'platejs/react';
function DocumentEditor({ initialValue }: { initialValue: Value }) {
const editor = usePlateEditor({ initialValue });
return (
<Plate editor={editor}>
<EditorContainer>
<Editor />
</EditorContainer>
</Plate>
);
}import type { Value } from 'platejs';
import { Plate, usePlateEditor } from 'platejs/react';
function DocumentEditor({ initialValue }: { initialValue: Value }) {
const editor = usePlateEditor({ initialValue });
return (
<Plate editor={editor}>
<EditorContainer>
<Editor />
</EditorContainer>
</Plate>
);
}Render DocumentEditor only after your route or data loader resolves the
document. See Controlled Editor Value for an abort-aware
client loader and explicit replacement patterns.
You can add plugins to your editor by including them in the plugins array:
const editor = createPlateEditor({
plugins: [HeadingPlugin, ListPlugin],
});const editor = createPlateEditor({
plugins: [HeadingPlugin, ListPlugin],
});Raw plugin arrays keep plugin APIs, reads, updates, stores, and individual
element shapes inferred. Use a generated editor contract when the application
also needs one exact recursive Value across the complete plugin graph.
Export the runtime inputs from one source file:
import { HeadingPlugin } from '@platejs/basic-nodes/react';
import { TablePlugin } from '@platejs/table/react';
import { property, schema as s, target } from 'platejs';
export const EditorKit = [HeadingPlugin, TablePlugin] as const;
export const EditorSchema = {
id: 'app-document',
version: 1,
overrides: [
s.override(HeadingPlugin, {
element: { type: 'headingOne' },
}),
],
properties: {
reviewState: s.elementProperty(
property.enum(['draft', 'approved'] as const),
{ target: target.element(HeadingPlugin) }
),
},
} as const;import { HeadingPlugin } from '@platejs/basic-nodes/react';
import { TablePlugin } from '@platejs/table/react';
import { property, schema as s, target } from 'platejs';
export const EditorKit = [HeadingPlugin, TablePlugin] as const;
export const EditorSchema = {
id: 'app-document',
version: 1,
overrides: [
s.override(HeadingPlugin, {
element: { type: 'headingOne' },
}),
],
properties: {
reviewState: s.elementProperty(
property.enum
plate generate finds the single exported Plate plugin tuple and optional
application schema by their validated runtime shapes. Their export names belong
to your app, not the compiler contract.
The editor-level schema may
remap an element type, content, groups, or an existing property's target, and
it may add application-owned properties. Plugin-owned property keys and value
laws stay fixed; changing one requires a new field and an explicit migration.
Installed code reads final identities from schema handles:
editor.plugin(HeadingPlugin).schema.type; // 'headingOne'
generatedSchema.properties.reviewState.key; // 'reviewState'editor.plugin(HeadingPlugin).schema.type; // 'headingOne'
generatedSchema.properties.reviewState.key; // 'reviewState'Generate and commit the TypeScript and JSON contracts:
pnpm add -D @platejs/cli
pnpm exec plate generatepnpm add -D @platejs/cli
pnpm exec plate generateplate generate reads src/editor.ts. Pass entry paths when an app owns
several editor modules. The @plate/editor-plugins registry item installs the
authored plugin module only; it does not copy generated contracts.
Add --watch during development. Gate committed artifacts in CI:
{
"scripts": {
"editor:check": "plate generate --check src/editor.ts"
}
}{
"scripts": {
"editor:check": "plate generate --check src/editor.ts"
}
}The generated module exports exact editor/value types, static schema handles, and a fingerprint. It does not own runtime plugin composition:
import { Plate, usePlateEditor } from 'platejs/react';
import {
schema as generatedSchema,
type Editor,
type Value,
} from './plugins.generated';
import { EditorKit, EditorSchema } from './editor';
export function insertDocumentContent(editor: Editor) {
editor.update.heading.insert({ level: 1 });
editor.update.table.set({ marginLeft: 24 });
}
export function DocumentEditor({ initialValue }: { initialValue: Value }) {
const editor = usePlateEditor({
plugins: EditorKit,
schema: EditorSchema,
initialValue,
});
generatedSchema.properties.reviewState.key;
return <Plate editor={editor} />;
}
export type DocumentEditorInstance = Editor;import { Plate, usePlateEditor } from 'platejs/react';
import {
schema as generatedSchema,
type Editor,
type Value,
} from './plugins.generated';
import { EditorKit, EditorSchema } from './editor';
export function insertDocumentContent(editor: Editor) {
editor.update.heading.insert({ level: 1 });
editor.update.table.set({ marginLeft: 24 });
}
export function DocumentEditor({ initialValue }: { initialValue: Value }) {
Use generated Value and Editor only at static boundaries such as storage,
collaboration, and exported application types. Runtime constructors and hooks
consume the authored EditorKit and EditorSchema directly.
The CI command recompiles the authored module and fails when committed generated artifacts are stale.
Create typed application steps with the migration scaffold:
pnpm exec plate migrate new add-captionpnpm exec plate migrate new add-captionPass --entry <path> when the editor module does not use the standard path.
The scaffold contains typed FromValue and ToValue snapshots, their
fingerprints, and the structural diff. Add the completed function to
EditorMigrations.steps under the version it produces, and bind the exported
from fingerprint in sourceFingerprints.
The editor runs configured migrations for initial and deferred complete
document loads. For offline JSON files, the entry module exports the exact
EditorKit, EditorSchema, and EditorMigrations names used below. Explicit
names keep the executable runner deterministic even when the module exports
other arrays or schema-like objects.
pnpm exec plate migrate run --entry src/editor.ts --check documents/*.json
pnpm exec plate migrate run --entry src/editor.ts --write documents/*.json
cat document.json | pnpm exec plate migrate run --entry src/editor.ts --stdinpnpm exec plate migrate run --entry src/editor.ts --check documents/*.json
pnpm exec plate migrate run --entry src/editor.ts --write documents/*.json
cat document.json | pnpm exec plate migrate run --entry src/editor.ts --stdinThe command is a dry run unless --write is present. --check exits nonzero
when any document needs migration. --stdin writes the migrated envelope to
standard output and never mutates storage. Envelope selections are mapped and
preserved through the same document and preparation pipeline.
Set the maximum length of the editor:
const editor = createPlateEditor({
maxLength: 100,
});const editor = createPlateEditor({
maxLength: 100,
});Set a custom id for the editor:
const editor = createPlateEditor({
id: 'my-custom-editor-id',
});const editor = createPlateEditor({
id: 'my-custom-editor-id',
});Pass the same ID to controller-aware hooks when selecting a specific mounted
editor, for example useEditor({ id: 'my-custom-editor-id' }).
Every live descendant has an editor-scoped NodeKey. Node keys include
text nodes, survive moves and immutable updates, and disappear when the node is
removed. They never enter JSON, clipboard slices, history, or collaboration
payloads. Use them for selection, drag and drop, temporary UI state, and lazy
path lookup.
const nodeKey = editor.key(element);
const path = editor.read.nodes.path(nodeKey);
editor.update.nodes.remove({ at: nodeKey });const nodeKey = editor.key(element);
const path = editor.read.nodes.path(nodeKey);
editor.update.nodes.remove({ at: nodeKey });Node keys can target nodes across one editor's document roots. Path lookup is root-local: the base editor resolves paths in the main root, while an editor view resolves paths in its own root.
Use ElementIdPlugin only when an element needs an ID that survives storage or
cross-session references. The plugin is opt-in. It assigns persisted string IDs
to block and inline elements, never text nodes.
import { ElementIdPlugin } from "platejs";
const editor = usePlateEditor({
plugins: [
ElementIdPlugin.configure({
initialState: {
generateId: () => crypto.randomUUID(),
},
}),
],
});
const elementId = editor.plugin(ElementIdPlugin);
const key = editor.key(element);
const id = elementId.read.id(key);
const entry = id ? elementId.read.entry(id) : undefined;import { ElementIdPlugin } from "platejs";
const editor = usePlateEditor({
plugins: [
ElementIdPlugin.configure({
initialState: {
generateId: () => crypto.randomUUID(),
},
}),
],
});
const elementId = editor.plugin(ElementIdPlugin);
const key = editor.key(element);
const id = elementId.read.id(key);
const entry = id ? elementId.read.entry(id) : undefined;The default generator is full-length nanoid(). Loads and moves preserve valid
IDs. Copies, splits, duplicates, and pasted copies receive fresh IDs. Explicit
duplicates are rejected. Exact schema-derived elements expose element.id;
use the plugin read at erased or optionally installed package boundaries.
The compiled schema property target decides which elements receive IDs. Narrow the target in the application schema when only blocks need persisted identity:
import { ElementIdPlugin, schema, target } from "platejs";
const editor = usePlateEditor({
plugins: [ElementIdPlugin],
schema: {
overrides: [
schema.override(ElementIdPlugin, {
properties: { id: { target: target.group("block") } },
}),
],
},
});import { ElementIdPlugin, schema, target } from "platejs";
const editor = usePlateEditor({
plugins: [ElementIdPlugin],
schema: {
overrides: [
schema.override(ElementIdPlugin, {
properties: { id: { target: target.group("block") } },
}),
],
},
});Document preparation generates IDs only for matching elements and removes the
plugin-owned id from excluded elements.
Use the pure migration helper before loading stored documents that lack IDs. It preserves valid strings, fills missing IDs, reports duplicates, and requires an explicit policy for legacy numeric IDs.
import { migrateElementIds, nanoid } from "platejs";
const result = migrateElementIds(storedValue, {
convertNumericId: (id) => `legacy-${id}`,
generateId: nanoid,
sourceKey: 'legacyElementId',
});
if (result.duplicates.length > 0) {
throw new Error("Resolve duplicate element IDs before loading the document.");
}
const editor = createPlateEditor({
plugins: [ElementIdPlugin],
initialValue: result.value,
});import { migrateElementIds, nanoid } from "platejs";
const result = migrateElementIds(storedValue, {
convertNumericId: (id) => `legacy-${id}`,
generateId: nanoid,
sourceKey: 'legacyElementId',
});
if (result.duplicates.length > 0) {
throw new Error("Resolve duplicate element IDs before loading the document.");
}
const editor = createPlateEditor({
plugins: [ElementIdPlugin],
initialValue: result.value,
});Plate also includes a built-in navigation feedback plugin for "you landed here" UX after TOC jumps, footnote navigation, search jumps, and custom outline movement.
This feature is enabled by default. You only need to touch the
navigationFeedback option when you want to change the flash duration or turn
the plugin off.
const editor = createPlateEditor({
navigationFeedback: {
duration: 1200,
},
});const editor = createPlateEditor({
navigationFeedback: {
duration: 1200,
},
});The NavigationFeedbackPlugin is part of the React editor defaults. Use
navigationFeedback for its editor-level configuration.
const editor = createPlateEditor({
navigationFeedback: false,
});const editor = createPlateEditor({
navigationFeedback: false,
});Control whether the editor should normalize its content on initialization:
const editor = createPlateEditor({
shouldNormalizeEditor: true,
});const editor = createPlateEditor({
shouldNormalizeEditor: true,
});Note that normalization may take a few dozen milliseconds for large documents, such as the playground value.
Configure the editor to automatically select a range:
const editor = createPlateEditor({
autoSelect: 'end', // or 'start', or true
});const editor = createPlateEditor({
autoSelect: 'end', // or 'start', or true
});This is not the same as auto-focus: you can select text without focusing the editor.
Override default components for plugins:
const editor = createPlateEditor({
plugins: [HeadingPlugin],
components: {
paragraph: CustomParagraphComponent,
heading: CustomHeadingComponent,
},
});const editor = createPlateEditor({
plugins: [HeadingPlugin],
components: {
paragraph: CustomParagraphComponent,
heading: CustomHeadingComponent,
},
});When your app imports the target, configure that descriptor directly:
const AppLinkPlugin = LinkPlugin.configure({
initialState: {
allowedSchemes: ['http', 'https'],
},
});
const editor = createPlateEditor({
plugins: [AppLinkPlugin],
});const AppLinkPlugin = LinkPlugin.configure({
initialState: {
allowedSchemes: ['http', 'https'],
},
});
const editor = createPlateEditor({
plugins: [AppLinkPlugin],
});A package plugin may need to adapt another installed package without importing it or controlling the consumer's editor kit:
import { PLUGINS } from 'platejs';
import { definePlatePlugin } from 'platejs/react';
const SingleBlockPlugin = definePlatePlugin(PLUGINS.singleBlock, {
override: {
plugins: {
[PLUGINS.trailingBlock]: {
enabled: false,
},
},
},
});import { PLUGINS } from 'platejs';
import { definePlatePlugin } from 'platejs/react';
const SingleBlockPlugin = definePlatePlugin(PLUGINS.singleBlock, {
override: {
plugins: {
[PLUGINS.trailingBlock]: {
enabled: false,
},
},
},
});The override applies only when the target is installed; a missing target is
ignored. It cannot change name, dependencies, or nest another override.
Direct target configuration wins.
A complete explicit descriptor can replace a lower-precedence core or dependency definition with the same name. Inside one plugin array, terminal configurations derived from the same authored plugin compose in order and later defined values win. Unrelated plugins and divergent authoring branches cannot share a name.
const AppParagraphPlugin = ParagraphPlugin.configure({
component: AppParagraphElement,
});
const editor = createPlateEditor({
plugins: [AppParagraphPlugin],
});const AppParagraphPlugin = ParagraphPlugin.configure({
component: AppParagraphElement,
});
const editor = createPlateEditor({
plugins: [AppParagraphPlugin],
});createPlateEditor derives the document value and plugin APIs from the installed
plugin tuple. initialValue is checked against that schema-derived value.
const AppKit = [TablePlugin, LinkPlugin] as const;
const editor = createPlateEditor({
plugins: AppKit,
});
// Usage
editor.update((tx) => {
tx.table.insertRow();
});const AppKit = [TablePlugin, LinkPlugin] as const;
const editor = createPlateEditor({
plugins: AppKit,
});
// Usage
editor.update((tx) => {
tx.table.insertRow();
});Use PlateEditor<typeof Kit> when another module needs the editor type and
ValueOf<MyEditor> when a named value type improves readability. Do not repeat
the schema as handwritten element interfaces.
import type { ValueOf } from 'platejs';
import type { PlateEditor } from 'platejs/react';
const AppKit = [TablePlugin, LinkPlugin] as const;
export type MyEditor = PlateEditor<typeof AppKit>;
export type MyValue = ValueOf<MyEditor>;
const value: MyValue = [{
type: 'paragraph',
children: [{ text: 'Hello, Plate!' }],
}];
const editor = createPlateEditor({
plugins: AppKit,
initialValue: value,
});import type { ValueOf } from 'platejs';
import type { PlateEditor } from 'platejs/react';
const AppKit = [TablePlugin, LinkPlugin] as const;
export type MyEditor = PlateEditor<typeof AppKit>;
export type MyValue = ValueOf<MyEditor>;
const value: MyValue = [{
type: 'paragraph',
children: [{ text: 'Hello, Plate!' }],
}];
const editor = createPlateEditor({
plugins: AppKit,
A component reads the current editor from context without repeating its plugin tuple:
const editor = useEditor();
const table = useEditorPlugin(TablePlugin);const editor = useEditor();
const table = useEditorPlugin(TablePlugin);Use useEditorPlugin(Plugin) when the component needs an exact plugin
capability. Generated application types stay at explicit static boundaries.