---
title: "Rich Text — Custom Lexical Features"
aliases: [lexical-custom-features, custom-lexical-feature, payload-lexical-feature]
tags: [payloadcms, lexical, rich-text, custom-feature, nodes, toolbar, slash-menu]
sources: [raw/rich-text__custom-features.md]
created: 2026-05-15
updated: 2026-05-15
---

## Overview

Custom Lexical features are modular extensions to the Payload rich text editor. Each feature is split into exactly two files:

- `feature.server.ts` — server-side entry point (nodes, markdown transformers, i18n, hooks)
- `feature.client.ts` — client-side UI (toolbar, slash menu, plugins, React node components)

The server feature is the sole entry point; it registers the client feature via an import path.

> **IMPORTANT:** Never import directly from `lexical`, `@lexical/*`, or `@payloadcms/richtext-lexical` on the client side. Use the re-exported paths instead:
> - Server/shared: `@payloadcms/richtext-lexical/lexical/utils`
> - Client: `@payloadcms/richtext-lexical/client`

## Do You Need a Custom Feature?

Before building a custom feature, consider [[wiki/payloadcms/rich-text-blocks|BlocksFeature]] — it supports:
- **Block blocks** — full-line custom blocks with arbitrary React components
- **Inline blocks** — insertable within paragraphs

Only build a custom feature if BlocksFeature cannot fulfill the requirement (e.g. custom nodes with lexical commands, toolbar groups, complex markdown transforms).

## Server Feature

### Minimal scaffold

```ts
import { createServerFeature } from '@payloadcms/richtext-lexical'

export const MyFeature = createServerFeature({
  feature: {},
  key: 'myFeature',
})
```

Register in editor config:

```ts
editor: lexicalEditor({ features: [MyFeature()] })
```

### Server Feature properties

| Property | Description |
|----------|-------------|
| `i18n` | Translations scoped to the feature key (`lexical:<key>:<label>`) |
| `markdownTransformers` | `ElementTransformer`/`TextMatchTransformer` for markdown ↔ editor conversion |
| `nodes` | Nodes wrapped with `createNode()` — controls HTML converters, hooks, sub-fields |
| `ClientFeature` | Import path to the client feature file (`'./path/to/feature.client#ExportName'`) |
| `clientFeatureProps` | Serializable props passed to the client feature (must be JSON-serializable) |

### Node options (via `createNode`)

