✨ Vibe Build πŸ“… January 17, 2026 ⏱️ 15 min read

Self-Hosting Excalidraw on Cloudflare Workers + D1

How I replaced a $7/month Excalidraw Plus subscription with a self-hosted solution running on Cloudflare Workers and D1. Free tier, full control, shareable links, and zero Firebase.

I love Excalidraw. The sketchy, hand-drawn aesthetic. The friction-free drawing experience. The way it makes architectural diagrams feel less like work and more like doodling on a napkin.

But there’s a catch: cloud storage and collaboration require Excalidraw Plus at $7/month. For personal use, that felt steep for what’s essentially β€œsave my drawings somewhere.”

So I built my own backend. Cloudflare Workers for the API. D1 for the database. Better Auth for authentication. Shareable links with custom slugs and expiration. Zero ongoing costs on the free tier.

See it in action: Cursor vs Claude Code comparison - a shared whiteboard with the read-only viewer.

Here’s how it went down.


What Excalidraw Plus Actually Does

Before building a replacement, I needed to understand what I was replacing.

Excalidraw Plus features:

  • Cloud storage for your drawings
  • Shareable links that persist
  • Real-time collaboration (multiple cursors)
  • End-to-end encryption
  • Version history

What I actually needed:

  • Save drawings to the cloud
  • Access from any device
  • Generate shareable links for read-only viewing
  • Control over link expiration

Real-time collaboration? Nice to have, but I mostly draw solo. Version history? Git handles that for my important diagrams. End-to-end encryption? I’ll address that differently.

The core requirement was simple: persist drawings and share them with anyone via a link.


The Architecture

Excalidraw stores drawings as JSON. That’s it. No complex binary formats, no proprietary encoding. Just JSON with element definitions, app state, and optionally, embedded images.

{
  "type": "excalidraw",
  "version": 2,
  "elements": [
    {
      "type": "rectangle",
      "x": 100,
      "y": 100,
      "width": 200,
      "height": 150,
      "strokeColor": "#1e1e1e",
      "backgroundColor": "transparent"
    }
  ],
  "appState": {
    "viewBackgroundColor": "#ffffff",
    "gridSize": null
  }
}

This simplicity is perfect for a lightweight backend:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Frontend (Astro + React)                      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  WhiteboardEditor.tsx    β”‚  WhiteboardViewer.tsx                β”‚
β”‚  - @excalidraw/excalidrawβ”‚  - Read-only view for shared links   β”‚
β”‚  - Auto-save (debounced) β”‚  - No auth required                  β”‚
β”‚  - Share link management β”‚  - Expiration countdown              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   API Layer (Hono on Workers)                   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  /api/whiteboards/        β”‚  /api/shared/:token                 β”‚
β”‚  - CRUD for whiteboards   β”‚  - Public endpoint (no auth)        β”‚
β”‚  - Share link management  β”‚  - Returns whiteboard if valid      β”‚
β”‚  - Auth required          β”‚  - Checks expiration                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   Database (Cloudflare D1)                      β”‚
β”‚  - whiteboard: id, user_id, title, data (JSON), timestamps      β”‚
β”‚  - share_link: id (slug), whiteboard_id, expires_at, created_at β”‚
β”‚  - Drizzle ORM for type-safe queries                            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Database Schema

Two tables: one for whiteboards, one for share links.

// src/worker/db/schema.ts
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';

export const whiteboard = sqliteTable('whiteboard', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  title: text('title').notNull().default('Untitled'),
  data: text('data').notNull().default('{}'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
  index('whiteboard_user_id_idx').on(table.userId),
]);

export const shareLink = sqliteTable('share_link', {
  id: text('id').primaryKey(), // The slug (e.g., "my-architecture-diagram")
  whiteboardId: text('whiteboard_id').notNull()
    .references(() => whiteboard.id, { onDelete: 'cascade' }),
  createdBy: text('created_by').notNull()
    .references(() => user.id, { onDelete: 'cascade' }),
  expiresAt: integer('expires_at', { mode: 'timestamp' }), // null = never expires
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
  index('share_link_whiteboard_id_idx').on(table.whiteboardId),
]);

