Files
excalidraw-box/PLAN.md
2026-05-24 15:32:36 +00:00

14 KiB
Raw Blame History

Project: excalidraw-box

Goal

Build a small self-hosted web app that provides:

- Excalidraw editor
- Server-side autosave to SQLite
- No collaboration
- No accounts inside the app
- Reverse-proxy auth handled by Caddy
- Manual export to PNG/SVG/.excalidraw
- Docker Compose deployment

The app should be intentionally small and auditable.


Non-goals

Do not implement:

- real-time collaboration
- public sharing links
- user accounts
- teams/workspaces
- comments
- Firebase
- S3/MinIO in v1
- complex permissions

Authentication is handled outside the app by Caddy basic_auth or later Authelia/AuthentiK.


Stack

Use:

Next.js App Router
React
TypeScript
@excalidraw/excalidraw
better-sqlite3
Docker Compose
Caddy reverse proxy

Rationale:

  • @excalidraw/excalidraw is the official embeddable package. ([npm][1])
  • Excalidraw exposes onChange(elements, appState, files) for persistence. ([Excalidraw Docs][2])
  • Excalidraw provides export utilities such as exportToCanvas for rendering scenes to images. ([Excalidraw Docs][3])
  • Next.js officially supports standalone Docker deployment. ([Next.js][4])

Version 1 feature set

V1 must have

1. Single default drawing
2. Load drawing from SQLite on page load
3. Autosave drawing to SQLite after edits
4. Debounce saves by 1.5 seconds
5. Show save status:
   - Saved
   - Saving…
   - Save failed
6. Export PNG from the current scene
7. Export .excalidraw JSON from the current scene
8. Persist SQLite database under /data
9. Dockerfile
10. docker-compose.yml
11. Caddyfile example

V1 should avoid

- multiple drawings
- login UI
- sharing
- collaboration
- migrations framework
- Prisma, unless really needed

Use plain better-sqlite3 to keep the code small.


Directory structure

excalidraw-autosave/
  app/
    api/
      drawing/
        route.ts
    globals.css
    layout.tsx
    page.tsx

  components/
    ExcalidrawClient.tsx

  lib/
    db.ts
    scene.ts
    debounce.ts

  public/

  Dockerfile
  docker-compose.yml
  Caddyfile.example
  next.config.ts
  package.json
  tsconfig.json
  .dockerignore
  README.md

Data model

Use one SQLite table.