| Option | Description |
|--------|-------------|
| `node` | The Lexical node class (supports node replacements) |
| `converters.html` | How the node serializes to HTML |
| `getSubFields` / `getSubFieldsData` | Sub-field schema + data for auto-population and hooks (REST only) |
| `graphQLPopulationPromises` | Population logic for GraphQL (REST hooks don't run in GraphQL context) |
| `validations` | Custom node-level validation |
| `hooks` | Node Hooks (same lifecycle as field hooks) |

### Feature load order

```ts
createServerFeature({
  feature: ({ featureProviderMap, props, config, resolvedFeatures }) => {
    // featureProviderMap contains ALL features even yet-to-be-loaded
    return { /* ... */ }
  },
  key: 'myFeature',
  dependenciesPriority: ['otherFeature'],  // must load before this one
  dependencies: ['requiredFeature'],        // must exist, any order
  dependenciesSoft: ['optionalFeature'],    // best-effort ordering
})
```

## Client Feature

### Minimal scaffold

```ts
'use client'
import { createClientFeature } from '@payloadcms/richtext-lexical/client'

export const MyClientFeature = createClientFeature({})
```

Register from server feature:

```ts
feature: {
  ClientFeature: './path/to/feature.client#MyClientFeature',
}
```

### Custom Nodes (client side)

Add node to the `nodes` array in **both** client and server features.

Example `DecoratorNode`:

```ts
import { DecoratorNode, $applyNodeReplacement } from '@payloadcms/richtext-lexical/lexical'

export class MyNode extends DecoratorNode<React.ReactElement> {
  static getType() { return 'myNode' }
  static clone(node: MyNode) { return new MyNode(node.__key) }
  static importJSON(serialized): MyNode { return $createMyNode() }

  createDOM(): HTMLElement { return document.createElement('div') }
  decorate(): React.ReactElement { return <MyNodeComponent nodeKey={this.__key} /> }
  exportJSON() { return { type: 'myNode', version: 1 } }
  isInline(): false { return false }
  updateDOM(): boolean { return false }
}

export function $createMyNode(): MyNode {
  return $applyNodeReplacement(new MyNode())
}
export function $isMyNode(node): node is MyNode { return node instanceof MyNode }
```

- Do **not** add `'use client'` to node files — they are used on the server too
- Lazy-import React components inside the node via `React.lazy()`

### Plugins

React components inside all Lexical context providers. Used for:
- Registering **listeners**, **transforms**, and **commands**
- Opening drawers, running side effects

```ts
export const MyClientFeature = createClientFeature({
  plugins: [MyPlugin],
})
```

### Toolbar Groups

Two display types:

| Type | Display | Exported helper |
|------|---------|-----------------|
| `buttons` | Horizontal, icon only | `toolbarFormatGroupWithItems`, `toolbarFeatureButtonsGroupWithItems` |
| `dropdown` | Vertical, icon + label | `toolbarAddDropdownGroupWithItems`, `toolbarTextDropdownGroupWithItems` |

Groups with the same `key` have their items merged.

### Toolbar Items

| Prop | Description |
|------|-------------|
| `key` | Unique identifier |
| `ChildComponent` | Icon React component |
| `Component` | Fully replaces default button (ignores `ChildComponent`/`onSelect`) |
| `label` | Shown in dropdown groups; can be a function for i18n |
| `onSelect` | Called on click |
| `isActive` | Highlight state function |
| `isEnabled` | Grays out if `false` |

Add to `toolbarFixed` or `toolbarInline` (or both):

```ts
export const MyClientFeature = createClientFeature({
  toolbarFixed: {
    groups: [
      toolbarAddDropdownGroupWithItems([{
        ChildComponent: IconComponent,
        key: 'myNode',
        label: ({ i18n }) => i18n.t('lexical:myFeature:label'),
        onSelect: ({ editor }) => editor.dispatchCommand(INSERT_MYNODE_COMMAND, undefined),
        isActive: ({ selection }) => { /* ... */ },
      }]),
    ],
  },
})
```

### Slash Menu

Groups + items; groups with the same key are merged.

```ts
export const MyClientFeature = createClientFeature({
  slashMenu: {
    groups: [
      slashMenuBasicGroupWithItems([{
        Icon: IconComponent,
        key: 'myNode',
        keywords: ['myNode', 'myFeature'],
        label: ({ i18n }) => i18n.t('lexical:myFeature:label'),
        onSelect: ({ editor }) => editor.dispatchCommand(INSERT_MYNODE_COMMAND, undefined),
      }]),
    ],
  },
})
```

### Markdown Transformers (client)

Client-side transformers trigger when a pattern is **typed** into the editor (live preview). Server-side transformers are used for serialization/deserialization.

### Providers

Wrap editor with React context providers:

```ts
export const MyClientFeature = createClientFeature({
  providers: [TableContext],
})
```

## Props & Sanitization

```ts
// Server
createServerFeature<UnSanitizedProps, SanitizedProps, UnSanitizedClientProps>({
  feature: async ({ props }) => {
    const sanitizedProps = process(props)
    return { sanitizedServerFeatureProps: sanitizedProps }
  },
})

// Client
createClientFeature<UnSanitizedClientProps, SanitizedClientProps>(({ props }) => {
  return { sanitizedClientFeatureProps: process(props) }
})
```

Client props must be **JSON-serializable** (no functions, Maps, etc.) — they travel over the network from server to client.

## Key Takeaways

- **Two-file split** is mandatory: `feature.server.ts` registers everything, `feature.client.ts` handles UI
- **Never import from raw lexical packages** — use `@payloadcms/richtext-lexical/lexical/*` re-exports
- **Try `BlocksFeature` first** — it covers most custom component use cases without a full custom feature
- **`featureProviderMap`** is available even before features load — use it to check for optional deps
- **Client props must be serializable** — functions/Maps stay server-side only
- **Same key = merge** — toolbar groups and slash menu groups with identical keys have items merged
- **Node files must not have `'use client'`** — node classes run on both sides
- **`graphQLPopulationPromises`** required for sub-field population in GraphQL (REST hooks don't apply there)

## Related Articles

- [[wiki/payloadcms/rich-text|Rich Text (Lexical) Overview]]
- [[wiki/payloadcms/rich-text-blocks|Rich Text — Blocks (Lexical BlocksFeature)]]
- [[wiki/payloadcms/rich-text-converters|Rich Text — Converters]]
- [[wiki/payloadcms/rich-text-converting-html|Rich Text — Converting HTML]]
- [[wiki/payloadcms/rich-text-converting-markdown|Rich Text — Converting Markdown]]
- [[wiki/payloadcms/hooks|PayloadCMS — Hooks]]

## Sources

- `raw/rich-text__custom-features.md`
- [Payload Docs: Custom Features](https://payloadcms.com/docs/rich-text/custom-features)
- [Lexical Docs](https://lexical.dev/docs/intro)
