Back to blog

Using Excalidraw in PayloadCMS

Avatar

veiag.dev

Mon Mar 17 2025
Using Excalidraw in PayloadCMS
Next.jsPayloadWeb

Introduction

Recently, I explored integrating Excalidraw — a powerful open-source whiteboard tool — within the PayloadCMS Lexical editor. Fortunately, the official Lexical Playground site already includes Excalidraw as a built-in feature. Inspired by this, I tried to replicate this functionality using Payload's block system to seamlessly embed and edit Excalidraw drawings like this:

StartDecision?Do somethingDo something elseEndYesNoAnimal+String name+eat()+sleep()Dog+bark()UserUserBrowserBrowserBackendBackendEnter credentialsSend login requestValidate & returntokenLogin successful

In this post, I'll explain how I implemented the Excalidraw block in PayloadCMS, which allows me to create, save, and edit drawings directly from the admin panel.

Implementing the Excalidraw Block

1. Defining the Block Schema

The first step is to define the ExcalidrawBlock in Payload. This block includes:

  • code (JSON) – Stores Excalidraw elements.
  • svg (Textarea) – Stores the SVG representation of the drawing.
  • height (Select) – Controls the height of the drawing (Additional field to control SVG sizing on the front end ).
1import { Block } from 'payload'
2
3export const ExcalidrawBlock: Block = {
4  slug: 'excalidraw',
5  fields: [
6    {
7      name: 'code',
8      type: 'json',
9      label: 'Code',
10    },
11    {
12      name: 'svg',
13      type: 'textarea',
14      maxLength: 999999,
15    },
16    {
17      // additional field, to control image sizing 
18      type: 'select',
19      name: 'height',
20      label: 'Height',
21      options: [
22        {
23          label: 'Default',
24          value: 'default',
25        },
26        {
27          label: 'Square',
28          value: 'square',
29        },
30        {
31          label: 'Unrestricted',
32          value: 'unrestricted',
33        },
34      ],
35      defaultValue: 'default',
36    },
37  ],
38}
39

2. Building the Excalidraw Field Component

The ExcalidrawBlock component handles rendering the drawing, opening the Excalidraw editor in a modal imported from @payload/ui, and saving both the SVG and JSON data.

1//Excalidraw.tsx
2'use client'
3
4import { Button, Modal, useField, useForm, useFormFields, useModal } from '@payloadcms/ui'
5import dynamic from 'next/dynamic'
6import { JSONFieldClientProps } from 'payload'
7import './index.scss'
8import { useMemo } from 'react'
9
10// Since client components get prerenderd on server as well hence importing
11// the excalidraw stuff dynamically with ssr false
12const ExcalidrawWrapper = dynamic(() => import('@/fields/Excalidraw/ExcalidrawWrapper'), {
13  ssr: false,
14})
15
16const ExcalidrawBlock: React.FC<JSONFieldClientProps> = ({ field, path }) => {
17  const { toggleModal, closeModal } = useModal()
18
19  const { dispatchFields, fields } = useForm()
20  const { value, setValue } = useField<string>({ path: path || field.name })
21
22  const customID = useMemo(() => {
23    return fields?.['id']?.value as string
24  }, [fields])
25  const jsonFieldPath = path?.includes('.') ? `${path}.${'code'}` : 'code'
26  
27
28  const json = useFormFields(([fields]) => {
29    return fields['code']?.value as string
30  })
31
32  const handleSave = (svg: string, elements: string) => {
33    closeModal(`excalidraw-${customID}`)
34
35    dispatchFields({
36      type: 'UPDATE',
37      path: jsonFieldPath,
38      value: elements,
39    })
40
41    setValue(svg)
42  }
43
44  return (
45    <div className="excalidraw-block">
46      <div dangerouslySetInnerHTML={{ __html: value }} className="excalidraw-svg"></div>
47      <Button onClick={() => toggleModal(`excalidraw-${customID}`)} className="excalidraw-edit">
48        Open Excalidraw
49      </Button>
50      <Modal slug={`excalidraw-${customID}`} className="excalidraw-modal">
51        <ExcalidrawWrapper
52          onSave={handleSave}
53          initialElements={json}
54          closeModal={() => closeModal(`excalidraw-${customID}`)}
55        />
56      </Modal>
57    </div>
58  )
59}

And ExcalidrawWrapper component integrates the Excalidraw editor and handles exporting the drawing to SVG. Also, it renders additional buttons right in Excalidraw UI, to close the dialog and save changes.

