Point it at your database. Get a working API.
Generaptr reads the tables of a MySQL or PostgreSQL database and writes an Express 5 + Drizzle API, with validation, an OpenAPI 3.1 document that describes it, and a web app to browse and edit the rows.
npx generaptr-cli
Node 22 or later. The CLI asks for the connection, then writes the API and the web app.
CREATE TABLE users ( id serial PRIMARY KEY, email varchar(50) NOT NULL UNIQUE, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now() );
src/db/schema.js and its routesexport const users = pgTable("users", { id: serial("id").primaryKey(), email: varchar("email", { length: 50 }).notNull(), active: boolean("active").notNull() .default(sql.raw("true")), createdAt: timestamp("created_at", { withTimezone: true }) .notNull().default(sql.raw("now()")), }, (table) => [ unique("users_email_key").on(table.email), ]);
The generated API
api/ ├── package.json # exact versions of express, drizzle-orm, the driver, zod ├── drizzle.config.js ├── .env.example # DATABASE_URL ├── openapi.json ├── scripts/baseline.js # npm run db:baseline └── src/ ├── index.js ├── app.js # one router per table ├── db/schema.js # one Drizzle table per database table ├── db/relations.js # both sides of every foreign key └── routes/users.js # list, get, create, update, delete
- Each table with one primary key gets five routes. Link tables with a composite key get none.
- The list and get routes take
withto include related rows:GET /users/1?with=accounts,groups. A foreign keyaccounts.user_idgivesaccounts.userandusers.accounts, and a link table such asusers_groupsgivesusers.groups. A nested list returns at most 100 rows, andwithreaches only tables that have routes. - zod schemas from
drizzle-orm/zodcheck each request body. A bad body gets 400 with the list of problems. - Database errors get a generic 500. The response never shows the database message.
- Defaults come from the database, so a client can leave out the columns that the database fills. Identity and generated columns are not in the request bodies.
src/db/schema.jsrebuilds the database it came from: keys with their names and actions, CHECK constraints, every index, generated and identity columns, collations, enums, and sequences. A test rebuilds a schema from it and compares the catalogs of both databases.
cd api
cp .env.example .env # set DATABASE_URL
npm start
The generated web app
web/ ├── package.json # exact versions of react, vite, tailwindcss, @json-render/* ├── vite.config.js # forwards /api to API_URL ├── .env.example # API_URL └── src/ ├── pages.json # one json-render spec per page, from openapi.json ├── main.jsx # renders the specs; the same for every API ├── theme.css # colors and radius for light and dark mode └── index.css
- Each table with routes gets a list with paging, a detail page with its related rows, and forms to create and edit a row. A filter in the sidebar finds a table.
- The pages are json-render specs drawn with shadcn components and Tailwind. Edit
src/pages.jsonto change a page. - A foreign key links to its row on a detail page, and is a select of rows in a form. JSON values show as a tree that folds.
- Forms check required fields, lengths, and JSON as you type, and show the API's message when the API rejects a row.
- A switch in the sidebar sets light mode, dark mode, or the system mode. To change the colors, edit
src/theme.css, or paste a theme from ui.shadcn.com/themes. - The browser calls
/apion the web app's own server, which forwards to the API, so the API needs no CORS.
cd web
cp .env.example .env # set API_URL
npm run dev
Read from a database or a spec
A database
MySQL through information_schema, PostgreSQL through pg_catalog. The user needs read access only.
An OpenAPI 3.1 file, in YAML or JSON
A file that generaptr wrote reads back to the same tables. Other files map by convention: each object schema becomes a table.
From either source, generaptr writes the API and the web app, or only openapi.json. An OpenAPI file can target MySQL or PostgreSQL, so you can move a schema from one engine to the other.
Migrations
The generated API uses Drizzle Kit. Migrations go forward only: to undo a change, write a new migration.
npm run db:generate # writes SQL from src/db/schema.js npm run db:migrate # applies the SQL
npm run db:baseline once. It writes the first migration and records it as applied without running it, because those tables exist there already. A new, empty database skips this step: db:migrate creates the tables.
Column types
| MySQL | PostgreSQL |
|---|---|
integer types, signed or unsigned | smallint, integer, bigint, serial and identity |
tinyint(1) as boolean | boolean |
decimal(p,s) | numeric(p,s) |
varchar(n), char(n) | character varying(n), character(n) |
enum(...) | enum types |
text, date and time types with fractional seconds, json, float, double | text, date, time, timestamp with precision and time zone, json, jsonb, uuid, real, double precision |
one-dimensional arrays, such as text[] | |
binary, varbinary, blob types | bytea |
Binary columns carry base64 strings in JSON. Any other type, such as tsvector, inet, money, bit(3), MySQL set(...), or a multi-dimensional array, becomes a Drizzle custom type with the exact SQL type.
Generaptr stops with Unsafe name on a table or column name that has characters other than letters, digits, and _.
Use it from code
The generaptr package holds the same steps as the CLI.
import { handlers, operations } from 'generaptr'; const db = new handlers.Postgres({ host: '127.0.0.1', port: 5432, user: 'postgres', password: 'secret', database: 'shop', schema: 'public', }); const schema = await db.readSchema(); await db.close(); await operations.api.generateApi('./shop-api', info, schema); await operations.web.generateWeb('./shop-web', info, schema); await operations.spec.generateSpec('./spec', info, schema);
info holds the name, version, description, author, and license of the generated project. The package README lists every export.
Known limits
Drizzle cannot write these, so generaptr stops with an error when a schema has one:
- PostgreSQL:
INCLUDEcolumns on an index, unique indexes withNULLS NOT DISTINCT, exclusion constraints, virtual generated columns, and a sequence of a type other thanbigintthat no column owns. - MySQL:
FULLTEXTandSPATIALindexes, andenumorsetvalues with a backslash.
Generaptr reads one PostgreSQL schema, public by default. It does not read views, functions, triggers, or comments.
Coming from 2.x? Keys now belong to the table, identity columns read as identity, and MySQL binary columns carry base64. The README lists the steps.