CREATE TABLE IF NOT EXISTS drawings (
  id TEXT PRIMARY KEY,
  data TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

For v1, always use:

id = "default"

Stored data should be the scene object:

{
  "type": "excalidraw-autosave-scene",
  "version": 1,
  "elements": [],
  "appState": {},
  "files": {}
}

Do not store only rendered PNG. Store the editable scene JSON as the source of truth.


API design

GET /api/drawing

Returns the saved drawing.

If no drawing exists, return an empty scene:

{
  "type": "excalidraw-autosave-scene",
  "version": 1,
  "elements": [],
  "appState": {},
  "files": {}
}

POST /api/drawing

Accepts:

{
  "elements": [],
  "appState": {},
  "files": {}
}

Behavior:

- Validate body is an object.
- Validate elements is an array.
- Validate appState is an object.
- Validate files is an object.
- Strip volatile or UI-only appState fields if needed.
- Save JSON to SQLite.
- Upsert row id = "default".
- Return { "ok": true, "updatedAt": "..." }.

Optional GET /api/health

Returns:

{
  "ok": true
}

Useful for Docker health checks.


Scene handling

Create lib/scene.ts.

Responsibilities:

- define EmptyScene
- normalize incoming scene
- remove unwanted volatile appState fields
- cap payload size

Recommended payload size cap for v1:

25 MB

If the scene exceeds this, return HTTP 413 Payload Too Large.

Reason: Excalidraw embedded images are stored under files; they can make scene JSON large.


Frontend behavior

Page load

1. Render loading state.
2. Fetch /api/drawing.
3. Pass returned scene to Excalidraw as initialData.
4. Render editor full-screen.

The official integration docs show that the Excalidraw component should be used client-side in Next.js, with "use client" and the Excalidraw CSS import. ([Excalidraw Docs][5])

Autosave

Use Excalidraws onChange.

Pseudo-flow:

onChange={(elements, appState, files) => {
  setSaveStatus("dirty");
  debouncedSave({ elements, appState, files });
}}

Debounced save:

- wait 1500 ms after last change
- POST to /api/drawing
- set status Saving… while request in flight
- set status Saved after success
- set status Save failed after failure

Important: Do not save on every pointer movement without debounce.

Save status UI

A small fixed indicator:

top-right:
  Saved
  Saving…
  Save failed

Optionally show last saved timestamp.


Export behavior

.excalidraw export

Implement client-side export by taking the current scene and downloading JSON.

Filename:

drawing-YYYY-MM-DD-HHMM.excalidraw

Content:

{
  "type": "excalidraw",
  "version": 2,
  "source": "https://excalidraw.com",
  "elements": [],
  "appState": {},
  "files": {}
}

This keeps compatibility with regular Excalidraw import/export. The Excalidraw repo describes the open .excalidraw JSON file format as a supported feature. ([GitHub][6])

PNG export

Use Excalidraw export utilities client-side.

The docs show exportToCanvas exported from @excalidraw/excalidraw; it takes elements, appState, and export dimensions. ([Excalidraw Docs][3])

Implementation idea:

import { exportToCanvas } from "@excalidraw/excalidraw";

const canvas = await exportToCanvas({
  elements,
  appState: {
    ...appState,
    exportBackground: true,
  },
  files,
  getDimensions: (width, height) => ({
    width,
    height,
    scale: 2,
  }),
});

const blob = await new Promise<Blob>((resolve) =>
  canvas.toBlob((blob) => resolve(blob!), "image/png"),
);

Then trigger browser download.

Do PNG export client-side first. It avoids needing headless browser rendering on the server.


Important implementation detail: access to current scene

Use excalidrawAPI.

The Excalidraw API exposes methods such as getFiles. ([Excalidraw Docs][7])

Store latest values from onChange in React refs:

const latestSceneRef = useRef({
  elements: [],
  appState: {},
  files: {},
});

Update it inside onChange.

Use that ref for export buttons.


Suggested files

package.json

{
  "name": "excalidraw-autosave",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@excalidraw/excalidraw": "latest",
    "better-sqlite3": "latest",
    "next": "latest",
    "react": "latest",
    "react-dom": "latest"
  },
  "devDependencies": {
    "@types/better-sqlite3": "latest",
    "@types/node": "latest",
    "@types/react": "latest",
    "@types/react-dom": "latest",
    "typescript": "latest"
  }
}

next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Database module

lib/db.ts

Requirements:

- Read DATABASE_PATH from env
- Default to /data/excalidraw.sqlite
- Ensure parent directory exists
- Initialize schema
- Export getDrawing() and saveDrawing()

Pseudo-code:

import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";

const dbPath = process.env.DATABASE_PATH ?? "/data/excalidraw.sqlite";
fs.mkdirSync(path.dirname(dbPath), { recursive: true });

const db = new Database(dbPath);

db.pragma("journal_mode = WAL");

db.exec(`
  CREATE TABLE IF NOT EXISTS drawings (
    id TEXT PRIMARY KEY,
    data TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  )
`);

export function getDrawing(id = "default") {
  const row = db.prepare("SELECT data FROM drawings WHERE id = ?").get(id) as
    | { data: string }
    | undefined;

  return row ? JSON.parse(row.data) : null;
}

export function saveDrawing(data: unknown, id = "default") {
  const serialized = JSON.stringify(data);

  db.prepare(
    `
    INSERT INTO drawings (id, data, created_at, updated_at)
    VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
    ON CONFLICT(id) DO UPDATE SET
      data = excluded.data,
      updated_at = CURRENT_TIMESTAMP
  `,
  ).run(id, serialized);

  return new Date().toISOString();
}

API route

app/api/drawing/route.ts

Requirements:

- Force Node runtime, not Edge
- GET returns saved or empty scene
- POST saves normalized scene
- Handle invalid JSON
- Enforce payload size cap

Add:

export const runtime = "nodejs";

Pseudo-code:

import { NextResponse } from "next/server";
import { getDrawing, saveDrawing } from "@/lib/db";
import { emptyScene, normalizeScene } from "@/lib/scene";

export const runtime = "nodejs";

export async function GET() {
  const saved = getDrawing("default");
  return NextResponse.json(saved ?? emptyScene());
}