1'use client'
2import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw'
3
4import '@excalidraw/excalidraw/index.css'
5import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
6import { Button } from '@payloadcms/ui'
7import { useState } from 'react'
8type ExcalidrawWrapperProps = {
9  onSave: (svg: string, elements: string) => void
10  initialElements?: string
11  closeModal: () => void
12}
13const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
14  onSave,
15  initialElements,
16  closeModal,
17}) => {
18  const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI>()
19
20  const handleExportToSVG = async () => {
21    if (!excalidrawAPI) {
22      return
23    }
24    const elements = excalidrawAPI.getSceneElements()
25    if (!elements || !elements.length) {
26      return
27    }
28    const svg = await exportToSvg({
29      elements,
30      appState: {
31        ...excalidrawAPI.getAppState(),
32        exportWithDarkMode: true,
33        exportBackground: false,
34      },
35      files: excalidrawAPI.getFiles(),
36    })
37
38    //save svg to json field and save current elements , which allows us to edit image later
39    onSave(svg.outerHTML, JSON.stringify(elements))
40  }
41  return (
42    <div className="excalidraw-wrapper">
43      <Excalidraw
44        theme="dark"
45        excalidrawAPI={(api) => setExcalidrawAPI(api)}
46        initialData={{
47          elements: JSON.parse(initialElements || '[]'),
48        }}
49        renderTopRightUI={() => {
50          return (
51            <>
52              <Button
53                buttonStyle="secondary"
54                onClick={() => {
55                  closeModal()
56                }}
57                className="remove-margins"
58              >
59                Exit
60              </Button>
61              <Button
62                buttonStyle="primary"
63                onClick={() => {
64                  handleExportToSVG()
65                }}
66                className="remove-margins"
67              >
68                Save
69              </Button>
70            </>
71          )
72        }}
73      ></Excalidraw>
74    </div>
75  )
76}
77export default ExcalidrawWrapper
78

We need to import this component dynamicly, since Excalidraw doesn't support server-side rendering. Excalidraw docs. Additionally, i created css fix for excalidraw own modals, because they are absolute positioned , and not visible when you scroll down.

1.excalidraw .Modal{
2    position: fixed !important; // fix for excalidraw modals , showing at top of page , not in viewport
3}
4

Then , i used ExcalidrawBlock component as a field component in Admin:

1...
2{
3  name: 'svg',
4  type: 'textarea',
5  maxLength: 999999,
6  admin: {
7    components: {
8      Field: './fields/Excalidraw',
9    },
10  },
11}
12...

3. Rendering on client

To properly render the Excalidraw block on the client, I created a custom jsxConverter for the RichText component. This allows the rendering of SVG drawings dynamically based on the block configuration.

1type NodeTypes = DefaultNodeTypes | SerializedBlockNode<Excalidraw>
2
3const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
4  ...defaultConverters,
5  blocks: {
6    excalidraw: ({ node }) => {
7      let className = ''
8      switch (node.fields.height) {
9        case 'default':
10          className = '*:max-h-[360px]' //This also provided in styles for compatibility ( old ios sucks )
11          break
12        case 'square':
13          className = '*:aspect-square'
14          break
15        case 'unrestricted':
16          className = ''
17          break
18      }
19      return (
20        <div
21          dangerouslySetInnerHTML={{ __html: node.fields.svg || '' }}
22          className={cn(
23            'w-full h-auto *:w-full *:h-auto  mt-4 mb-8',
24            className,
25            `exclaidraw-${node.fields.height}`,
26          )}
27        />
28      )
29    },
30  }
31})

Possible Improvements

One area for improvement is optimizing the API responses. Currently, the code field (which holds the Excalidraw elements) is included in the responses. This increases the payload size and slows down the site. Although I attempted to hide this field using hidden: true, it became inaccessible in the Excalidraw field component.

Additionally, storing SVG as a string in the database is not the most efficient approach. However, I currently lack the advanced Payload knowledge required to implement a more optimized solution.

Another potential improvement involves avoiding dangerouslySetInnerHTML for rendering SVGs. While this method works fine in my case, it's not safe to do so.

Conclusion

By leveraging Payload's block feature and integrating Excalidraw, you can provide a rich, interactive drawing experience within your CMS. Users can embed, edit, and export drawings without leaving the admin panel.

Share this article