DEV Community

Cover image for No signup, just boxes and arrows: building a free ER diagram editor
erdiagram
erdiagram

Posted on • Originally published at erdiagram.dev

No signup, just boxes and arrows: building a free ER diagram editor

I just wanted to sketch a database schema. Five tables, a couple of foreign keys, nothing fancy.

I opened the usual suspects, and every single one met me with the same wall: create an account, confirm your email, maybe pick a plan, then you can draw a box. For a thirty-second sketch.

So I did the most predictable developer thing imaginable and built my own. It's live at erdiagram.dev — free, and you can start drawing the moment the page loads. No signup, no modal, nothing in the way.

This is the why, plus the three technical decisions I'd actually defend in a code review:

  1. The browser is the backend for guests (so there's no signup wall).
  2. Canvas instead of DOM/SVG for the renderer.
  3. A boring last-write-wins sync protocol instead of CRDTs.

Let's get into it.

1. The one rule: it has to work before you have an account

The whole project hangs on a single principle: the editor works before you sign up.

That sounds trivial, but it shaped a surprising amount of the architecture. A guest needs to draw tables, drag them, connect foreign keys, undo/redo, import SQL — and have all of it survive a page reload. With no user row to hang anything on.

The fix was to treat the browser itself as the backend for guests. Each visitor gets a Guest_ID (a UUID v4 in localStorage) and their whole diagram lives in IndexedDB. No network round-trip, no server record, no "your trial expires in 7 days." The data is theirs, on their machine, for as long as they want it.

If they later decide to sign up, the local diagram migrates to their account in one step. The guest mode isn't a crippled demo — it's the real editor with local persistence instead of cloud sync. Only the genuinely account-dependent features sit behind login (share links, version snapshots, larger table limits).

If you're building a tool, I'd push this hard: "let me just try it" is the most fragile moment in your funnel. Don't spend it on a registration form.

2. Why Canvas instead of SVG or DOM nodes

The first real fork in the road was the renderer.

The web-native instinct is to make each table a <div> (or an SVG <g>) and let the browser lay it out. It's pleasant to write, you get CSS for free, and it works great — right up until someone pastes in a schema with 80 tables and a few hundred relationship lines. Now the browser is reflowing and repainting a massive DOM tree on every drag frame, and the whole thing turns to syrup.

So I went with LeaferJS, a Canvas 2D engine. Tables, columns, relationship lines, anchors, the grid background — all painted to one canvas. The tradeoff is real, and worth being honest about:

  • You lose the free stuff: no CSS, no DOM accessibility tree, no "just add a :hover in a stylesheet." You re-implement hit-testing, text layout, and z-ordering yourself.
  • You gain a flat render surface that doesn't care whether there are 5 shapes or 5,000. Pan and zoom are a transform on one node. Dragging moves objects in a scene graph instead of thrashing layout.

For a diagramming tool — many objects, constant spatial manipulation, smooth pan/zoom as table stakes — Canvas was right. For a form-heavy app it would've been exactly wrong. Pick the renderer that matches the shape of the interaction, not the one that's in fashion.

That core editor ended up isolated enough to be its own module: parsers (SQL DDL and DBML, both directions), layout algorithms (tree / force / grid), PNG and SVG exporters, and the canvas engine — all decoupled from the SvelteKit app around it.

3. The stack, boring on purpose

The app shell is SvelteKit (Svelte 5). Svelte 5's runes ($state, $derived, $effect) made the reactive glue between the canvas and the surrounding UI — toolbar, side panel, the save-status dot — easy to reason about without dragging in a state-management library.

Everything else is deliberately unexciting, which I count as a feature:

  • SQLite via better-sqlite3 — synchronous, embedded, no separate DB process. For a single-region app it's plenty, and it makes deploys trivial.
  • A custom Node server serving both HTTP and WebSocket from one process.
  • JWT in an HttpOnly cookie for auth, bcrypt for passwords, email codes via Resend.
  • Fly.io, single region, one SQLite volume. The whole monthly bill is single digits.

I call out the cost because "indie tool that needs $200/month of infra to break even" is a trap. Boring, cheap, boring again.

4. The part I rewrote: real-time sync

For signed-in users, diagrams live in the cloud and sync over WebSocket with autosave. This is the piece I overthought first, then deliberately under-thought into something that actually ships.

No operational transforms, no CRDTs. I'm one person, and the realistic concurrency here is "same user, two tabs" or "two people glancing at a diagram together" — not Google-Docs-scale co-editing. So the protocol is intentionally dumb:

  • The server is the single authority for a monotonic revision number (rev).
  • A client commits a full snapshot tagged with the baseRev it started from.
  • If baseRev === serverRev, the server accepts, bumps rev, and broadcasts to peers.
  • If baseRev < serverRev (someone committed in between), the server rejects with a conflict and the current state. The client adopts the new rev and re-pushes its own snapshot — last write wins, with an automatic rebase.

There's exactly one in-flight commit at a time; edits that pile up during the round-trip coalesce and go out on the next tick. And since a browser can die mid-commit, the pending snapshot is also buffered in IndexedDB — a crash before the ack doesn't lose the edit.

Is last-write-wins "correct" in the academic sense? No. But it's predictable, it's a few hundred lines instead of a few thousand, and it fits how the tool is actually used. Picking the simplest model that matches your real concurrency — instead of the most impressive one — was one of my better calls.

The two features I almost cut

Both turned out to matter more than I expected:

Internationalization. I added 10 languages (English, Chinese, Japanese, Korean, Portuguese, Spanish, German, French, Russian, Indonesian) almost on a whim. Developer-tool markets in non-English languages are far less crowded, and once the i18n plumbing exists, the marginal cost is just translation strings.

Import/export that round-trips. SQL DDL in, diagram out; diagram out, SQL or DBML back. The editor isn't a box you get stuck inside — paste your existing schema, get a picture, tweak it, take the SQL with you. Tools that hold your data hostage feel hostile, and developers notice.

What's next

There's a /templates section now — ready-made schemas for e-commerce, blogs, SaaS, hospitals, schools, booking systems — each one a click away from opening in the editor. On the roadmap: PDF export, virtualized rendering for genuinely huge diagrams, and React/Vue wrappers for the core library.

But the thing I'm still proudest of is the boring one: you land on the page and you start drawing. No account. No friction. Just boxes and arrows, the way it should be.

Kick the tires at erdiagram.dev — and I'd genuinely love to hear what breaks. Drop a comment, especially if you throw a gnarly schema at it and the auto-layout does something ridiculous.

Thanks for reading. Back to the canvas.

Top comments (0)