Lexical
Adds the lexical WYSIWYG editor as a field type.
strapi-plugin-lexical
Integrates the Lexical WYSIWYG editor as a custom field in Strapi. Basically a port of Lexical playground into strapi environment with some nice extras.
Alpha Software
This plugin is in active development. Contributions in the form of bug reports, feature suggestions, and pull requests are highly encouraged!Table of contents
Installation
Install the plugin:
npm install strapi-plugin-lexical
Enable the plugin:
1// ./config/plugins.js 2{ 3 lexical: { 4 enabled: true, 5 }, 6 7};
Include the required CSS and Prism.js in your Strapi admin:
1// ./src/admin/app.js 2import "strapi-plugin-lexical/dist/style.css"; 3import "prismjs";
Add Vite support for Prism.js:
Install the plugin:
npm install --save-dev vite-plugin-prismjs
Update your Vite configuration:
1// ./src/admin/vite.config.js 2import { mergeConfig } from "vite"; 3import prismjs from "vite-plugin-prismjs"; 4 5export default (config) => 6 mergeConfig(config, { 7 plugins: [ 8 prismjs({ 9 languages: "all", // Load all languages or customize as needed 10 }), 11 ], 12 });
Note: Prism.js is required even if you don't plan to support code blocks. If you find a workaround to avoid this, please share it with us via a pull request or issue. We happily skip this installation step if we can!
Usage
- A new Lexical custom field type will be available in the Strapi content-type builder.
- Currently, it supports features migrated from the Lexical playground.
- For rendering content on your frontend, consider using libraries like payload-lexical-react-renderer or similar tools.
Handling Media and Internal Links
This plugin ensures reliable rendering of images and internal links by maintaining relationships between rich text content and referenced entities. By using a regular media field and automatically generated or your own link components we can ensure that all referenced media and internal links are readily available for your frontend, always reflecting the latest data.
Important: This is readme section is WIP. We will update this soon and give better examples on how to query and render images and links
Opt-in Mechanism
To enable this feature, you have to create secondary fields:
- With the suffix
Media
(e.G.YourFieldNameMedia
): This field must be a multiple media field with editing disabled. - With the suffix
Links
(e.G.YourFieldNameLinks
): This must be a component, either use our pregeneratedLinks
component or build your own. Important: It should only contain relation fields and the field name must match the linked collection name.
Integration in Lexical documents
Media is stored as a custom Lexical nodes, while store relations to strapi content with a custom URL format for links. These are automatically parsed and extracted into the fields you created above.
Media References
- Images are stored as
strapi-image
node in Lexical. - Other file types are planned but not yet supported.
- The structure is rather simple, as you can see:
strapi-image Lexical Node Data Structure:
1{ "documentId": "id_of_media_asset" }
Internal Links
- Internal links are stored using the regular
link
node in Lexical. The URL follows the format:
strapi://collectionName/documentId
This ensures that even if a page’s slug changes, links remain valid.
Rendering Strapi media and links
There are two options:
Fetch while rendering
@todo
Benefit: Less code, multiple API calls while rendering
- adjust the rendering functions of each lexical node
- actually.. can it even be asnyc? double check... this is the
not recommended way
anyways...
Example: Fetching the Latest API Data for a link
1const [collectionName, documentId] = linkNode.url.replace("strapi://", "").split("/");
2const articles = client.collection(collectionName);
3const singleArticle = await articles.findOne(documentId);
4// your link generation logic here ...
5return `/${singleArticle.locale}/blog/${singleArticle.slug}`
Prefetch and inject for rendering
@todo
To render media and links, we have to query the data from our media field and fields within the link component, then inject the data into our rendering process.
Benefit: only one API call, more control
- fetch fields
- iterate through the lexical document
- inject the document from the strapi api response into the lexical node for later rendering
- the data is now available when rendering the lexical node in your renderer
Example Renderer with NextJS:
1// LexicalRenderer.tsx
2import Image from "next/image";
3import Link from "next/link";
4
5import type { Media_Plain } from "@strapi/common/schemas-to-ts/Media";
6import { Links_Plain } from "@strapi/components/links/interfaces/Links";
7
8import React from "react";
9
10import clsx from "clsx/lite";
11
12import { createPath } from "@/utils/paths";
13
14import type {
15 AbstractNode,
16 ElementRenderer,
17 ElementRenderers,
18 Node,
19 PayloadLexicalReactRendererContent,
20} from "lexical-renderer-atelier-disko";
21import {
22 defaultElementRenderers,
23 PayloadLexicalReactRenderer,
24} from "lexical-renderer-atelier-disko";
25
26type StrapiImageNode = {
27 documentId: string;
28 entity: Media_Plain;
29} & AbstractNode<"strapi-image">;
30
31type NodeAll = Node | StrapiImageNode;
32
33const elementRenderers: ElementRenderers & {
34 "strapi-image": ElementRenderer<StrapiImageNode>;
35} = {
36 ...defaultElementRenderers,
37 // Define your custom lexical nodes here
38 link: (element, children, parent, className) => (
39 <Link
40 href={element.url}
41 className={className}
42 target={element.newTab ? "_blank" : "_self"}
43 >
44 {children}
45 </Link>
46 ),
47 "strapi-image": (element, children, parent, className) => (
48 <Image
49 className={clsx("mx-auto", className)}
50 src={`${process.env.NEXT_PUBLIC_IMAGE_BASE_URL}${element.entity.url}`}
51 alt={element.entity.alternativeText}
52 width={Math.floor(element.entity.width / 2)}
53 height={Math.floor(element.entity.height / 2)}
54 sizes={`(max-width: 768px) 100vw, ${Math.floor(
55 (element.entity.width / 2) * 1.25
56 )}px`}
57 loading="lazy"
58 />
59 ),
60};
61
62export function LexicalRenderer({
63 children,
64 classNames,
65 media,
66 links,
67}: {
68 children: PayloadLexicalReactRendererContent;
69 classNames?: { [key: string]: string };
70 media?: Media_Plain[];
71 links?: Links_Plain;
72}) {
73 // Inject media and links into our lexical document
74 const injectedDocument = React.useMemo(() => {
75 if (!children) {
76 return null;
77 }
78
79 if (media || links) {
80 const injectStrapiEntities = (nodes: NodeAll[]) => {
81 for (const node of nodes) {
82 // Media (Images only for now)
83 if (node.type === "strapi-image" && media?.length) {
84 const foundMedia = media.find(
85 // @ts-expect-error documentId is there. the ts schema plugin is just outdated :(
86 ({ documentId }) => documentId === node.documentId
87 );
88 if (foundMedia) {
89 node.entity = foundMedia;
90 }
91 }
92
93 // Links
94 if (
95 node.type === "link" &&
96 links &&
97 node.url.indexOf("strapi://") === 0
98 ) {
99 // Extract info from strapi link
100 const [collectionName, linkDocumentId] = (node.url as string)
101 .replace("strapi://", "")
102 .split("/") as [keyof Links_Plain, string];
103 if (links[collectionName]) {
104 // Find linked document
105 const foundCollectionDocument = links[collectionName].find(
106 ({ documentId }) => documentId === linkDocumentId
107 );
108 if (foundCollectionDocument) {
109 // Generate page link with helper function
110 node.url = createPath(
111 collectionName,
112 foundCollectionDocument.locale,
113 foundCollectionDocument.slug
114 );
115 }
116 }
117 }
118 if (node.type !== "strapi-image" && node.children) {
119 injectStrapiEntities(node.children);
120 }
121 }
122 };
123
124 injectStrapiEntities(children.root.children);
125 }
126
127 return children;
128 }, [children, media, links]);
129
130 if (!children || !injectedDocument) {
131 return null;
132 }
133
134 return (
135 <PayloadLexicalReactRenderer
136 content={injectedDocument}
137 classNames={classNames}
138 elementRenderers={elementRenderers}
139 />
140 );
141}
142
143export const lexicalToPlaintext = (json: { root: Node }) => {
144 const traverse = (node: Node): string => {
145 if (node.type === "text" && node.text) return node.text;
146 if (node.children) return node.children.map(traverse).join(" ");
147 return "";
148 };
149 return traverse(json.root);
150};
Roadmap
v0 - Alpha
- Implement basic functionality.
- Port features from the Lexical playground as the initial foundation.
- Integrate Strapi Media Assets and enable linking to Strapi Collection Entries
- Create field presets:
- Simple, Complex, and Full (selectable during field setup).
- Gather community feedback.
- Look for a potential co-maintainer.
v1 - Stable
- Introduce plugin-based architecture:
- Allow users to extend functionality with their own plugins.
- Enable configuration of presets via plugin settings.
- Open to community ideas! Submit an issue.
Contributing
We welcome contributions! Here’s how you can help:
- Report bugs or suggest features via the issue tracker.
- Submit pull requests to improve functionality or documentation.
- Share your feedback and ideas to shape the plugin’s future.
Resources
- Lexical Documentation
- Lexical Playground
- Payload Lexical React Renderer
- Strapi Plugin Development Guide
🛠️ Sponsored by hashbite.net | support & custom development available
We welcome everyone to post issues, fork the project, and contribute via pull requests. Together we can make this a better tool for all of us!
If the contribution process feels too slow or complex for your needs, hashbite.net can quickly implement features, fix bugs, or develop custom variations of this plugin on a paid basis. Just reach out through their website for direct support.
Install now
npm install strapi-plugin-lexical
Create your own plugin
Check out the available plugin resources that will help you to develop your plugin or provider and get it listed on the marketplace.