A React ERD viewer for any database on your machine.
Inspired by Truss, which draws a Laravel app's migrations — dbui drops the framework requirement. It discovers the database servers already running on your machine, lets you pick one, lists its databases, and draws the schema you choose as an interactive crow's-foot diagram. No migrations to parse, no config, no project layout to conform to: point it at anything PostgreSQL or MySQL/MariaDB it can reach.
dbui/
server/ Express API - discovery, connections, introspection (PostgreSQL + MySQL/MariaDB)
web/ React + Vite UI - SVG diagram canvas, table index, details panel
npm install
npm run dev # API on :4477, UI on http://localhost:5477npm run dev runs both processes; Ctrl-C stops both. For a single-port build:
npm start # builds web/ and serves UI + API from http://127.0.0.1:4477The API binds to 127.0.0.1 only.
| Variable | Effect |
|---|---|
DBUI_PORT |
API port (default 4477) |
DBUI_POOL_MAX |
connections per database (default 4) - lower it for servers with a tiny max_connections |
DBUI_DATABASE_URL, DATABASE_URL, POSTGRES_URL, MYSQL_URL, PG* |
pre-seed a discovery candidate |
Tests (npm test) cover the PostgreSQL catalog queries — against a real Postgres running in-process via
PGlite, no server needed — plus type formatting, the scale controls (scope, detail, focus,
neighbour cap, bundling, hub folding) and both layout modes.
-
Discover. The start screen lists database servers found on this machine, from five independent sources:
Source How docker containerdocker psfor images that look like a database; the published host port, and the credentials from the container's own env vars (POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB,MYSQL_*). Stopped containers are listed too, marked as not running.local processlsofLISTEN sockets owned by apostgres/mysqldprocess — catches non-standard ports.unix socket/tmp/.s.PGSQL.*,/var/run/postgresql/*, Homebrew MySQL socket paths.open portTCP probe of the default ports (5432-5435, 6432, 54320, 54322; 3306-3308). Add more in the "Also scan ports" box. environmentDATABASE_URL,POSTGRES_URL,MYSQL_URL,PG*variables in the API process's environment.Anything not found — a remote host, an SSH tunnel — goes through Connect manually.
-
Pick a database. After connecting, dbui lists every database you can connect to, with owner, size and encoding.
-
Read the diagram. Tables become cards (type / column / key, exactly like Truss), foreign keys become crow's-foot edges labelled with the constraint name.
Clicking a card selects it: everything not directly related dims, and the details panel lists the columns, both directions of every foreign key, and the indexes.
No server handy? Open the sample schema draws a bundled blog schema offline. It is generated from
server/test/fixture.sql — the same fixture the tests introspect — by npm run sample --workspace server, so
the offline diagram is real introspection output rather than hand-written JSON.
A 344-table schema drawn all at once, every column, every wire, is a hairball — it fits at 24% zoom, where no label is legible. Four controls exist to prevent that, and on a large database they are all on by default.
Detail level (top right) decides how much of each table is drawn:
| Level | Card | Layout |
|---|---|---|
overview |
name only | its own tight grid per schema — 116 tables land at ~80% zoom instead of 24% |
keys |
primary, foreign and unique columns | full-size layout |
full |
every column (capped, see below) | full-size layout |
auto |
follows the zoom between keys and full |
full-size layout |
auto deliberately never selects overview: that level has its own layout size, and a level that follows the
zoom and changes the layout feeds back into the zoom that fitting produces. keys and full share one layout
— key columns are a subset of all columns — so crossing that threshold never moves a card. Cards always reserve
their full-detail footprint, so no detail level can make two of them overlap.
Scope — the schema picker next to the database name. A database with more than 60 tables opens on its
largest schema. Foreign keys that leave the scope are not drawn as wires across the canvas; they become chips on
the card that owns them (→ persons, ← 36 refs). Clicking a chip focuses that table.
Focus — double-click a card (or a table in the list, or press E) to draw only that table and everything
within 1–3 foreign-key hops, ignoring the scope. It is laid out in layers, left to right: the focused table, then
each hop beside it in columns sized to the screen. Esc clears it. This is the way to read a schema this size.
Most real schemas have one — a persons or users table that a hundred others point at. No arrangement of 100
cards is readable, and a ring is just a starburst, so a focus view caps what it draws and aggregates the rest:
- The cap. Twelve neighbour cards (adjustable under
view→ neighbours drawn), chosen nearest-hop first, then by how connected they are, then alphabetically. - Bundles. Everything withheld becomes a dashed card per schema carrying a count, sample table names and one
aggregate wire (
11 tables). A schema that brings dozens splits by name prefix instead —audit_*,invoice_*— with the tail kept inside that schema, never a nameless "other". Each withheld table belongs to exactly one bundle, anchored on the drawn table closest to the focus. - Growing the view. Clicking a bundle draws its members; the counts and remaining bundles adjust, so the
diagram grows only where you asked.
reseton the focus chip undoes it. The details panel lists the same relationships grouped by schema and collapsed, with draw on any row or draw all on any group. - Bus routing. A table with five or more drawn wires no longer fans them out: they leave the card, join one vertical trunk and branch off horizontally at each target's row, so twelve relationships read as one line with twelve branches.
So a 100-relationship table is 12 cards plus about 6 bundles — and the same rule holds at 1000.
Hubs — in most schemas a handful of tables carry the bulk of the relationships, and they produce most of the
spaghetti. Tables at or above the hub threshold (20 relationships, adjustable) are marked with an amber
badge, and the hubs toggle folds their wires into chips. The layout still knows about those relations, so
folding the wires away never moves a table.
Also on by default above 60 tables: constraint labels off, tables with no relationships hidden, and a cap of 22
rows per card (+ N more opens the rest). The minimap (bottom left) shows where you are.
- Notation — the child (
many) end carries the crow's foot; a nullable foreign key gets the "zero or many" circle. The parent (one) end carries the double tick. Hovering an edge shows the constraint,ON DELETEandON UPDATE. - Cards — the badge counts relationships (amber = hub); an amber dot flags a table with no primary key.
enumcolumns are underlined (hover for the values); the chevron collapses a table to its header. - Navigate — drag the background to pan, wheel to zoom (cursor-anchored), drag a card to move it, click to
select, double-click to focus. Selecting dims everything not directly related. Positions are remembered per
database, scope and detail level, so a diagram you have tidied up stays tidy;
arrangere-runs the layout andreset saved layout(inview) forgets them. - Layout — force-directed per connected component inside each schema (springs along foreign keys, repulsion over a spatial hash, then rectangle separation), schemas shelf-packed with labelled frames. Deterministic: the same schema always draws the same picture.
- Keys —
/search,ffit,efocus the selection,+/-zoom,Escclear focus then selection. viewmenu — short type names (varchar_255) vs. the engine's own (character varying(255)), include views, show unrelated tables, minimap, panels, neighbours drawn, hub threshold, row cap, reset layout.- Theme —
dark/lightin the top bar, remembered across sessions.
Passwords never reach the browser and are never written to disk. Anything discovery finds (container env,
DATABASE_URL) stays in the API process, keyed by candidate id; the UI connects by that id and afterwards holds
only an opaque connection id. Passwords you type are used for that connection and kept in memory until you
disconnect or the API stops. Discovery probes ports and reads container metadata — it never guesses passwords.
Only localStorage state is cosmetic: diagram positions and view preferences.
| Route | Purpose |
|---|---|
GET /api/discover?ports=5455,15432 |
candidates + notes (e.g. "docker daemon unreachable") |
POST /api/connect |
{candidateId?, engine, host, port, socketPath?, user, password?, database?} → connection + databases |
GET /api/connections/:id/databases |
re-list databases |
GET /api/connections/:id/schema?database=x&schemas=public,app |
normalized schema |
DELETE /api/connections/:id |
close the connection and its pools |
The schema payload is engine-independent:
PostgreSQL is read from pg_catalog (types via format_type, comments, enum labels, index definitions, row
estimates from reltuples); MySQL/MariaDB from information_schema. Adding an engine means one module in
server/src/engines/ exporting createPool, serverVersion, listDatabases, introspect and
bootstrapDatabases — nothing in the UI changes.
Requires PostgreSQL 10+ (uses attidentity) or MySQL 5.7+/MariaDB 10.2+, and Node 20+.
Inspired by Truss, which renders a Laravel project's migrations as an ERD. dbui takes the same reading experience — cards, crow's feet, constraint labels — and points it at live servers instead, so it works on any PostgreSQL or MySQL/MariaDB database on the machine, Laravel or not.
Built with Claude Code.
MIT.





{ "engine": "postgres", "serverVersion": "12.5", "database": "app", "schemas": ["public"], "tables": [{ "id": "public.posts", "schema": "public", "name": "posts", "kind": "table", "rowEstimate": 1240, "sizeBytes": 81920, "comment": null, "columns": [{ "name": "user_id", "type": "bigint", "nullable": false, "default": null, "autoIncrement": false, "enumValues": null, "isPrimary": false, "isUnique": false, "foreignKeys": [{ "constraint": "posts_user_id_fkey", "table": "public.users", "column": "id" }] }], "indexes": [{ "name": "posts_pkey", "unique": true, "primary": true, "columns": ["id"] }], "primaryKey": ["id"] }], "relations": [{ "id": "public.posts.posts_user_id_fkey", "name": "posts_user_id_fkey", "from": { "table": "public.posts", "columns": ["user_id"] }, "to": { "table": "public.users", "columns": ["id"] }, "onDelete": "CASCADE", "onUpdate": "NO ACTION", "optional": false, "selfReference": false }] }