Key decisions:

  1. Slugs as primary keys: Instead of random UUIDs, share links use human-readable slugs like my-architecture-diagram. This makes URLs memorable and shareable.

  2. Nullable expiration: expiresAt can be null, meaning the link never expires. This is the default - you opt into expiration, not out of it.

  3. Cascade deletes: When a whiteboard is deleted, all its share links go with it. No orphaned links.


The whiteboard CRUD is straightforward. The interesting part is the share link management.

Users can optionally name their share links. The slug is generated from the name (or whiteboard title if no name is provided):

// Convert a string to URL-friendly slug
function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')  // Remove special chars
    .replace(/\s+/g, '-')       // Spaces β†’ hyphens
    .replace(/-+/g, '-')        // Multiple hyphens β†’ single
    .replace(/^-|-$/g, '')      // Trim leading/trailing
    .slice(0, 50);              // Limit length
}

// Handle collisions by appending -1, -2, etc.
async function generateUniqueSlug(db, baseSlug: string): Promise<string> {
  if (!baseSlug) baseSlug = 'shared';

  const [existing] = await db
    .select({ id: shareLink.id })
    .from(shareLink)
    .where(eq(shareLink.id, baseSlug));

  if (!existing) return baseSlug;

  // Find next available number
  let counter = 1;
  while (counter <= 1000) {
    const candidateSlug = `${baseSlug}-${counter}`;
    const [exists] = await db
      .select({ id: shareLink.id })
      .from(shareLink)
      .where(eq(shareLink.id, candidateSlug));

    if (!exists) return candidateSlug;
    counter++;
  }

  // Fallback to random suffix
  return `${baseSlug}-${Math.random().toString(36).slice(2, 6)}`;
}

The Create Endpoint

whiteboardRoutes.post("/:id/shares", async (c) => {
  const user = await getAuthUser(c);
  if (!user) return c.json({ error: "Unauthorized" }, 401);

  const whiteboardId = c.req.param("id");
  const body = await c.req.json<{ name?: string; expiresInHours?: number | null }>();

  // Validate expiresInHours
  if (body.expiresInHours !== null && body.expiresInHours !== undefined) {
    if (typeof body.expiresInHours !== "number" || isNaN(body.expiresInHours)) {
      return c.json({ error: "expiresInHours must be a valid number" }, 400);
    }
  }

  const db = drizzle(c.env.DB);

  // Verify ownership
  const [board] = await db
    .select({ userId: whiteboard.userId, title: whiteboard.title })
    .from(whiteboard)
    .where(eq(whiteboard.id, whiteboardId));

  if (!board) return c.json({ error: "Whiteboard not found" }, 404);
  if (board.userId !== user.id) return c.json({ error: "Forbidden" }, 403);

  // Generate slug from custom name or whiteboard title
  const baseSlug = slugify(body.name || board.title);
  let slug = await generateUniqueSlug(db, baseSlug);

  // Calculate expiration (null = never expires)
  let expiresAt: Date | null = null;
  if (body.expiresInHours !== null && body.expiresInHours !== undefined) {
    const hours = Math.max(body.expiresInHours, 1);
    expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
  }

  // Insert with retry for race conditions
  let attempts = 0;
  while (attempts < 5) {
    try {
      await db.insert(shareLink).values({
        id: slug,
        whiteboardId,
        createdBy: user.id,
        expiresAt,
        createdAt: new Date(),
      });
      break;
    } catch (error) {
      if (error.message?.includes("UNIQUE constraint") && attempts < 4) {
        slug = await generateUniqueSlug(db, baseSlug);
        attempts++;
      } else {
        throw error;
      }
    }
  }

  return c.json({ slug, expiresAt: expiresAt?.toISOString() ?? null }, 201);
});

The retry loop handles a subtle race condition: two requests might both check for my-diagram, find it available, then both try to insert. The second one fails with a unique constraint violation, so we regenerate and retry.

The Public Endpoint

Anyone with a share link can view the whiteboard - no authentication required:

