DEV Community

Varun Krishnan
Varun Krishnan

Posted on

I Built a PostgreSQL Schema Visualizer With Pure TypeScript SVG - No Diagramming Library

The Problem

I manage a few side projects with PostgreSQL databases. Every time I add a table, change a column, or refactor a relationship, I have to update my ER diagram.

I was using draw.io. It works. But it's manual. And it's tedious.

Open the file. Drag a new box. Connect the lines. Repeat for every schema change. It takes 30 minutes to an hour, it gets stale the moment someone commits a migration, and then you're back to answering "wait, what does the schema actually look like?"

A coworker asks you that question. You open pgAdmin. You click through tables. You mentally assemble the picture. It's slow and it's only in your head.

There had to be a better way.

The Solution

dbdiagramr.

Paste your PostgreSQL connection string. Get an interactive ER diagram in under 10 seconds. Pan, zoom, hover over tables to trace relationships, drag tables around. Export as SVG or PNG.

Er-diagram creator named dbdiagram

How It Works

The whole flow is four steps:

  1. You paste your connection string
  2. A server API introspects information_schema for tables, columns, keys
  3. A pure TypeScript function generates an SVG diagram
  4. You pan, zoom, and explore

No Canvas. No heavy libraries. Just SVG.

The data model is deliberately small - four types:

type Column = {
  name: string;
  type: string;
  nullable: string;
  default: string | null;
  isPrimaryKey: boolean;
};

type ForeignKey = {
  column: string;
  referencesTable: string;
  referencesColumn: string;
};

type Table = {
  name: string;
  columns: Column[];
  foreignKeys: ForeignKey[];
};

type Schema = {
  tables: Table[];
};
Enter fullscreen mode Exit fullscreen mode

Every diagram is just a Schema object. Everything else is rendering.

The Introspection

The server connects to your database and reads only the structure — never your data. For each table it runs three queries in parallel against information_schema:

-- Columns
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position;

-- Primary keys
SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'PRIMARY KEY'
  AND tc.table_schema = 'public'
  AND tc.table_name = $1;

-- Foreign keys
SELECT kcu.column_name,
       ccu.table_name AS foreign_table_name,
       ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
  ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND tc.table_schema = 'public'
  AND tc.table_name = $1;
Enter fullscreen mode Exit fullscreen mode

These run server-side in a single API route, get assembled into a typed Schema object, and get sent back as JSON. The client never touches the database directly.

Why SVG (and not Canvas)

I chose pure SVG for three reasons:

  1. Accessible by default - text is text, colors are colors. No rasterization needed.
  2. Exportable for free - SVG is the document. Serialize it and you're done.
  3. No dependencies - the diagram generator is a pure function: schema in, SVG string out.
function generateDiagramSVG(schema: Schema): string {
  // grid layout
  // table cards → <g> elements
  // foreign keys → <line> with arrow markers
  return parts.join("\n");
}
Enter fullscreen mode Exit fullscreen mode

That single function produces the whole diagram server-side or client-side. The interactive React component sits on top and layers on pan, zoom, drag, and hover highlighting roughly 400 lines.

For PNG export, I serialize the current SVG (with tight viewBox and inlined styles), draw it to a canvas, and let the browser encode it:

const svgString = serializeSvg(svgElement, positions);
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const img = new Image();
img.src = URL.createObjectURL(blob);
// draw to canvas, canvas.toBlob → PNG download
Enter fullscreen mode Exit fullscreen mode

The Hard Part: Foreign Key Routing

The most interesting problem wasn't drawing the boxes - it was deciding where the connection lines exit each table.

A foreign key connects column A in table 1 to column B in table 2. But which edge of table 1 should the line leave from? If table 1 is above table 2, the line exits the bottom. If they're side by side, it exits the right side. If one is much taller, it gets complicated.

The naive version produced lines that crossed through unrelated tables and made the diagram unreadable.

The fix was a four-way conditional that decides based on relative position:

if (bottom <= tgt.y) {
  // source sits above target → line exits bottom, enters top
  x1 = cx; y1 = bottom;
  x2 = tCx; y2 = tTop;
} else if (pos.y >= tgt.y + tgt.h) {
  // source sits below target → line exits top, enters bottom
  x1 = cx; y1 = pos.y;
  x2 = tCx; y2 = tgt.y + tgt.h;
} else if (pos.x + pos.w <= tgt.x) {
  // source sits left of target → line exits right edge
  x1 = right; y1 = cy;
  x2 = tLeft; y2 = tCy;
} else if (pos.x >= tgt.x + tgt.w) {
  // source sits right of target → line exits left edge
  x1 = pos.x; y1 = cy;
  x2 = tRight; y2 = tCy;
}
Enter fullscreen mode Exit fullscreen mode

Simple on paper. It still took a while to get right because the cases interact - and the fallback (overlapping boxes) matters more than you'd think.

Lessons Learned

  • A tiny data model wins. Four types. The whole app is built on them, and it keeps the mental overhead near zero.
  • The grid layout breaks at - 50 tables. A static grid is fine for most schemas but gets cluttered on big ones. Next step would be a force-directed layout or at least smarter clustering.
  • Shipping beats perfecting. I could have kept polishing the routing algorithm forever. Shipping it and getting real feedback taught me more than any extra week of tweaking.

Honest Limitations

I'll say the same thing I'd tell a friend:

  • PostgreSQL only. No MySQL, SQLite, or MongoDB yet.
  • Public schema only - custom schemas aren't supported yet.
  • No real-time sync. You generate, you export, you're done.
  • Static grid layout struggles past ~50 tables.

Tech Stack

  • Next.js 14 (App Router)
  • TypeScript
  • Tailwind CSS
  • pg for PostgreSQL
  • Pure SVG for diagrams
  • MIT licensed, fully self-hostable

Top comments (0)