Where the story starts
I built dbdiagramr paste a PostgreSQL connection string, get an interactive ER diagram in under 10 seconds. The first version rendered everything as pure SVG I generated by hand. No canvas, no diagram library. That was a deliberate choice, and it worked.
But as I pushed it from "a demo" to "a product" with a schema library and homepage previews, the hand-rolled renderer started fighting me. So I swapped the rendering engine for React Flow v12 + dagre. This is that migration what broke, what I chose, and what the trade-offs actually look like in production.
The goal
- Interactive diagrams on the homepage demo and every
/schema/[slug]detail page - Hover a table → unrelated tables dim to 15% opacity so you can trace foreign-key relationships
- The same diagram engine for static (SSG) pages and the client-side interactive canvas
- PNG + SVG export that matches what's on screen
- Keep pages fully static-server-rendered with a client hydration layer on top
What the first version did (and where it strained)
The MVP rendered a static grid with straight-line connectors. It was:
- Exportable for free - SVG is the document, serialize and you're done
-
Zero dependencies - one pure function,
schema in → SVG string out - Easy to reason about - four TypeScript types underpin the whole app
The first pain was foreign-key routing. A clean diagram won't draw a line from table A to table B if it slices through three unrelated tables. My first pass was a four-way conditional that picked which edge to exit from based on relative position (above/below/left/right). It worked for small schemas and got fiddly as schemas grew. I already knew I'd revisit it.
The real push, though, was interactivity and maintainability. To get hover-to-trace, drag, pan, zoom, and the relationship tracing, I was going to bolt a lot of custom logic onto raw SVG. React Flow already solves that nodes, edges, viewport, minimap, controls and it's MIT licensed.
Why dagre (and not the obvious alternative)
When you need a layout engine inside a diagram tool, the first name everyone mentions is ELK (via elkjs). I seriously considered it:
- Great hierarchical layouts out of the box
- Nice output
But there's one hard blocker for dbdiagramr: it's MIT-only. ELK is licensed under EPL-2.0 (and GPL-3.0 in places). Mixing that into an MIT product means taking on a license I don't want to reason about for a small open-source tool.
So: dagre. It's:
- MIT - same license as the project
- Synchronous - runs in static-generation with zero async plumbing (all four schema pages are built at build time, so dagre's sync API keeps things simple)
- Ships its own TypeScript types - no
@types/dagreneeded
That combination was worth far more than a slightly better layout. The constraint turned an easy pick into the right pick.
import dagre from "@dagrejs/dagre";
export function layoutSchema(schema: Schema): Record<string, Rect> {
const g = new dagre.graphlib.Graph();
g.setGraph({ nodesep: 60, ranksep: 90, marginx: 40, marginy: 40, rankdir: "LR" });
// add each table node, sized from its columns
for (const table of schema.tables) {
const { width, height } = tableSize(table);
g.setNode(table.name, { width, height });
}
// foreign keys → edges
for (const table of schema.tables) {
for (const fk of table.foreignKeys) {
g.setEdge(table.name, fk.referencesTable);
}
}
dagre.layout(g);
const layout: Record<string, Rect> = {};
for (const table of schema.tables) {
const node = g.node(table.name);
layout[table.name] = { x: node.x, y: node.y, w: node.width, h: node.height };
}
return layout;
}
One renderer, two outputs
The key architectural move was keeping a single layout source of truth and letting two layers read from it:
-
renderDiagramSVG(schema, layout)- produces the static SVG that SSG pages and the PNG export use -
<SchemaDiagram schema={...} />- a React Flow canvas that consumes the same dagre layout, so the interactive diagram matches the static one pixel-for-pixel
That one function is what guarantees the homepage, the schema library thumbnails, and the detail-page interactive canvas all agree. No drift between "what you see" and "what you export".
The interactive layer
On top of dagre's layout, the React Flow layer gives:
- Hover-to-trace - on hover, unrelated tables drop to 15% opacity (related stay at 100%); edges animate so you can follow the foreign key through the diagram
-
Custom nodes - a
TableNodewith row-anchored handles: FK columns get a source handle on the right, referenced columns a target handle on the left, with PK/FK badges -
Cardinality on edges -
1and*badges rendered via an edge label layer - Viewport extras - background zoom, minimap, and a download panel exporting SVG or PNG (PNG is rasterized at 2× for crisp export)
All of it sits on dagre's layout rankdir: LR, 60px node spacing, 90px rank separation so related tables end up grouped left-to-right instead of scattered.
What a real schema looks like when you do it
The site ships a free schema library rendered this way real public schemas introspected into the exact Schema type:
- Supabase auth - 7 tables, 3 foreign keys
- NextAuth.js / Auth.js - 4 tables, 2 relationships
- Laravel 11 default - 7 tables
- Django auth - 9 tables, 9 relationships
The big one, Django (9 tables, 9 relationships), is nearly a star topology most tables reference the auth user table. dagre handles it fine; the lesson is to let a real layout engine deal with that instead of my hand-rolled routing conditional.
Honestly, what did it cost?
- A static model is simpler. The pure-SVG version truly was dependency-free. React Flow pulls in more code for edge routing, viewports, and type plumbing. That's a real trade, and I don't regret making it.
- License is a design constraint. The MIT-only requirement is what made dagre the right call over ELK and it was worth reasoning at the outset rather than mid-refactor.
But what it bought:
- Interactive relationship tracing the pure-SVG version never had the kind of thing I'd have spent weeks hand-building on raw SVG
- One layout, two outputs an identical static renderer and canvas that never drift
- Export that always matches the screen SVG or 2× PNG, straight from the live view
Honest limitations (still)
- PostgreSQL only - no MySQL, SQLite, or MongoDB yet
- Public schema only - custom schemas aren't supported yet
- Real-time sync is still not a thing - you generate, you export, you're done
- dagre handles the schema sizes here fine; at hundreds of tables I'd look at force layouts (and re-graph sync)
The takeaway
If you're drawing relationships, don't hand-roll the canvas/network code when a purpose-built, MIT-licensed library exists. Let the library own the viewport and interaction; you keep the part that matters a small, typed data model and a single renderer that powers both static and interactive output. My first instinct was "I can just draw this myself." It took a week of wiring to realize the library was doing me a favor the payoff is interactivity I'd have hand-built for far longer.
Tech Stack
- Next.js 14 (App Router)
- TypeScript
- Tailwind CSS
-
pgfor PostgreSQL -
@xyflow/react(React Flow v12) for the canvas -
@dagrejs/dagrefor layout (MIT) - MIT licensed, fully self-hostable