Claude Code with Excalidraw: Embedded Diagrams That Don't Drift
Why Excalidraw without CLAUDE.md ships diagrams that vanish on refresh
Excalidraw is the hand-drawn-aesthetic diagram editor that ships as both a hosted product and an npm package you embed in your own app. The embedded version is one component, one prop for the initial scene, one callback for changes, and a render that looks like it took ten minutes. Then the engineer ships, the user draws a diagram, refreshes the page, and the diagram is empty. The bug report says "the editor forgot my drawing." The library is not at fault. The embedded <Excalidraw /> component is uncontrolled by default: it manages its own internal scene state and emits onChange when the scene mutates. Nothing in that callback writes to your backend, persists to localStorage, or hydrates from a snapshot on next mount. That wiring is yours to build.
Claude Code does not know any of this by default. It generates <Excalidraw initialData={...} onChange={(elements) => setElements(elements)} />, ships an empty Next.js page, and stops. The result demos beautifully on localhost and breaks in two ways simultaneously: the SSR pass throws because Excalidraw's renderer touches window, and the scene state is mirrored into a React parent that fights the editor's own state. The most common Claude defaults that break Excalidraw in production: the component imported at the module top level so the SSR build cannot tree-shake it out, the scene held in React state which causes typing lag on every text-element keystroke, custom elements declared without registering their type so the renderer cannot draw them, files (image element binaries) discarded on serialisation because the snapshot only saved the elements array not the files map, and collaboration wired via the API surface without the corresponding excalidraw-collab integration so cursors flicker.
This guide covers the CLAUDE.md configuration that locks Claude Code into Excalidraw's correct embedding patterns: dynamic import with SSR disabled, scene held inside the editor with onChange mirroring outward, the initialData prop used once at mount with all subsequent updates going through the imperative API, files persisted alongside the elements array as a single snapshot, and collaboration set up via the documented excalidraw-collab socket protocol. For the React rendering patterns the embed depends on, Claude Code with React covers the component model that pairs cleanly with imperative editor APIs.
The Excalidraw CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For an Excalidraw embed it needs to declare: the package versions, the SSR strategy, the scene management rules, the file handling discipline, the imperative API surface, the collaboration setup, and the hard rules that block the mistakes Claude makes most often.
# Excalidraw rules
## Stack
- @excalidraw/excalidraw ^0.18.x
- React 18 with Next.js App Router (SSR-disabled wrapper)
- excalidraw-collab for socket-based multi-user editing (optional)
- Editor mounted once per route, never re-created on parent re-render
## Project structure
- src/diagram/ , diagram embed and helpers
- src/diagram/Embed.tsx , dynamic-imported component wrapper
- src/diagram/scene/ , scene serialisation (elements + files + appState)
- src/diagram/persistence/ , load / save boundary
- src/diagram/collab/ , socket and awareness setup (if enabled)
- src/diagram/api/ , imperative API access via excalidrawAPI ref
## SSR (MANDATORY)
- Excalidraw is dynamic-imported with { ssr: false }
- Wrapper component is the ONLY place Excalidraw is imported
- NEVER import @excalidraw/excalidraw at the module top level of a route
- NEVER render the wrapper inside a server component without an SSR-disabled boundary
## Scene management (MANDATORY)
- Scene is UNCONTROLLED, do NOT pass elements via a state-bound prop
- Use initialData ONCE at mount
- For external updates (e.g. server push), use excalidrawAPI.updateScene()
- onChange mirrors outward, never feeds the same change back in
- Mirror elements + files together, not separately
## File handling (MANDATORY)
- BinaryFiles map MUST be persisted alongside the elements array
- Image elements reference fileId, NEVER inline base64 in the element
- Files are uploaded to your storage and the fileId is the storage key
- onPaste of image data: store via excalidrawAPI.addFiles(), then create element
## Imperative API (MANDATORY)
- Capture excalidrawAPI via the excalidrawAPI prop ONCE
- Hold it in a ref or zustand store, NEVER in React state
- API methods: updateScene, setActiveTool, addFiles, getSceneElements, getFiles
- Do NOT call API methods inside the render phase, only inside handlers / effects
## Collaboration (optional)
- excalidraw-collab handles the protocol, do not roll your own
- Awareness state via the collaborators prop, updated via updateScene
- Cursors and selection broadcast via the socket layer
- Reconciliation handled by Excalidraw, do not merge scenes manually
## Hard rules
- NEVER import @excalidraw/excalidraw without dynamic + ssr: false
- NEVER pass elements as a controlled state prop
- NEVER mutate elements array in place, always pass a new array
- NEVER inline file binaries in element data (use the BinaryFiles map)
- NEVER persist appState without filtering ephemeral fields
- ALWAYS persist files + elements as one snapshot
- ALWAYS hold excalidrawAPI in a ref, not in state
Five rules here prevent the majority of incidents Claude generates without them.
The SSR rule prevents the build-time crash. Excalidraw's renderer touches window.matchMedia and document at import time for its canvas setup. A direct import in a Next.js page or layout fails the server build with ReferenceError: window is not defined. The rule "dynamic import with ssr: false" forces Claude to use Next.js's documented escape hatch, which produces a client-only boundary where the editor mounts safely.
The uncontrolled scene rule prevents the typing-lag bug. Engineers reach for <Excalidraw elements={elements} onChange={setElements} /> because that is the React idiom. Each setElements triggers a parent re-render, which re-renders the wrapper, which re-creates the scene, which destroys the cursor position. The rule "scene is UNCONTROLLED" makes the engineer use initialData once and mirror changes outward via onChange without feeding them back.
The file handling rule prevents the silent image-loss bug. Excalidraw's image elements reference a fileId that points into a separate BinaryFiles map. Engineers persist the elements array and skip the files map, then the next mount loads the elements, sees image elements with fileId values that point to nothing, and renders broken placeholders. The rule "BinaryFiles map MUST be persisted alongside the elements array" makes the dual persistence explicit.
The imperative API rule prevents the editor-ref-in-state bug. The excalidrawAPI prop is a callback that receives the imperative API once the editor is ready. Engineers mirror it into useState, which triggers a re-render the moment the editor mounts, which re-renders the wrapper, which can re-mount the editor. The rule "hold it in a ref or zustand store" avoids the trigger.
The appState filter rule prevents the snapshot churn. Excalidraw's appState contains transient fields like cursorButton, selectedElementIds, and zoom. Persisting it verbatim means every cursor movement writes to your backend. The rule "filter ephemeral fields" forces Claude to keep only the persistent fields (viewBackgroundColor, gridSize, theme) in the snapshot.
Install and SSR-safe wrapper
Install Excalidraw and its peer dependencies:
pnpm add @excalidraw/excalidraw
Excalidraw ships its own CSS that must be imported once at the app root:
// src/app/layout.tsx
import '@excalidraw/excalidraw/index.css';
The SSR-safe wrapper uses Next.js's next/dynamic:
// src/diagram/Embed.tsx
'use client';
import dynamic from 'next/dynamic';
import { useCallback, useRef } from 'react';
import type {
ExcalidrawElement,
BinaryFiles,
AppState,
} from '@excalidraw/excalidraw/types/types';
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false, loading: () => <DiagramSkeleton /> },
);
type Snapshot = {
elements: readonly ExcalidrawElement[];
files: BinaryFiles;
appState: Partial<AppState>;
};
type Props = {
documentId: string;
initialSnapshot: Snapshot | null;
onPersist: (snapshot: Snapshot) => Promise<void>;
};
export function DiagramEmbed({ documentId, initialSnapshot, onPersist }: Props) {
const apiRef = useRef<ExcalidrawImperativeAPI | null>(null);
const handleApi = useCallback((api: ExcalidrawImperativeAPI) => {
apiRef.current = api;
}, []);
const handleChange = useCallback(
(
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
void onPersist({
elements,
files,
appState: persistableAppState(appState),
});
},
[onPersist],
);
return (
<Excalidraw
key={documentId}
excalidrawAPI={handleApi}
initialData={initialSnapshot}
onChange={handleChange}
/>
);
}
function DiagramSkeleton() {
return (
<div
style={{
width: '100%',
height: '100%',
background: '#fafafa',
display: 'grid',
placeItems: 'center',
color: '#71717a',
fontFamily: 'system-ui',
fontSize: 14,
}}
>
Loading diagram
</div>
);
}
function persistableAppState(s: AppState): Partial<AppState> {
return {
viewBackgroundColor: s.viewBackgroundColor,
gridSize: s.gridSize,
theme: s.theme,
name: s.name,
};
}
Three things in this snippet that the CLAUDE.md rule enforces. The Excalidraw component is imported via next/dynamic with ssr: false, so the server build never touches Excalidraw's window-dependent code. The excalidrawAPI is captured into a ref, not state, so the parent does not re-render when the editor mounts. The onChange callback filters appState through persistableAppState so cursor movements and selection changes do not flood your backend.
The key={documentId} is deliberate. It tells React to remount the component when the document ID changes, which is correct: a new document gets a clean editor with a clean initial data load. Without it, navigating from document A to document B would reuse the same editor instance and the initialData change would be ignored.
Persistence with debouncing
The onChange callback fires synchronously on every scene mutation. For a 100-element diagram with active text editing, that is hundreds of changes per second. Writing each change to your backend would flood the network and rate-limit your API.
A persistence wrapper with debounce:
// src/diagram/persistence/save.ts
import type {
ExcalidrawElement,
BinaryFiles,
AppState,
} from '@excalidraw/excalidraw/types/types';
type Snapshot = {
elements: readonly ExcalidrawElement[];
files: BinaryFiles;
appState: Partial<AppState>;
};
const DEBOUNCE_MS = 600;
export function createPersister(
remoteSave: (snapshot: Snapshot) => Promise<void>,
): (snapshot: Snapshot) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Snapshot | null = null;
const flush = async () => {
if (!pending) return;
const snapshot = pending;
pending = null;
timer = null;
try {
await remoteSave(snapshot);
} catch (err) {
pending = snapshot;
console.error('save failed, will retry', err);
}
};
return (snapshot: Snapshot) => {
pending = snapshot;
if (timer === null) {
timer = setTimeout(flush, DEBOUNCE_MS);
}
};
}
Use it in the embed:
const persister = useMemo(() => createPersister(saveSnapshotToBackend), []);
<DiagramEmbed
documentId={documentId}
initialSnapshot={initialSnapshot}
onPersist={persister}
/>
The debounce holds writes for 600ms after the last change. A burst of keystrokes during text editing produces one save instead of fifty. The trailing-edge behaviour means the user sees their last edit persisted within 600ms of stopping typing, which is responsive enough that they never notice.
Add a persistence rule to CLAUDE.md:
## Persistence
- Debounce onChange writes by 600ms (configurable per project)
- Snapshot = { elements, files, appState (filtered) }
- Backend stores snapshot as a single document, not split across tables
- Acknowledge on persist success, retry on failure
- Pending writes cleared on unmount
Files: the gotcha that loses images
Image elements in Excalidraw have a small element record in the elements array and a separate binary blob in the files map keyed by fileId. The two must be persisted together and restored together. A snapshot that contains the image element but not the binary will render a broken-image placeholder on next mount.
A file upload boundary:
// src/diagram/api/files.ts
import type {
BinaryFiles,
BinaryFileData,
} from '@excalidraw/excalidraw/types/types';
export async function uploadFile(
fileData: BinaryFileData,
uploadToStorage: (id: string, dataURL: string) => Promise<void>,
): Promise<BinaryFileData> {
await uploadToStorage(fileData.id, fileData.dataURL);
return fileData;
}
export async function snapshotFiles(
files: BinaryFiles,
uploadToStorage: (id: string, dataURL: string) => Promise<void>,
): Promise<BinaryFiles> {
const entries = await Promise.all(
Object.entries(files).map(async ([id, fileData]) => {
await uploadToStorage(id, fileData.dataURL);
return [id, fileData] as const;
}),
);
return Object.fromEntries(entries);
}
Wire it into the embed's onChange:
const handleChange = useCallback(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const persistedFiles = await snapshotFiles(files, uploadFileToStorage);
persister({
elements,
files: persistedFiles,
appState: persistableAppState(appState),
});
},
[persister],
);
The upload step runs alongside the persist step. The image binary lives in your blob storage (S3, R2, Supabase Storage). The snapshot stored in your database references the fileId, which is the storage key. On next mount, the embed receives initialData.files populated with the dataURL reconstructed from storage, and the image element renders correctly.
For the storage backend that holds the binaries, Claude Code with Cloudflare R2 covers the object-storage patterns that suit Excalidraw's file model. For the database that stores the snapshot, Claude Code with Convex covers the document storage patterns that pair cleanly with the elements + appState shape.
Library items and custom elements
Excalidraw's library is a set of reusable element groups the user can drag onto the canvas. The embed accepts a libraryItems field in initialData and emits onLibraryChange when the user updates the library.
Loading a library:
const initialData = {
elements: snapshot.elements,
files: snapshot.files,
appState: snapshot.appState,
libraryItems: snapshot.libraryItems,
};
Listening for library changes:
<Excalidraw
excalidrawAPI={handleApi}
initialData={initialData}
onChange={handleChange}
onLibraryChange={async (items) => {
await saveLibraryToBackend(items);
}}
/>
Custom element types are not part of Excalidraw's public extension surface in the same way tldraw's ShapeUtil is. The supported pattern is to compose existing element types into library items, or to render custom DOM via the editor's renderTopRightUI and renderCustomStats slots.
Add a library and custom element rule to CLAUDE.md:
## Library and custom UI
- libraryItems persisted alongside elements + files
- onLibraryChange fires on user add/remove, debounce same as elements
- Custom UI rendered via renderTopRightUI and renderCustomStats props
- NEVER subclass Excalidraw internal element types (not a public API)
Collaboration via excalidraw-collab
Excalidraw ships an official collaboration package that handles the socket protocol, reconciliation, and cursor broadcasting. Use it instead of rolling your own.
Setup the collab client:
// src/diagram/collab/setup.ts
import { initializeSocket, type CollabAPI } from 'excalidraw-collab/client';
export function setupCollab(
documentId: string,
user: { id: string; name: string; color: string },
excalidrawAPI: ExcalidrawImperativeAPI,
): CollabAPI {
const collab = initializeSocket({
documentId,
user,
api: excalidrawAPI,
serverUrl: process.env.NEXT_PUBLIC_COLLAB_SERVER_URL!,
});
collab.start();
return collab;
}
Wire it into the embed:
useEffect(() => {
const api = apiRef.current;
if (!api) return;
const collab = setupCollab(documentId, user, api);
return () => collab.stop();
}, [documentId, user]);
The collab client subscribes to the editor's scene changes via the imperative API, broadcasts them over the socket, and reconciles incoming changes into the local scene. Cursors and selection states are tracked via the collaborators prop on Excalidraw, which the collab client updates as remote presences arrive.
Common Claude Code mistakes with Excalidraw
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Direct import in a Next.js page
Claude generates:
import { Excalidraw } from '@excalidraw/excalidraw';
export default function DiagramPage() {
return <Excalidraw />;
}
The Next.js build fails the SSR pass because Excalidraw's renderer touches window at import time.
Correct pattern: dynamic import with ssr: false inside a client wrapper.
2. Scene held in React state
Claude generates:
const [elements, setElements] = useState([]);
<Excalidraw onChange={(els) => setElements(els)} initialData={{ elements }} />
setElements re-renders the parent on every keystroke. The editor receives a new initialData reference, which it ignores after first mount, but the parent re-render still cascades through siblings and parents.
Correct pattern: initialData set once via key={documentId}, onChange mirrors outward without feeding back.
3. Files persisted separately from elements
Claude generates a save that calls saveElements(elements) and saveFiles(files) as two requests, with the files request fired after the elements request. If the second request fails, the saved state has elements that reference missing files.
Correct pattern: one snapshot save that includes both, atomically.
4. excalidrawAPI in useState
Claude generates:
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
<Excalidraw excalidrawAPI={setApi} />
setApi runs when the editor mounts, which re-renders the parent, which re-renders the wrapper, which can cause editor remount under React Strict Mode.
Correct pattern: useRef to capture the API, plus a useCallback to avoid recreating the setter on every render.
5. AppState persisted verbatim
Claude generates saveSnapshot({ elements, files, appState }) where appState includes cursorButton, selectedElementIds, zoom, and dozens of other transient fields. Every cursor move triggers a save.
Correct pattern: a persistableAppState filter that keeps only fields that survive a refresh.
6. Manual reconciliation in collaboration
Claude generates a custom mergeScenes(local, remote) function instead of using excalidraw-collab. The custom merge handles trivial cases and breaks on element deletions, group selection, and binding edits.
Correct pattern: use excalidraw-collab and let it handle reconciliation.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for Excalidraw projects
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm add @excalidraw/*)",
"Bash(pnpm add excalidraw-collab*)",
"Bash(pnpm test*)",
"Bash(pnpm tsc --noEmit*)"
],
"deny": [
"Bash(pnpm remove @excalidraw/excalidraw*)",
"Bash(rm -rf src/diagram*)"
]
}
}
Building embeds that survive real users
The Excalidraw CLAUDE.md in this guide produces embeds where the component is dynamic-imported with SSR disabled so the Next.js build passes cleanly, the scene is uncontrolled with initialData set once and onChange mirrored outward via a debounced persister, files are uploaded to storage alongside the elements array as one atomic snapshot, the imperative API is captured into a ref so the editor mounts without re-render cascades, and collaboration is wired via the official excalidraw-collab package instead of a custom reconciler.
The underlying principle is the same as any embedded canvas library with Claude Code. Excalidraw without a CLAUDE.md produces embeds that look right in the demo and break the moment they hit production: SSR crashes because the component was imported at the wrong level, typing lag because the scene was mirrored into React state, missing images because the files map was dropped on serialisation, and editor re-mounts because the imperative API was captured into state instead of a ref.
For the React component patterns the embed depends on, Claude Code with React covers the rendering model that pairs cleanly with imperative APIs. For the Next.js App Router patterns that host the embed at a per-document URL, Claude Code with Next.js covers the routing patterns that suit per-document pages. Claudify includes an Excalidraw-specific CLAUDE.md template with the SSR wrapper, the persistence boundary, the files upload pattern, the API ref discipline, and all six common-mistake rules pre-configured.
Get Claudify. Ship Excalidraw embeds that survive a refresh.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify