From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Feature Kits
  • Plugin
    • Plugin Methods
    • Plugin Shortcuts
    • Plugin Context
    • Plugin Components
    • Plugin Rules
    • Editing Behavior
    • Plugin Input Rules
  • Editor
    • Editor Methods
    • Controlled Value
  • Performance
  • Static Rendering
  • HTML
  • Markdown
  • Form
  • TypeScript
  • Debugging
  • Unit Testing
  • Browser
  • Troubleshooting

Plugin Configuration

PreviousNext

How to configure and customize Plate plugins.

Plate plugins own one capability: schema, behavior, state, services, reads, updates, rendering, or a deliberate combination of them. Define independent fields at creation and use terminal configuration only for app-owned values.

  • Getting Started: Components - Instructions for adding plugins to your editor
  • PlatePlugin API - The complete API reference for creating plugins

Basic Plugin Configuration

New Plugin

The most basic plugin configuration requires only a name:

Feature KitsPlugin Methods

On This Page

Basic Plugin ConfigurationNew PluginExisting PluginSchema PluginsElementsBlock ContentInline, Void, and Text PropertiesBehavioral PluginsPlugin RulesEvent HandlersInject PropsAdd APIs and Transaction CommandsNative Plite CapabilitiesAdvanced Plugin ConfigurationPlugin StoreDependenciesEnabled FlagOptional CapabilitiesPlugin OrderHTML Input HooksTyped PluginsUsing definePlatePluginUsing Typed PluginsSee also
Build your editor
Production-ready AI template and reusable components.
Get all-access
const MyPlugin = definePlatePlugin("minimal", {});
const MyPlugin = definePlatePlugin("minimal", {});

While this plugin doesn't do anything yet, it's a starting point for more complex configurations.

Existing Plugin

The .configure method allows you to configure an existing plugin:

const ConfiguredPlugin = MyPlugin.configure({
  initialState: {
    myOption: "new value",
  },
});
const ConfiguredPlugin = MyPlugin.configure({
  initialState: {
    myOption: "new value",
  },
});

Schema Plugins

Schema plugins declare persisted document identity and structure under schema. React components stay on the plugin root.

Elements

Declare a new element with schema.element:

import { schema } from "platejs";
 
const NoticePlugin = definePlatePlugin("noticeFeature", {
  schema: {
    element: { ...schema.element.textBlock(), type: "notice" },
  },
});
import { schema } from "platejs";
 
const NoticePlugin = definePlatePlugin("noticeFeature", {
  schema: {
    element: { ...schema.element.textBlock(), type: "notice" },
  },
});

You can associate a component with your element. See Plugin Components for more information.

import { schema } from "platejs";
 
const NoticePlugin = definePlatePlugin("noticeFeature", {
  component: NoticeElement,
  schema: {
    element: { ...schema.element.textBlock(), type: "notice" },
  },
});
import { schema } from "platejs";
 
const NoticePlugin = definePlatePlugin("noticeFeature", {
  component: NoticeElement,
  schema: {
    element: { ...schema.element.textBlock(), type: "notice" },
  },
});

Block Content

Plate treats each non-inline element as normal-flow block content unless its schema declares blockContent: false. Use that flag for structural internals, not for blocks that should remain selectable.

const RowPlugin = definePlatePlugin("row", {
  schema: {
    element: {
      ...schema.element.textBlock(),
      blockContent: false,
    },
  },
});
const RowPlugin = definePlatePlugin("row", {
  schema: {
    element: {
      ...schema.element.textBlock(),
      blockContent: false,
    },
  },
});

Read the compiled result through the Plate schema API:

editor.read.schema.isBlockContent(element);
editor.read.schema.isBlockContent(element);

This classification is independent of editor.read.nodes.isSelectable(element). Content containers use plugins.blockContent(...) when they declare which normal-flow blocks they accept.

Inline, Void, and Text Properties

Element behavior lives inside schema.element. Declare a boolean text property with a property descriptor under schema.mark:

import { property, schema } from "platejs";
 
