• L
  • O
  • A
  • D
  • I
  • N
  • G
AYO! IT'S PUVISH!

Integrate Mermaid Diagram Into Payload Lexical Editor

Updated on 2025-02-24T05:50:16.632Z

Written on 2025-02-21T08:41:10.072Z


mermaid_payload.png

Everybody loves Mermaid.js. If you don't know what it is then go check it out at: https://mermaid.js.org/. It's the easiest way (next to copy-paste an image file) to put diagrams of all kinds on your HTML page with the simplest syntax on this planet.

Below showcases what each type of diagram looks like:

Git Diagram

Loading diagram...


Mindmap (I just love Japanese food!)

Loading diagram...


Architecture (of this very website)

Loading diagram...


Enough with the showcases, let's see how it's done!

Step 1: Install Dependencies

First, create a new Payload project with npx create-payload-app or integrate Payload CMS into your existing Next.js application (check out details here). Once your Payload project is ready, install the Mermaid.js library with good old npm i mermaid. I won't go into details on how to create collections and add an editor. You can check them all out at Payload's official documentation.


Step 2: Create a Mermaid Client Component

To render Mermaid diagrams, you’ll need a dedicated client-side component. This component will parse Mermaid syntax and initialize the diagrams. Since Mermaid requires access to the DOM, this component must run only on the client side.

Mermaid will not re-render if the syntax has changed after the component is loaded. I've been struggling for real-time render for a while but thanks to this article, I finally was able to make it work.

NOTE: if you don't want the preview to consume too much resource on rendering, you can just add a refresh button and use onClick to remove the data-processed attribute and run mermaid.contentLoaded() instead.

This component will serve two purposes:

  1. Preview in the Lexical Editor: Let content creators see their diagrams in real-time.
  2. Live Website Rendering: Display the finalized diagrams to your audience. (If you use this component as-is, you might get the hydration error. I'll get to this fix later.)

Step 3: Create an Input Block for Lexical

Now you have got the diagram renderer but there's no way to input the data into it yet. Let's create an input component so that you can create/edit it in Lexical


Step 4: Configure a Custom Block in Lexical

Payload’s Lexical editor supports custom blocks, allowing you to add a dedicated “Mermaid Diagram” block to your rich text fields. Configure this block to store raw Mermaid syntax and reference your client component for previewing. Let's create a configuration that imports the input component that we just created so that you can later load it as a custom block for Lexical.

Then on the collection file, import the block above and put it in the blocks array under BlocksFeature.


Step 5: Handle Server-Client Hydration

When rendering whatever mutates on the client side in a Next.js, you’ll likely encounter the hydration error. This will happen to because Mermaid will only render the diagram after the component is loaded to the browser. To fix this you can either try any of these:

  1. Add suppressHydrationWarning={true} to the parent element that will render the diagram. In this case it should be the <div> element with mermaid class in mermaid-renderer.tsx or
  2. Create a dedicated dynamic component that wraps the Mermaid diagram's renderer.

For 2, you can just create another .tsx file with the sample code below. One thing to note when creating a dynamic component is that the path here must be absolute, and the target file to import the component must export it as default (see further).

This ensures the diagram is generated after the page loads, eliminating conflicts between serverrendered HTML and client-side DOM manipulation.


Step 6: Map the Mermaid Block to Your Component

In your frontend, configure the RichText element to recognize the custom Mermaid block. Create Payload’s jsxConverters property to link the block to your dynamic client component. Then pass the jsxConverters to the RichText component on your page. This tells frontend to replace the Mermaid block with your dynamically loaded component.


Boom! Diagrams Activated!


You’ve now empowered your Payload CMS to handle Mermaid.js diagrams end-to-end. Content editors can create and preview visuals effortlessly, while your website delivers interactive, client-rendered diagrams without hydration issues.

This setup isn’t just for blogs—use it for documentation, project roadmaps, or data dashboards. Mermaid’s flexibility combined with Payload’s headless architecture opens endless possibilities for dynamic content.

TIP: Explore Mermaid’s theming options to match your site’s design, or add interactivity with click events. The sky’s the limit!

Now go forth and diagram all the things. 🌊


no comments yet

Connect with me?

tsx
// ./components/payload/mermaid-renderer.tsx

"use client";

import React from "react";
import mermaid from "mermaid";
import { useEffect, useState, useRef } from "react";

interface Mermaid {
  data: string;
  
}

export default function Mermaid({ data }: Mermaid) {
  const diagramRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    mermaid.initialize({
      startOnLoad: true,
    });
  }, []);

  // Rerender when data changes
  useEffect(() => {
    // Remove this attribute to make it re-render
    diagramRef.current!.removeAttribute("data-processed");
    diagramRef.current!.innerHTML = data;
    mermaid.contentLoaded();
  }, [data]);

  return (
    <>
      <div
        className="mermaid"
        ref={diagramRef}
      />
    </>
  );
}
tsx
// ./components/payload/mermaid/index.tsx