export async function POST(req: Request) {
  const text = await req.text();

  if (text.length > 25 * 1024 * 1024) {
    return NextResponse.json(
      { ok: false, error: "Scene too large" },
      { status: 413 },
    );
  }

  let raw: unknown;

  try {
    raw = JSON.parse(text);
  } catch {
    return NextResponse.json(
      { ok: false, error: "Invalid JSON" },
      { status: 400 },
    );
  }

  const scene = normalizeScene(raw);
  const updatedAt = saveDrawing(scene, "default");

  return NextResponse.json({ ok: true, updatedAt });
}

Client component

components/ExcalidrawClient.tsx

Requirements:

- "use client"
- import Excalidraw and CSS
- fetch initial data
- render full viewport
- onChange debounced autosave
- export JSON button
- export PNG button
- save status

Important:

import "@excalidraw/excalidraw/index.css";

Use a custom debounce instead of lodash to avoid another dependency.


CSS/UI

Keep UI minimal.

- Editor fills viewport.
- Floating toolbar at top-right.
- Buttons:
  - Export PNG
  - Export .excalidraw
- Save status badge:
  - Saved
  - Saving…
  - Failed

Do not attempt to reproduce Excalidraws own toolbar.


Docker

Dockerfile

Use Next.js standalone output.

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV DATABASE_PATH=/data/excalidraw.sqlite

RUN mkdir -p /data

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000

CMD ["node", "server.js"]

docker-compose.yml

services:
  excalidraw-autosave:
    build: .
    container_name: excalidraw-autosave
    expose:
      - "3000"
    environment:
      DATABASE_PATH: /data/excalidraw.sqlite
    volumes:
      - ./data:/data
    restart: unless-stopped

  caddy:
    image: caddy:latest
    container_name: excalidraw-caddy
    depends_on:
      - excalidraw-autosave
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile.example:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:

Caddyfile.example

draw.example.com {
  basic_auth {
    zero REPLACE_WITH_CADDY_HASH
  }

  reverse_proxy excalidraw-autosave:3000
}

Generate password hash:

docker run --rm -it caddy:latest caddy hash-password

Backup plan

SQLite file:

./data/excalidraw.sqlite

Backup command:

mkdir -p ./backups
sqlite3 ./data/excalidraw.sqlite ".backup './backups/excalidraw-$(date +%F-%H%M).sqlite'"

Recommended cron:

0 3 * * * cd /path/to/excalidraw-autosave && sqlite3 ./data/excalidraw.sqlite ".backup './backups/excalidraw-$(date +\%F-\%H\%M).sqlite'"

Also back up .excalidraw exports manually for critical drawings.


Security model

V1 app assumes:

- It is not public without reverse proxy auth.
- Caddy handles TLS.
- Caddy handles basic_auth.
- The app does not implement users.
- The app should not expose port 3000 to the public internet.

In Compose, use:

expose:
  - "3000"

Do not use:

ports:
  - "3000:3000"

unless testing locally.


Acceptance criteria

Codex should finish with a repo that satisfies:

1. `npm run dev` opens editor locally.
2. Drawing reloads after refresh.
3. Drawing persists after app restart.
4. Docker Compose starts Caddy + app.
5. SQLite DB is created under ./data.
6. No direct public app port is exposed.
7. Export PNG downloads a PNG.
8. Export .excalidraw downloads editable JSON.
9. Autosave status visibly changes.
10. No collaboration code exists.

Manual test:

1. Start app.
2. Draw rectangle and text.
3. Wait until status says Saved.
4. Refresh browser.
5. Confirm rectangle and text remain.
6. Restart container.
7. Confirm drawing remains.
8. Export PNG.
9. Export .excalidraw.
10. Import .excalidraw into official Excalidraw and confirm it opens.

Future V2, only after V1 works

Add these later, not now:

- multiple named drawings
- drawing list page
- thumbnails
- duplicate drawing
- delete drawing
- server-side export endpoint
- periodic backup UI
- WebDAV/S3 export
- basic file manager

For V2 multiple drawings, change routes to:

GET    /api/drawings
POST   /api/drawings
GET    /api/drawings/:id
PUT    /api/drawings/:id
DELETE /api/drawings/:id

Schema:

CREATE TABLE drawings (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  data TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Instruction

Build the V1 exactly as specified. Keep code minimal. Do not add accounts, collaboration, sharing, Firebase, Prisma, Tailwind, or extra abstractions. Use the official @excalidraw/excalidraw package and SQLite persistence through better-sqlite3. Use Caddy for auth in the deployment example.