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:
-
Slugs as primary keys: Instead of random UUIDs, share links use human-readable slugs like
my-architecture-diagram. This makes URLs memorable and shareable. -
Nullable expiration:
expiresAtcan be null, meaning the link never expires. This is the default - you opt into expiration, not out of it. -
Cascade deletes: When a whiteboard is deleted, all its share links go with it. No orphaned links.
The Share Link API
The whiteboard CRUD is straightforward. The interesting part is the share link management.
Creating Share Links with Custom Slugs
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:
| Service | Free Tier | My Usage |
|---|---|---|
| Workers | 100k requests/day | ~100/day |
| D1 | 5M rows read, 100k writes/day | <1k/day |
| R2 | 10GB storage | 0 (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:
- D1 schema: Two tables -
whiteboardandshare_link - Hono API: CRUD endpoints + public share endpoint
- React components: Editor with share modal, read-only viewer
- 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:
- R2 for images: Store base64 images in R2, reference them in the JSON
- Collaboration via Durable Objects: WebSocket-based real-time editing
- Export to PNG/SVG: Server-side rendering of diagrams
- 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.