// src/worker/api/shared.ts
sharedRoutes.get("/:token", async (c) => {
  const token = c.req.param("token");
  const db = drizzle(c.env.DB);

  // Find the share link
  const [link] = await db
    .select({
      whiteboardId: shareLink.whiteboardId,
      expiresAt: shareLink.expiresAt,
    })
    .from(shareLink)
    .where(eq(shareLink.id, token));

  if (!link) {
    return c.json({ error: "Share link not found" }, 404);
  }

  // Check expiration
  if (link.expiresAt && new Date() > link.expiresAt) {
    return c.json({ error: "Share link has expired" }, 410);
  }

  // Fetch the whiteboard
  const [board] = await db
    .select({
      id: whiteboard.id,
      title: whiteboard.title,
      data: whiteboard.data,
    })
    .from(whiteboard)
    .where(eq(whiteboard.id, link.whiteboardId));

  if (!board) {
    return c.json({ error: "Whiteboard not found" }, 404);
  }

  return c.json({
    ...board,
    expiresAt: link.expiresAt?.toISOString() ?? null,
  });
});

HTTP 410 Gone for expired links is intentional - it tells clients the resource existed but is no longer available, which is semantically correct.


The Frontend Components

The Editor with Share Modal

The editor component now includes a share modal for creating and managing links:

// Share modal state
const [showShareModal, setShowShareModal] = useState(false);
const [shareLinks, setShareLinks] = useState<ShareLink[]>([]);
const [shareName, setShareName] = useState('');
const [expiresInHours, setExpiresInHours] = useState<number | null>(null);

// Create a share link
async function createShareLink() {
  const res = await fetch(`/api/whiteboards/${id}/shares/`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: shareName || undefined,
      expiresInHours,
    }),
    credentials: 'include',
  });
  if (!res.ok) throw new Error('Failed to create share link');
  setShareName('');
  await fetchShareLinks();
}

The modal lets users:

  • Enter a custom name (optional)
  • Choose expiration: never, 1 hour, 6 hours, 24 hours, 2 days, or 7 days
  • See all active links with time remaining
  • Copy links to clipboard
  • Revoke links instantly

The Read-Only Viewer

Shared links render in a dedicated viewer component with view mode enabled:

