
Adds the lexical WYSIWYG editor as a field type.
released April 23, 2025
npm install strapi-plugin-lexicalIntegrates 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!
Install the plugin:
npm install strapi-plugin-lexicalEnable the plugin:
// ./config/plugins.js
{
lexical: {
enabled: true,
},}; ```
Include the required CSS and Prism.js in your Strapi admin:
// ./src/admin/app.js
import "strapi-plugin-lexical/dist/style.css";
import "prismjs";Add Vite support for Prism.js:
Install the plugin:
npm install --save-dev vite-plugin-prismjsUpdate your Vite configuration:
// ./src/admin/vite.config.js
import { mergeConfig } from "vite";
import prismjs from "vite-plugin-prismjs";
export default (config) =>
mergeConfig(config, {
plugins: [
prismjs({
languages: "all", // Load all languages or customize as needed
}),
],
});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!
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
To enable this feature, you have to create secondary fields:
Media (e.G. YourFieldNameMedia): This field must be a multiple media field with editing disabled.Links (e.G. YourFieldNameLinks): This must be a component, either use our pregenerated Links component or build your own. Important: It should only contain relation fields and the field name must match the linked collection name.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.
strapi-image node in Lexical.strapi-image Lexical Node Data Structure:
{ "documentId": "id_of_media_asset" }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.
There are two options:
@todo
Benefit: Less code, multiple API calls while rendering
not recommended way anyways...Example: Fetching the Latest API Data for a link
const [collectionName, documentId] = linkNode.url.replace("strapi://", "").split("/");
const articles = client.collection(collectionName);
const singleArticle = await articles.findOne(documentId);
// your link generation logic here ...
return `/${singleArticle.locale}/blog/${singleArticle.slug}`@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
Example Renderer with NextJS:
// LexicalRenderer.tsx
import Image from "next/image";
import Link from "next/link";
import type { Media_Plain } from "@strapi/common/schemas-to-ts/Media";
import { Links_Plain } from "@strapi/components/links/interfaces/Links";
import React from "react";
import clsx from "clsx/lite";
import { createPath } from "@/utils/paths";
import type {
AbstractNode,
ElementRenderer,
ElementRenderers,
Node,
PayloadLexicalReactRendererContent,
} from "lexical-renderer-atelier-disko";
import {
defaultElementRenderers,
PayloadLexicalReactRenderer,
} from "lexical-renderer-atelier-disko";
type StrapiImageNode = {
documentId: string;
entity: Media_Plain;
} & AbstractNode<"strapi-image">;
type NodeAll = Node | StrapiImageNode;
const elementRenderers: ElementRenderers & {
"strapi-image": ElementRenderer<StrapiImageNode>;
} = {
...defaultElementRenderers,
// Define your custom lexical nodes here
link: (element, children, parent, className) => (
<Link
href={element.url}
className={className}
target={element.newTab ? "_blank" : "_self"}
>
{children}
</Link>
),
"strapi-image": (element, children, parent, className) => (
<Image
className={clsx("mx-auto", className)}
src={`${process.env.NEXT_PUBLIC_IMAGE_BASE_URL}${element.entity.url}`}
alt={element.entity.alternativeText}
width={Math.floor(element.entity.width / 2)}
height={Math.floor(element.entity.height / 2)}
sizes={`(max-width: 768px) 100vw, ${Math.floor(
(element.entity.width / 2) * 1.25
)}px`}
loading="lazy"
/>
),
};
export function LexicalRenderer({
children,
classNames,
media,
links,
}: {
children: PayloadLexicalReactRendererContent;
classNames?: { [key: string]: string };
media?: Media_Plain[];
links?: Links_Plain;
}) {
// Inject media and links into our lexical document
const injectedDocument = React.useMemo(() => {
if (!children) {
return null;
}
if (media || links) {
const injectStrapiEntities = (nodes: NodeAll[]) => {
for (const node of nodes) {
// Media (Images only for now)
if (node.type === "strapi-image" && media?.length) {
const foundMedia = media.find(
// @ts-expect-error documentId is there. the ts schema plugin is just outdated :(
({ documentId }) => documentId === node.documentId
);
if (foundMedia) {
node.entity = foundMedia;
}
}
// Links
if (
node.type === "link" &&
links &&
node.url.indexOf("strapi://") === 0
) {
// Extract info from strapi link
const [collectionName, linkDocumentId] = (node.url as string)
.replace("strapi://", "")
.split("/") as [keyof Links_Plain, string];
if (links[collectionName]) {
// Find linked document
const foundCollectionDocument = links[collectionName].find(
({ documentId }) => documentId === linkDocumentId
);
if (foundCollectionDocument) {
// Generate page link with helper function
node.url = createPath(
collectionName,
foundCollectionDocument.locale,
foundCollectionDocument.slug
);
}
}
}
if (node.type !== "strapi-image" && node.children) {
injectStrapiEntities(node.children);
}
}
};
injectStrapiEntities(children.root.children);
}
return children;
}, [children, media, links]);
if (!children || !injectedDocument) {
return null;
}
return (
<PayloadLexicalReactRenderer
content={injectedDocument}
classNames={classNames}
elementRenderers={elementRenderers}
/>
);
}
export const lexicalToPlaintext = (json: { root: Node }) => {
const traverse = (node: Node): string => {
if (node.type === "text" && node.text) return node.text;
if (node.children) return node.children.map(traverse).join(" ");
return "";
};
return traverse(json.root);
};We welcome contributions! Here’s how you can help:
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.
Share your work with the community and get it listed in the Strapi ecosystem for everyone to discover and use.
Submit