const CustomLinkPlugin = definePlatePlugin("customLink", {
  schema: {
    element: {
      content: schema.content.text({ default: "text", min: 1 }),
      inline: true,
    },
  },
});
 
const CustomImagePlugin = definePlatePlugin("customImage", {
  schema: { element: { void: "block" } },
});
 
const CustomBoldPlugin = definePlatePlugin("customBold", {
  schema: {
    mark: property.boolean({ default: false, omitDefault: true }),
  },
});
import { property, schema } from "platejs";
 
const CustomLinkPlugin = definePlatePlugin("customLink", {
  schema: {
    element: {
      content: schema.content.text({ default: "text", min: 1 }),
      inline: true,
    },
  },
});
 
const CustomImagePlugin = definePlatePlugin("customImage", {
  schema: { element: { void: "block" } },
});
 
const CustomBoldPlugin = definePlatePlugin("customBold", {
  schema: {
    mark: property.boolean({ default: false

Behavioral Plugins

Rather than declare an element or text property, you may want to customize the behavior of your editor. Plugin fields describe that behavior.

Plugin Rules

The rules property allows you to configure common editing behaviors like breaking, deleting, and merging nodes without overriding editor methods. This is a powerful way to define intuitive interactions for your custom elements.

For example, you can define what happens when a user presses Enter in an empty heading, or Backspace at the start of a blockquote.

import { HeadingPlugin } from "@platejs/basic-nodes/react";
 
HeadingPlugin.configure({
  rules: {
    break: { empty: "reset" },
  },
});
import { HeadingPlugin } from "@platejs/basic-nodes/react";
 
HeadingPlugin.configure({
  rules: {
    break: { empty: "reset" },
  },
});

See the Plugin Rules guide for a complete list of available rules and actions.

Event Handlers

The on field owns both editor lifecycle and React DOM events. A DOM handler receives a PlatePluginContext & { event } object.

Child names do not repeat the on prefix. Lifecycle handlers use names such as commit, nodeChange, and textChange; DOM handlers use keyDown, paste, and click.

const ExamplePlugin = definePlatePlugin("example", {
  on: {
    commit: ({ editor, snapshot }) => {
      console.info(editor, snapshot.children);
    },
    keyDown: ({ editor, event }) => {
      console.info(`You pressed ${event.key}`);
    },
  },
});
const ExamplePlugin = definePlatePlugin("example", {
  on: {
    commit: ({ editor, snapshot }) => {
      console.info(editor, snapshot.children);
    },
    keyDown: ({ editor, event }) => {
      console.info(`You pressed ${event.key}`);
    },
  },
});

Inject Props

You may want to inject a class name or CSS property into any node having a certain property. For example, the following plugin sets the textAlign CSS property on paragraphs with a textAlign property.

import { PLUGINS, property, schema, target } from "platejs";
 
const TextAlignPlugin = definePlatePlugin("textAlign", {
  codecs: ({ defineCodecs }) =>
    defineCodecs({
      "text/html": {
        decode: ({ element }) => element.style.textAlign || undefined,
        encode: ({ value }) => ({ style: { textAlign: value } }),
        match: [
          {
            style: {
              textAlign: ["start", "left", "center", "right", "end", "justify"],
            },
          },
        ],
      },
    }),
  inject: {
    isBlock: true,
    nodeProps: {
      defaultNodeValue: "start",
      styleKey: "textAlign",
      validNodeValues: ["start", "left", "center", "right", "end", "justify"],
    },
  },
  schema: ({ targetElementTypes }) => ({
    properties: {
      textAlign: schema.elementProperty(property.string(), {
        target: target.types(targetElementTypes),
        typeChange: "preserve-if-allowed",
      }),
    },
  }),
  targetPlugins: [PLUGINS.paragraph],
  update: ({ tx }) => ({
    set: (value: string) => tx.nodes.set({ textAlign: value }),
  }),
});
import { PLUGINS, property, schema, target } from "platejs";
 
const TextAlignPlugin = definePlatePlugin("textAlign", {
  codecs: ({ defineCodecs }) =>
    defineCodecs({
      "text/html": {
        decode: ({ element }) => element.style.textAlign || undefined,
        encode: ({ value }) => ({ style: { textAlign: value } }),
        match: [
          {
            style: {
              textAlign: ["start", "left", "center", "right", "end", "justify"],
            },
          },























inject.nodeProps owns rendering, while the constructor's codecs callback binds the bidirectional HTML claim to this plugin's schema. defineCodecs keeps value inferred from the property declaration. Both paths use the author callback's compiled schema.properties.textAlign handle. Consumers use typed node fields or the plugin's semantic capabilities instead of reading that property map. Its name identifies only the capability namespace. .configure() and .extend() do not change schema identity. The schema passed to createPlateEditor or usePlateEditor may remap an element type or relationship; plugin-owned property keys remain fixed. A plugin update passes one atomic property patch, so every key and value comes from the plugin's shallow schema contract. An alias uses the exact author handle key as a computed object key.

A paragraph node affected by the plugin looks like this:

const paragraph = {
  type: 'paragraph',
  textAlign: 'right',
  children: [{ text: 'This paragraph is aligned to the right!' }],
};
const paragraph = {
  type: 'paragraph',
  textAlign: 'right',
  children: [{ text: 'This paragraph is aligned to the right!' }],
};

Add APIs and Transaction Commands

Put state-bound queries under read, immutable services under api, and document mutations under update. Constructor callbacks receive plugin context.

export type CustomPluginState = {
  prefix: string;
};
 
const CustomPlugin = definePlatePlugin("custom", {
  api: ({ store }) => ({
    getPrefix: () => store.get("prefix"),
  }),
  initialState: {
    prefix: "Note: ",
  } satisfies CustomPluginState,
  update: ({ store, tx }) => ({
    insertPrefix: () => {
      tx.text.insert(store.get("prefix"));
    },
  }),
});
export type CustomPluginState = {
  prefix: string;
};
 
const CustomPlugin = definePlatePlugin("custom", {
  api: ({ store }) => ({
    getPrefix: () => store.get("prefix"),
  }),
  initialState: {
    prefix: "Note: ",
  } satisfies CustomPluginState,
  update: ({ store, tx }) => ({
    insertPrefix: () => {
      tx.text.insert(store.get("prefix"


After the plugin resolves, concrete editors infer its services on the root API and one-shot writes use the name-scoped update helper:

editor.api.custom.getPrefix();
editor.update.custom.insertPrefix();
editor.api.custom.getPrefix();
editor.update.custom.insertPrefix();

Generic package code can reach the same immutable API object through editor.plugin(CustomPlugin).api.getPrefix().

Native Plite Capabilities

Plate plugins declare Plite capabilities directly at the plugin root. Use readMiddleware, commands, corrections, stateFields, effectTypes, facetProviders, contributions, on, activate, and validate without a second wrapper.

import { defineStateField } from "platejs";
import { definePlatePlugin } from "platejs/react";
 
const enabledField = defineStateField({
  initial: false,
  key: "customState.enabled",
});
 
const CustomStatePlugin = definePlatePlugin("customState", {
  stateFields: [enabledField],
  read: ({ state }) => ({
    enabled: () => state.getField(enabledField),
  }),
  on: {
    commit: ({ commit }) => {
      console.info(commit.changed);
    },
  },
});
import { defineStateField } from "platejs";
import { definePlatePlugin } from "platejs/react";
 
const enabledField = defineStateField({
  initial: false,
  key: "customState.enabled",
});
 
const CustomStatePlugin = definePlatePlugin("customState", {
  stateFields: [enabledField],
  read: ({ state }) => ({
    enabled: () => state.getField(enabledField),
  }),
  on: {
    commit: ({ commit }) => {
      console.info(commit.changed);


Extensions, APIs, and commands

Put every independent capability in the constructor. Use .extend() only for an imported/prebuilt descriptor, a shared factory the constructor cannot access, or an earlier-stage type.

An independently reusable standalone Plite descriptor uses defineExtension from @platejs/plite. See Plugin Methods.

Advanced Plugin Configuration

Plugin Store

Each plugin has its own store, which can be used to manage plugin-specific state.

type MyPluginState = {
  count: number;
};
 
const MyPlugin = definePlatePlugin("myPlugin", {
  initialState: {
    count: 0,
  } satisfies MyPluginState,
  on: {
    click: ({ store }) => {
      store.set({ count: 1 });
    },
  },
});
type MyPluginState = {
  count: number;
};
 
const MyPlugin = definePlatePlugin("myPlugin", {
  initialState: {
    count: 0,
  } satisfies MyPluginState,
  on: {
    click: ({ store }) => {
      store.set({ count: 1 });
    },
  },
});

You can access and update the store using the following methods:

// Get the current value
const count = editor.plugin(MyPlugin).store.get("count");
 
// Set a new value
editor.plugin(MyPlugin).store.set({ count: 5 });
 
// Update the value based on the previous state
editor.plugin(MyPlugin).store.set((state) => {
  state.count += 1;
});
// Get the current value
const count = editor.plugin(MyPlugin).store.get("count");
 
// Set a new value
editor.plugin(MyPlugin).store.set({ count: 5 });
 
// Update the value based on the previous state
editor.plugin(MyPlugin).store.set((state) => {
  state.count += 1;
});

In React components, use usePluginStore to subscribe to store changes:

const MyComponent = () => {
  const count = usePluginStore(MyPlugin, "count");
  return <div>Count: {count}</div>;
};
const MyComponent = () => {
  const count = usePluginStore(MyPlugin, "count");
  return <div>Count: {count}</div>;
};

See more in Plugin Context and Editor Methods guides.

Dependencies

Declare required plugins with their plugin objects. Plate installs the dependency graph recursively, deduplicates plugins by name, and loads dependencies before their dependents.

const MyPlugin = definePlatePlugin("myPlugin", {
  dependencies: [ParagraphPlugin, ListPlugin],
});
const MyPlugin = definePlatePlugin("myPlugin", {
  dependencies: [ParagraphPlugin, ListPlugin],
});

Enabled Flag

The enabled property allows you to conditionally enable or disable a plugin:

const MyPlugin = definePlatePlugin("myPlugin", {
  enabled: true, // or false to disable
});
const MyPlugin = definePlatePlugin("myPlugin", {
  enabled: true, // or false to disable
});

Optional Capabilities

Keep optional capabilities as ordinary plugins in the consumer's plugin array. When an enhancement needs a host, the enhancement depends on the host:

const CodeHighlightPlugin = definePlatePlugin("codeHighlight", {
  dependencies: [CodeBlockPlugin],
});
 
const plugins = [CodeBlockPlugin, CodeHighlightPlugin];
const CodeHighlightPlugin = definePlatePlugin("codeHighlight", {
  dependencies: [CodeBlockPlugin],
});
 
const plugins = [CodeBlockPlugin, CodeHighlightPlugin];

Omit CodeHighlightPlugin for plain code blocks. The host does not install optional enhancements.

Plugin Order

Dependencies load before their dependents. Independent plugins keep the order provided by the application:

const plugins = [LinkPlugin, MentionPlugin, CommentPlugin];
const plugins = [LinkPlugin, MentionPlugin, CommentPlugin];

Use dependencies for a real installation requirement. Competing shortcuts, input rules, and codecs own their local priority; plugin registration has no global priority.

HTML Input Hooks

Use hooks on the plugin's 'text/html' codec to decide whether it participates in an HTML input, transform the source string, or transform the decoded fragment.

const HtmlCleanupPlugin = definePlatePlugin("htmlCleanup", {
  codecs: ({ defineCodecs }) =>
    defineCodecs({
      "text/html": {
        query: ({ data, source }) =>
          source.types.includes("text/html") &&
          data.includes("<!--StartFragment-->"),
        transformData: ({ data }) =>
          data.replaceAll(/<!--(?:Start|End)Fragment-->/g, ""),
      },
    }),
});
const HtmlCleanupPlugin = definePlatePlugin("htmlCleanup", {
  codecs: ({ defineCodecs }) =>
    defineCodecs({
      "text/html": {
        query: ({ data, source }) =>
          source.types.includes("text/html") &&
          data.includes("<!--StartFragment-->"),
        transformData: ({ data }) =>
          data.replaceAll(/<!--(?:Start|End)Fragment-->/g, ""),
      },
    }),
});

query, transformData, and transformFragment are siblings on the 'text/html' codec. Node-level HTML conversion remains separate from these whole-input hooks.

Typed Plugins

definePlatePlugin infers one exact definition from the author object. Keep state contracts explicit with a typed initialState value or satisfies; do not pass caller generics to the factory.

Using definePlatePlugin

The inferred definition carries the plugin name, state, API, reads, and updates:

import { property, schema } from "platejs";
import { definePlatePlugin } from "platejs/react";
 
export type SnippetPluginState = {
  language: string;
  syntax: boolean;
  syntaxPopularFirst: boolean;
};
 
export const SnippetPlugin = definePlatePlugin("snippet", {
  api: ({ store }) => ({
    getLanguage: () => store.get("language"),
    getSyntaxState: () => store.get("syntax"),
  }),
  initialState: {
    language: "typescript",
    syntax: true,
    syntaxPopularFirst: false,
  } satisfies SnippetPluginState,
  schema: {
    element: schema.element.textBlock({
      properties: { language: property.string() },
    }),
  },
  update: ({ schema: { type }, store, tx }) => ({
    insertCurrentLanguage: () => {
      tx.nodes.insert({
        type,
        language: store.get("language"),
        children: [{ text: "" }],
      });
    },
  }),
});
import { property, schema } from "platejs";
import { definePlatePlugin } from "platejs/react";
 
export type SnippetPluginState = {
  language: string;
  syntax: boolean;
  syntaxPopularFirst: boolean;
};
 
export const SnippetPlugin = definePlatePlugin("snippet", {
  api: ({ store }) => ({
    getLanguage: () => store.get("language"),
    getSyntaxState: () => store.get("syntax"),




















Using Typed Plugins

When using typed plugins, you get full type checking and autocompletion ✨

const editor = createPlateEditor({
  plugins: [SnippetPlugin],
});
 
// Type-safe access to state
const state = editor.plugin(SnippetPlugin).store.get();
state.language;
state.syntax;
state.syntaxPopularFirst;
 
// Type-safe API
editor.api.snippet.getSyntaxState();
editor.api.snippet.getLanguage();
 
// Type-safe updates
editor.update.snippet.insertCurrentLanguage();
const editor = createPlateEditor({
  plugins: [SnippetPlugin],
});
 
// Type-safe access to state
const state = editor.plugin(SnippetPlugin).store.get();
state.language;
state.syntax;
state.syntaxPopularFirst;
 
// Type-safe API
editor.api.snippet.getSyntaxState();
editor.api.snippet.getLanguage();
 
// Type-safe updates
editor.update.snippet.insertCurrentLanguage();

See also

See the PlatePlugin API for every plugin field.

, omitDefault:
true
}),
},
});
],
},
}),
inject: {
isBlock: true,
nodeProps: {
defaultNodeValue: "start",
styleKey: "textAlign",
validNodeValues: ["start", "left", "center", "right", "end", "justify"],
},
},
schema: ({ targetElementTypes }) => ({
properties: {
textAlign: schema.elementProperty(property.string(), {
target: target.types(targetElementTypes),
typeChange: "preserve-if-allowed",
}),
},
}),
targetPlugins: [PLUGINS.paragraph],
update: ({ tx }) => ({
set: (value: string) => tx.nodes.set({ textAlign: value }),
}),
});
));
},
}),
});
},
},
});
}),
initialState: {
language: "typescript",
syntax: true,
syntaxPopularFirst: false,
} satisfies SnippetPluginState,
schema: {
element: schema.element.textBlock({
properties: { language: property.string() },
}),
},
update: ({ schema: { type }, store, tx }) => ({
insertCurrentLanguage: () => {
tx.nodes.insert({
type,
language: store.get("language"),
children: [{ text: "" }],
});
},
}),
});