"use client";

import type { TextareaFieldClient, TextareaFieldClientProps } from "payload";
import Mermaid from "@/components/payload/mermaid-renderer";
import { useFormFields, TextareaField } from "@payloadcms/ui";
import React, { useMemo } from "react";

export const MermaidBlockInput: React.FC<TextareaFieldClientProps> = ({
  field,
  path,
  permissions,
  readOnly,
  renderedBlocks,
  schemaPath,
  validate,
}) => {
  const mermaidCodeField = useFormFields(([fields]) => fields["code"]);

  const label = [];

  const props: TextareaFieldClient = useMemo<TextareaFieldClient>(
    () => ({
      ...field,
      type: "textarea",
      admin: {
        ...field.admin,
        label,
      },
    }),
    [field],
  );

  const key = `${field.name}`;

  return (
    <>
      // Input area
      <TextareaField
        field={props}
        forceRender={true}
        key={key}
        path={path}
        permissions={permissions}
        readOnly={readOnly}
        renderedBlocks={renderedBlocks}
        schemaPath={schemaPath}
        validate={validate}
      />

      // Preview area
      <div>
        <p>Preview</p>
        <Mermaid data={mermaidCodeField?.value as string} />
      </div>
    </>
  );
};
tsx
// @/components/payload/dynamic-mermaid-renderer

"use client";

import dynamic from "next/dynamic";

const Mermaid = dynamic(() => import("./mermaid-renderer"), {
  ssr: false,
  loading: () => <p>Loading diagram...</p>,
});

export const DynamicMermaid = ({ data }) => <Mermaid data={data} />;
tsx
import { DynamicMermaid } from "@/components/payload/mermaid/dynamic-mermaid-renderer";
import {
  type JSXConvertersFunction,
  RichText,
} from "@payloadcms/richtext-lexical/react";

const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
  ...defaultConverters,
  blocks: {
    Mermaid: ({ node }) => (
      <>
        <DynamicMermaid data={node.fields.code} />
      </>
    ),
  },
});

export default function Blog() {

  // get your richtext content here
  let richtextContent

  return (
    <>
      <RichText
        // attach the converter here
        converters={jsxConverters}
        data={richtextContent}
      />
    </>
  )
}
typescript
// ./lib/payload/mermaid-block.ts

import type { Block } from 'payload';

export const mermaidBlock: Block = {
    slug: 'Mermaid',
    fields: [
        {
            admin: {
                components: {
                    Field: '/components/payload/mermaid#MermaidBlockInput',
                },
            },
            name: 'code',
            type: 'textarea',
        },
    ],
}
typescript
import type { CollectionConfig } from 'payload'
import {
    lexicalEditor,
    BlocksFeature,
} from '@payloadcms/richtext-lexical'
import { mermaidBlock } from '@/lib/payload/blocks-feature/mermaid-block'

export const Blog: CollectionConfig = {
    slug: 'blog',
    access: {
        read: () => true,
    },
    fields: [
        {
            name: 'content',
            type: 'richText',
            required: true,
            editor: lexicalEditor({
                features: ({ defaultFeatures }) => [...defaultFeatures,
                BlocksFeature({
                    blocks: [mermaidBlock] // insert it here
                }),],
            }),
        }
    ],
}