// src/components/WhiteboardViewer.tsx
export default function WhiteboardViewer({ token }: { token: string }) {
  const [whiteboard, setWhiteboard] = useState<WhiteboardData | null>(null);
  const [timeRemaining, setTimeRemaining] = useState<string>('');

  useEffect(() => {
    fetch(`/api/shared/${token}/`)
      .then(res => {
        if (res.status === 404) throw new Error('Link not found');
        if (res.status === 410) throw new Error('Link expired');
        return res.json();
      })
      .then(setWhiteboard)
      .catch(setError);
  }, [token]);

  // Update countdown every minute for expiring links
  useEffect(() => {
    if (!whiteboard?.expiresAt) return;

    function updateTimeRemaining() {
      const diff = new Date(whiteboard.expiresAt).getTime() - Date.now();
      if (diff <= 0) {
        setTimeRemaining('Expired');
        return;
      }
      const hours = Math.floor(diff / (1000 * 60 * 60));
      const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
      setTimeRemaining(hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`);
    }

    updateTimeRemaining();
    const interval = setInterval(updateTimeRemaining, 60000);
    return () => clearInterval(interval);
  }, [whiteboard?.expiresAt]);

  return (
    <div className="h-screen flex flex-col">
      <header>
        <h1>{whiteboard?.title}</h1>
        <span className="badge">View Only</span>
        {whiteboard?.expiresAt && <span>{timeRemaining} remaining</span>}
      </header>

      <Excalidraw
        initialData={parsedData}
        viewModeEnabled={true}
        UIOptions={{
          canvasActions: {
            export: { saveFileToDisk: true }, // Allow exporting
            loadScene: false,
            clearCanvas: false,
          },
        }}
      />
    </div>
  );
}

Key details:

  • viewModeEnabled={true} locks the canvas - no editing
  • Export is still enabled so viewers can download the diagram
  • The countdown updates live for expiring links
  • Permanent links show no expiration indicator

Challenges and Solutions

Challenge 1: D1’s 1MB Row Limit

Excalidraw supports embedded images as base64. A few screenshots can easily blow past D1’s row size limit.

Solution: Disable image insertion entirely.

<Excalidraw
  UIOptions={{
    tools: {
      image: false, // Avoid D1's 1MB row limit
    },
  }}
/>

For my use case (architecture diagrams, flowcharts), I don’t need images. If you do, you’d need R2 for image storage with references in the JSON.

Challenge 2: Better Auth + D1 Context

D1 bindings are only available inside request handlers, but Better Auth wants a database instance at construction time.

Solution: Factory function pattern.

// src/lib/auth.ts
export function createAuth(env: Env) {
  const db = drizzle(env.DB);
  return betterAuth({
    database: drizzleAdapter(db, { provider: 'sqlite' }),
    // ... config
  });
}

// Called fresh on each request
const auth = createAuth(c.env);
const session = await auth.api.getSession({ headers: c.req.raw.headers });

Challenge 3: React 19 SSR on Workers

React 19’s server rendering imports MessageChannel by default, which doesn’t exist in Cloudflare Workers.

Solution: Vite alias to use the edge-compatible build.

// astro.config.mjs
vite: {
  resolve: {
    alias: {
      ...(import.meta.env.PROD && {
        'react-dom/server': 'react-dom/server.edge',
      }),
    },
  },
}

Challenge 4: Slug Collision Race Condition

Two requests creating links for β€œMy Diagram” simultaneously could both generate the slug my-diagram, then one fails on insert.

Solution: Retry with regeneration on unique constraint violation.

while (attempts < 5) {
  try {
    await db.insert(shareLink).values({ id: slug, ... });
    break;
  } catch (error) {
    if (error.message?.includes("UNIQUE constraint")) {
      slug = await generateUniqueSlug(db, baseSlug);
      attempts++;
    } else throw error;
  }
}

The Cost Breakdown

Excalidraw Plus: $7/month = $84/year (per user)

Self-hosted on Cloudflare:

ServiceFree TierMy Usage
Workers100k requests/day~100/day
D15M rows read, 100k writes/day<1k/day
R210GB storage0 (images disabled)

Total cost: $0

For a team of 5, that’s $420/year saved.


What I Built vs. What I Gave Up

What I have:

  • Cloud-synced drawings accessible anywhere
  • Shareable links with custom slugs (tuo-lei.com/shared/my-diagram)
  • Optional expiration (1 hour to never)
  • Read-only viewer for shared links
  • Auto-save while drawing
  • Full data ownership
  • Zero monthly costs

What I gave up:

  • Real-time collaboration (I rarely need it)
  • Version history (git handles important diagrams)
  • Image embedding (disabled due to D1 limits)
  • Mobile app (web works fine)
  • End-to-end encryption (my server, my data)

For my use case, it’s a perfect trade.


Try It Yourself

The full implementation is live at tuo-lei.com/draw. The key pieces:

  1. D1 schema: Two tables - whiteboard and share_link
  2. Hono API: CRUD endpoints + public share endpoint
  3. React components: Editor with share modal, read-only viewer
  4. Better Auth: GitHub OAuth (or skip auth for public whiteboards)

Quick Setup

# Create D1 database
npx wrangler d1 create whiteboards

# Apply migrations
npx drizzle-kit generate
npx wrangler d1 migrations apply web-app --local
npx wrangler d1 migrations apply web-app --remote

# Deploy
npm run deploy

The whole thing took a weekend. Most of that was figuring out Excalidraw’s internal state format and getting Better Auth working with D1’s request-scoped bindings.


What’s Next

A few things I might add later:

  1. R2 for images: Store base64 images in R2, reference them in the JSON
  2. Collaboration via Durable Objects: WebSocket-based real-time editing
  3. Export to PNG/SVG: Server-side rendering of diagrams
  4. Public galleries: Optionally list shared diagrams publicly

But for now, it does exactly what I need: save my diagrams, share them when needed, pay nothing.


Built with Cloudflare Workers, D1, Hono, Better Auth, and Excalidraw. Zero ongoing costs.