drawWhiteboard

Embed Excalidraw in Next.js: SSR, Save/Load

Stop fighting whiteboarding in React. Learn to embed Excalidraw with custom toolbar, server-side rendering hacks, API persistence, and theming – all in a production Next.js app.

Why Embed Excalidraw?

Excalidraw gives you a lightweight, collaborative whiteboard that renders shapes, text, and diagrams with a hand-drawn feel. But dropping it into a Next.js app introduces SSR conflicts, toolbar overreach, and data persistence challenges. This guide walks you through four exact steps: taming server-side rendering, building a custom toolbar, saving/loading drawings via an API, and matching your app's theme.

SSR Caveats

Next.js pre-renders pages on the server. Excalidraw depends on browser APIs like window, canvas, and PointerEvents. If you import it normally, you'll get a window is not defined error. Fix it with dynamic import and ssr: false.

  • Wrap the component in a dynamic() call from next/dynamic
  • Set ssr: false to skip server rendering
  • Mark the parent page or layout as a 'use client' component if you use React hooks nearby
  • Use typeof window !== 'undefined' guards for any direct Excalidraw API calls

Example: Dynamic Import

import dynamic from 'next/dynamic';

const ExcalidrawWrapper = dynamic(
  () => import('@excalidraw/excalidraw').then((mod) => mod.Excalidraw),
  { ssr: false }
);

export default function WhiteboardPage() {
  return <ExcalidrawWrapper />;
}

This pattern alone handles the most common SSR pitfalls. For deeper hooks, ensure any callback passed to onChange or onPointerUpdate is stable (use useCallback) to avoid unnecessary re-renders.

Custom Toolbar

The default Excalidraw toolbar includes everything from laser pointers to image export. In a production app you often want only a subset. You can control the toolbar by using the ref and the updateScene API, or by hiding the built-in toolbar and building your own buttons.

  • Set viewModeEnabled to true to hide the default toolbar
  • Attach a ref to Excalidraw: <Excalidraw ref={excalidrawRef} />
  • Call excalidrawRef.current?.updateScene({ appState: { activeTool: { type: 'rectangle' } } })
  • Or use setActiveTool from the API if your version exposes it

Example: Custom Rectangle Button

import { Excalidraw } from '@excalidraw/excalidraw';
const [excalidrawAPI, setExcalidrawAPI] = useState(null);

return (
  <>
    <button onClick={() => {
      excalidrawAPI?.setActiveTool({ type: 'rectangle' });
    }}>
      Rectangle
    </button>
    <Excalidraw
      excalidrawAPI={(api) => setExcalidrawAPI(api)}
      viewModeEnabled={false}
    />
  </>
);

Map all the tools you need: selection, arrow, text, freedraw. For eraser, use type: 'eraser'. Keep your toolbar small – three to five buttons improve UX. Handle active tool state in a parent component so the UI stays in sync.

Save and Load via API

Users expect their drawings to persist. Excalidraw emits a onChange event with the full elements array and appState. You save this payload to your backend, then restore it on mount.

  • Listen to onChange and debounce the save call (500ms works well)
  • Send a POST request to your API route with { elements, appState }
  • Store it in your database – a single JSON column works
  • On mount, fetch the saved data and pass it as initialData prop

Example: Save & Load

const [elements, setElements] = useState([]);

// Save (debounced)
const handleChange = useCallback((els, state) => {
  setElements(els);
  fetch('/api/whiteboard/save', {
    method: 'POST',
    body: JSON.stringify({ elements: els, appState: state }),
  });
}, []);

// Load
useEffect(() => {
  fetch('/api/whiteboard/load')
    .then((res) => res.json())
    .then((data) => {
      if (data) {
        setElements(data.elements);
      }
    });
}, []);

return (
  <Excalidraw
    initialData={{ elements, appState: null }}
    onChange={handleChange}
  />
);

Keep in mind: appState can be large (view zoom, scroll position). You may want to save only elements and restore a minimal viewport. For large diagrams (over 500 elements), consider sending only changed elements using a diff approach.

Theming

Excalidraw ships with a theme prop that accepts 'light' or 'dark'. For deeper customization, you can override colors via UIOptions or CSS variables.

  • Pass theme={theme} where theme is from your app context (e.g., 'light' or 'dark')
  • Use UIOptions to change canvas background: UIOptions={{ canvasBackground: '#f0f0f0' }}
  • Override CSS variables in your global styles: --excalidraw-primary: #yourColor;
  • Match your app's brand colors for consistent UX

Example: Dark Mode Integration

const { colorMode } = useAppTheme(); // 'light' | 'dark'

<Excalidraw
  theme={colorMode}
  UIOptions={{
    canvasBackground: colorMode === 'dark' ? '#1e1e1e' : '#ffffff',
  }}
/>

For custom toolbar icons or dialog colors, inspect the Excalidraw CSS custom properties at runtime and override them in a :root block. This gives you a whiteboard that feels like part of your app, not a third-party widget.

You now have a production-ready embedding of Excalidraw in Next.js: SSR handled, toolbar tailored, data persisted, theme matched. The average implementation takes about two hours from start to finish. Ready to ship faster? Smartees gives you AI-powered code generation and real-time collaboration scaffolding so you can launch interactive whiteboards in minutes – not days.

Whiteboard

Free, browser-side, one sign-in for downloads.

Whiteboard