gen
Generate database types, typed models, relation metadata and validators.
better-supabase gen [--check] [--watch] [--snapshot <file>] [--db-url-stdin | --project-ref <ref>]
better-supabase gen --metadata <path|-> [--emit <file> | --out <dir> [--check]]gen introspects your database with
@supabase/postgrest-typegen,
the same generator behind supabase gen types typescript, and writes:
database.types.ts, whatsupabase gen typesprints for the same schema (CI checks this against the Supabase CLI), plus aComputedFieldskey on each table and view that names its computed fields, socreateClient<Database>()works as usual;- the main module (
output), with models in your casing, enum and CHECK constants, typed constraint names, and theschemaobject; - the runtime metadata next to it (
generated.meta.js, withgenerated.meta.d.ts): tables, columns, relations with foreign key actions, and functions. Relations follow each foreign key to the table it references; the copies PostgREST lists for views over that table are left out, so adding a view never renames another table's relations. It is plain JavaScript typed asSchemaMeta, so TypeScript doesn't check a large object literal in every program that imports the main module. Each table and function is one line of JSON, which keeps the module small to load and a schema change visible per entry in a diff. Commit both files with the main module; - one file per configured generator (
zod(),valibot(),jsonSchema(),standardSchema()); - with
readSetsconfigured, theread-setsSQL module file: one function per read set.genimports those modules after writing the main module, since they import it.
It doesn't need Docker or the Supabase CLI: it only needs a way to run SQL.
Files are only rewritten when their contents change, so watchers and bundlers
don't rebuild for nothing. Line endings don't count as a change: a checkout
with core.autocrlf keeps its \r\n files, and --check passes on them.
database.types.ts is formatted with oxfmt,
an optional peer. Without it, gen writes the file unformatted and says so;
install it with pnpm add -D oxfmt. better-supabase accepts any oxfmt from
0.66.0 up to, but not including, 1.0. @supabase/postgrest-typegen pins exactly 0.66.0 as its
peer, the version that formats like supabase gen types, so pnpm
warns about a newer oxfmt until you allow it in pnpm-workspace.yaml:
peerDependencyRules:
allowedVersions:
"@supabase/postgrest-typegen>oxfmt": "0.71.0"Names are sorted by code point, not by locale, so every machine writes the same files.
Introspection cache
Before reading a database, gen runs one query that hashes the system
catalogs (tables, columns, constraints, policies, functions, grants, comments,
role memberships and role settings; planner statistics are left out). When the hash, the
schemas, the better-supabase version and the pinned typegen version match the
last run, it reuses the
snapshot cached in node_modules/.cache/better-supabase and skips
introspection. Deleting that folder clears the cache.
--watch keeps one connection open and runs that query every --interval
milliseconds, so it reads the full schema only after a change. It also checks
the config file's modification time, and when the file changes it loads it
again and regenerates; a config that fails to load is reported once and kept
until the next edit fixes it. Each run imports the readSets and realtime.policies
modules again, with the project files they import (such as the main module
the run wrote); packages in node_modules load once. Ctrl-C ends the
connection, including a query in flight. Connecting gives up after 10 seconds,
and each introspection query after 2 minutes.
Options
| Option | Effect |
|---|---|
--check | Writes nothing; exits 1 when a generated file is out of date and prints a diff of each one. Use it in CI. |
--watch | Regenerates when the schema or the config file changes (polls every --interval ms, default 2000). |
--snapshot <file> | Reads a saved snapshot instead of connecting. |
--db-url-stdin | Reads the connection string from stdin; overrides the config, $DATABASE_URL and $SUPABASE_DB_URL. |
--project-ref <ref> | Reads a hosted project through the Management API. |
--metadata <path|-> | Reads a GeneratorMetadata document (- for stdin) and prints one file on stdout. See From a GeneratorMetadata document. |
--emit <file> | With --metadata, the file to print: schema (default), types, zod, valibot, json-schema or standard-schema. |
--out <dir> | With --metadata, writes every generated file into this directory instead of printing one. |
With --json, gen prints { "tables", "written" }, and gen --check
prints { "upToDate", "stale" }.
A connection string holds the database password, so no command takes one as
an argument, where it would land in shell history and the process list. Set
$DATABASE_URL or $SUPABASE_DB_URL, or pipe it in:
printf %s "$PROD_DB_URL" | better-supabase gen --check --db-url-stdinWhere the schema comes from
In order:
--snapshot,--db-url-stdinor--project-ref;source.snapshotin the config, when none of those flags is passed;source.dbUrlorsource.projectRefin the config;- a Supabase Lite project's
[db] driverinsupabase/config.toml(turn this off withsource.lite: false); $DATABASE_URL;$SUPABASE_DB_URL, the name the Supabase CLI andbetter-supabase envuse;- the local stack, on the
[db] portfromsupabase/config.toml(54322 by default).
On a Lite project, the postgres driver reads its [db] url. The pglite
and sqlite-postgres drivers replay supabase/migrations and then the
declarative schema files into an in-memory PGlite with Lite's auth schema,
and introspect that, so gen needs no running server. The bare sqlite
driver takes native SQLite DDL, which gen can't read: switch to
sqlite-postgres, or save a snapshot.
Hosted projects without a database password
source.projectRef (or --project-ref) runs the introspection queries
through the Management API's read-only SQL endpoint
(POST /v1/projects/{ref}/database/query/read-only). You need a
personal access token in
SUPABASE_ACCESS_TOKEN, not the database password, and the queries run as a
read-only role.
Create a scoped token for this: limit it to the project and the read-only query permission the personal access tokens guide lists for that endpoint. A classic token carries your whole account, on every organization and project, which is more than CI or an agent needs. A scoped token that lacks the permission answers "You do not have permission to perform this action".
export default defineConfig({
source: { projectRef: "abcdefghijklmnopqrst" },
});SUPABASE_ACCESS_TOKEN=sbp_... better-supabase gen --checkSUPABASE_API_URL points it at another Management API host.
What the metadata knows
The generated schema carries what the runtime needs to stay correct without
extra round trips:
-
Read-only columns. Generated columns,
identity alwayscolumns and view columns Postgres marks as not insertable or updatable are left out of theInsertandUpdatetypes, and writes to them fail before a request is sent. -
Unique keys and constraint names.
findUniqueaccepts the primary key or any named unique key, andUniqueConstraint,CheckConstraintandForeignKeyConstrainttypes narrowisConflict(error, 'customers_kvk_key'). See unique keys and errors. -
Foreign key actions.
on delete cascade,set nullandset defaultare recorded on relations, so a delete invalidates the tables it changes. See caching. -
Codecs. With
codecsin the config,int8,numericandtimestamptzcolumns are read exactly (asbigint,stringorTemporal.Instant;timestampbecomesTemporal.PlainDateTime), and the generated validators match. See Temporal. -
Columns a CHECK makes not null. A nullable column with
check (slug is not null), alone or as a term of a top-leveland, is typed and validated as not null. It is required on insert unless it has a default or the table has a row-levelbefore inserttrigger, which can fill it before the CHECK runs. A term underorornot, and anot validconstraint, change nothing. Prefernot nullon the column itself when you can. -
Columns the database fills on insert. A not-null column without a default is required in
InsertOfand the insert validators. When a trigger or another database rule fills it, list its database name intables.<table>.insertOptionalandgenmakes it optional on insert while the row type stays not null:better-supabase.config.ts export default defineConfig({ tables: { invoices: { insertOptional: ["number"] } }, });genfails when a listed name is not a column of the table. -
Function arguments and results. Every argument in
Functionsacceptsnull, since Postgres passesnullto any function, and arguments with a default are optional. Functions that return rows of a table or areturns table (...)record get aresultentry, sodb.$rpcreturns them in your casing. -
Overloaded functions. A function with several signatures gets a union of
{ Args; Returns }inFunctions, one member per overload in signature order, as indatabase.types.ts. PostgREST picks an overload by the argument names, sodb.$rpcdoes too: a call type-checks against any overload, returns the type of the overload whose names it passes, and decodes the result with that overload'sresult. Overloads with the same argument names (pick(value integer)andpick(value text)) return the union of their types, and PostgREST can't choose between them at runtime either. An overload without arguments is typedRecord<PropertyKey, never>, so it never matches a call that passes one. -
Nullable function results. Postgres can't promise that a function returns a value: a
strictfunction returnsnullfor anullargument, a SQL function returnsnullwhen its query finds no row, and an aggregate in areturns tablecolumn isnullover no rows. So a scalar result, each element of asetofscalar, a single table row and eachreturns tablecolumn are typed| null. Rows ofreturns setofa table are not, and neither isvoidorJson, which already includesnull. When you know a result is never null, say so in the config:better-supabase.config.ts export default defineConfig({ functions: { open_ticket_count: { notNull: true }, customer_note_counts: { notNull: ["customer_id", "note_count"] }, }, });notNull: truecovers the whole result, and a list names thereturns tablecolumns (database names) that are never null.genfails on a function or column it can't find.
Documentation in the validators
The zod(), valibot(), jsonSchema() and standardSchema() generators carry what the
database says about a table into the schemas, so an OpenAPI document or an MCP
tool built from them describes each field:
- Each table schema gets a title from the table name (
customer_tagsbecomesCustomer tags, thenCustomer tags insertandCustomer tags update) and a description fromcomment on table. - Each field gets a description from
comment on column. A comment line that starts with@exampleadds an example: JSON when it parses (@example 42,@example "Acme B.V."), text otherwise. Examples the column's type rejects are left out. - Simple CHECK constraints become bounds the validators enforce. Comparisons
of a numeric column with a constant (
price >= 0,rating between 1 and 5) becomeminimum,maximumand their exclusive forms, and comparisons oflengthorchar_lengthwith a constant becomeminLengthandmaxLength. Terms joined byor, other functions and comparisons between columns are left out.
create table public.customers (
name text not null check (char_length(name) between 1 and 200)
-- ...
);
comment on table public.customers is 'Companies the organization sells to.';
comment on column public.customers.name is 'Trading name.
@example "Acme B.V."';export const customersInsert = z
.object({
name: z
.string()
.min(1)
.max(200)
.meta({ description: "Trading name.", examples: ["Acme B.V."] }),
// ...
})
.meta({
title: "Customers insert",
description: "Companies the organization sells to.",
});Valibot gets the same through v.title, v.description, v.examples,
v.minLength and v.minValue in a pipe, and the JSON Schema through
title, description, examples, minLength and minimum. The
standardSchema() output carries them in each field spec.
The generated metadata carries the comments too, so the API documents that
defineApi and createOpenApi render describe each table
and column without a validator. A table or column comment becomes
description in the metadata (without its @example lines), and a comment
line that starts with @deprecated sets deprecated: true. In the
document, the table's description goes on its schemas and its tag, each
column's on its property, and a deprecated table marks its schemas and
operations deprecated. A tag description set on the resource wins over the
table comment.
Examples stay in the validators; pass examples to defineApi to put rows
in the document.
Coming from supabase-to-zod
supabase-to-zod converts the database.types.ts file into zod 3 schemas
through ts-to-zod. The zod() generator writes zod 4 schemas from the
database catalog instead, so they carry the CHECK bounds, comments and codecs
above, and it keeps separate Row, Insert and Update schemas per table.
Replace the supabase-to-zod script with generators: [zod()] and import
<table>Row from generated.zod.ts.
Standard Schema without a validation library
standardSchema() writes <output>.standard.ts: a schema per table for its
Row, Insert and Update shapes that needs no zod or valibot install. Each one
implements Standard Schema (~standard.validate)
and Standard JSON Schema (~standard.jsonSchema), so it works anywhere a
Standard Schema does: the validation plugin,
validate(), form libraries, oRPC and MCP tool inputs.
import { defineConfig, standardSchema } from "better-supabase/config";
export default defineConfig({
output: "src/lib/supabase/generated.ts",
generators: [
standardSchema({ json: { "customers.metadata": "./schemas.ts#metadata" } }),
],
});import { type TableSchema, tableSchema } from "better-supabase";
export const customersInsert: TableSchema<InsertOf<"customers">> = tableSchema({
title: "Customers insert",
fields: {
name: { kind: "string", minLength: 1, maxLength: 200 },
status: {
kind: "enum",
values: ["lead", "active", "archived"],
optional: true,
},
metadata: {
kind: "json",
nullable: true,
optional: true,
schema: metadata,
},
},
});
export const validators = {
customers: { insert: customersInsert, update: customersUpdate },
};The checks match the zod() output: uuid, integer and ISO date formats,
enum values, CHECK bounds, null only on nullable columns, and Temporal
values on instant and plainDateTime codec columns. A present key never
accepts undefined, and unknown keys are dropped from the validated value.
json takes a Standard Schema per typed jsonb column; its issues keep their
path under the column, and its JSON Schema is used when it has one.
~standard.jsonSchema.input() and output() return the same JSON Schema the
jsonSchema() generator writes for that shape, for the draft-2020-12,
draft-07 and openapi-3.0 targets:
const schema = customersInsert["~standard"].jsonSchema.input({
target: "draft-07",
});Snapshots
better-supabase introspect --out supabase/snapshot.json
better-supabase gen --snapshot supabase/snapshot.jsonA committed snapshot lets CI and contributors generate without a database.
introspect writes and checks it.
The output is the same after supabase db reset and on every machine:
object ids come from names, and the arguments of each function keep their
declaration order. That matters for extension functions with unnamed
arguments, such as pgvector's distance functions and citext's casts, whose
argument order the catalog query alone does not fix.
From a GeneratorMetadata document
gen --metadata generates from the JSON document that
@supabase/postgrest-typegen defines (GeneratorMetadata, versioned by
GENERATOR_METADATA_VERSION), the same document
@supabase/typegen
pipes to out-of-process generators. Pass a file, or - to read stdin:
better-supabase gen --metadata - < metadata.json > src/lib/supabase/generated.tsIt follows the registry's contract for an out-of-process tool:
- stdout holds only the generated file. Notices, warnings and the oxfmt notice go to stderr.
- It exits 0 on success, 1 when generation fails (for example without
@supabase/postgrest-typegeninstalled), 2 on a usage error, and 65 (the code the registry maps toMetadataRejectedError, as for Dart'ssupabase_typegen) when it can't read the document, the document is not JSON, itsversionis not theGENERATOR_METADATA_VERSIONthis release reads, or the schema rejects it. The reason is on stderr. - It reads
better-supabase.config.tsfrom the working directory when there is one (casing,schemas,tables,codecs, generator options), and the defaults otherwise.
--emit picks the file:
--emit | Prints |
|---|---|
schema | The defineSchema module, with the metadata written into it instead of a separate generated.meta.js. It imports Database from databaseTypesOutput. |
types | database.types.ts, formatted when oxfmt is installed. |
zod | The zod() generator's file, with the options from the config when it lists zod(). |
valibot | The valibot() generator's file, likewise. |
json-schema | The jsonSchema() generator's document, likewise. |
standard-schema | The standardSchema() generator's file, likewise. |
--out <dir> writes the usual files (database.types.ts, the main module,
its metadata module and each configured generator's file) into one directory
instead, and --check compares them there. A generator with its own output
keeps that path. It leaves the read-set SQL module
and the files an earlier gen wrote alone.
The document carries no more than the Data API types need, so a few things
the database connection adds are missing. Relations, primary keys, enums,
single-column unique keys and single-column CHECK unions stay typed; unique
keys and checks get the names Postgres gives them by default
(<table>_<column>_key, <table>_<column>_check). Multi-column unique keys
and checks, foreign key actions, indexes, triggers, policies, grants, buckets
and the realtime publication are absent, and a notice on stderr says so. Run
gen against the database for the full model.
doctor --metadata reads the same document. Checks that need what it lacks
are skipped with an info finding, and the advisors and live checks are
skipped as for a saved snapshot.
As a @supabase/typegen language
The registry runs an out-of-process language in the project directory with
the sorted document on stdin. An externalLanguage entry for better-supabase
runs npx better-supabase gen --metadata -:
import { externalLanguage } from "./external.ts";
export const betterSupabase = externalLanguage(
"better-supabase",
[
{
name: "emit",
audience: "user",
kind: "choice",
choices: [
"schema",
"types",
"zod",
"valibot",
"json-schema",
"standard-schema",
],
default: "schema",
help: "The file to generate: the defineSchema module, database.types.ts or a validator file",
},
],
{
command: "npx",
args: (_metadata, options) => [
"--no-install",
"better-supabase",
"gen",
"--metadata",
"-",
"--emit",
String(options.emit),
],
installHint:
"Install Node.js, then run `npm install -D better-supabase @supabase/postgrest-typegen` in the project.",
classify: (result) => {
if (
result.stderr.includes("could not determine executable to run") ||
result.stderr.includes(
'needs the "@supabase/postgrest-typegen" package',
)
) {
return {
kind: "not-installed",
tool: "the better-supabase package",
installHint:
"Run `npm install -D better-supabase @supabase/postgrest-typegen` in the project that should receive the types, then generate from that directory.",
};
}
if (result.exitCode === 65) return { kind: "metadata-rejected" };
return undefined;
},
},
);Once the entry is in the registry's languages, user options become flags
of supabase gen types, so supabase gen types --lang better-supabase --emit zod
prints what better-supabase gen --metadata - --emit zod prints for the same
database. The upstream registry doesn't ship this entry, so without it, pipe
the document to better-supabase gen --metadata - yourself.
Last updated on