Configuration
better-supabase.config.ts options and defaults.
The CLI looks for better-supabase.config.{ts,mts,js,mjs,json} in the
working directory, then in each parent directory up to the one that holds
.git, and loads the first it finds. Paths in the file (output, sql.dir
and the rest) are relative to the directory that holds it, so a config at the
repository root works when the CLI runs from a package. --config overrides
the search. TypeScript configs load natively on Node 24.
import { defineConfig, zod } from "better-supabase/config";
import { CustomerMetadata } from "./src/types.ts";
export default defineConfig({
source: { projectRef: "abcdefghijklmnopqrst" },
schemas: ["public"],
casing: "camel",
output: "src/lib/supabase/generated.ts",
codecs: { int8: "bigint", numeric: "string", timestamptz: "instant" },
json: {
"customers.metadata": {
import: "./src/types.ts#CustomerMetadata",
schema: CustomerMetadata,
},
"notes.attachments": {
type: "{ files: { name: string; size: number }[] }",
},
},
sensitive: ["contacts.email", "contacts.phone"],
tables: {
customers: { relations: { primaryContact: "contact" } },
internal_jobs: { exclude: true },
},
plugins: {
timestamps: true,
softDelete: { column: "archived_at" },
tenant: { column: "organization_id" },
actor: true,
},
realtime: { tables: ["customers", "notes"] },
generators: [zod()],
});Options
| Option | Default | Notes |
|---|---|---|
source | local stack | dbUrl, projectRef (+ accessToken, default $SUPABASE_ACCESS_TOKEN), snapshot or lite. See gen |
schemas | ['public'] | Tables in other schemas get prefixed keys |
casing | 'snake' | 'camel' maps columns in queries; see casing |
tables | {} | Per-table casing, exclude, serviceRole, relation renames and insertOptional, keyed by table name or schema.table. serviceRole keeps the models but tells doctor the table is for service_role only; insertOptional lists not-null columns the database fills on insert |
output | src/lib/supabase/generated.ts | database.types.ts and generated.meta.js are written next to it |
postgrestVersion | '13' | Written to __InternalSupabase in database.types.ts |
codecs | JSON types | int8: 'bigint' | 'string', numeric: 'string', timestamptz: 'instant' read values exactly; see Temporal |
relations | { nullableUnderRls: false } | nullableUnderRls: true types to-one includes of tables with RLS as | null; see includes |
json | {} | jsonb types, keyed by table.column or schema.table.column (database names). schema adds a database check |
sensitive | [] | Columns the noSensitiveSelect rule guards (table.column) |
generators | [] | zod(), valibot(), jsonSchema(), standardSchema() or your own |
claims | { tenant: 'tenant_id', scope: 'tenant', features: 'features', memberships: 'memberships' } | JWT claim names. tenant is the active-tenant claim the tenant plugin, current_tenant_id() and the storage and realtime policies read; scope is the scope of the module's memberships entries; features is the plan-features claim. See Claims |
plugins | none | Flags for timestamps, soft delete, tenant and actor columns |
buckets | {} | Typed storage buckets; doctor compares them with the database and config.toml |
storagePaths | {} | Text columns that hold object paths, keyed by table.column, with a buckets key or bucket id as the value. They're typed as StoragePath<'bucket-id'> |
functions | {} | Function results that are never null, keyed by function name or schema.name: { notNull: true } or the returns table columns. See gen |
topics | {} | Realtime topic templates |
realtime | { tables: [], global: [], users: {} } | Tables for live queries; global lists the ones without a tenant column, users maps per-user tables to their user column |
entitlements | { key: 'id' }; customer defaults to the organizations module's stripe_customer_id | Where the entitlements SQL module finds each tenant's Stripe customer, or source ({ plans } or "custom") for entitlements without the Stripe Sync Engine. memberships ("tenant" or "provider") picks where it reads memberships from |
authorization | none | An authorization provider the provider access model, the entitlements module, bucket and topic policies and doctor delegate to |
vectorSearch | {} | Embedding columns ({ chunks: 'embedding' }) the vector-search SQL module writes search_<table> for |
readSets | [] | Modules exporting read sets; gen compiles them into the read-sets SQL module |
sql | supabase/schemas, 900_better_supabase, supabase/tests | Where generated SQL and pgTAP files go. modules lists the SQL modules to keep in sync: a list of names, or an object keyed by module name whose values set its mode, schema, table and column names, id type and permission keys (existing tables). access also picks the access model |
seed | supabase/seed.ts, supabase/seeds/000_better_supabase.sql | Entry module and output of seed |
specs | src/lib/openapi.ts, one OpenAPI 3.1 output at openapi.json, failOn: 'error' | Entry module, outputs (format, version, output, overlays, serialize, ui) failure level and manifest file of spec |
openapi | src/lib/openapi.ts, openapi.json | Deprecated alias of specs. Entry module and output of openapi emit; spec reads them when specs is unset |
doctor | format: 'text' | ignore finding codes, strict mode, claimsLimit, policyHelperLimit, and the defaults of --only, --format and --out (only, format, output) |
gen | every configured task, watchInterval: 2000 | tasks gen runs (types, sql, spec, seed, scaffold, env) and the --watch interval, which spec --watch reads too |
env | output: '.env.local', prefix from the framework | Defaults of env --out and --prefix (output, prefix; '' for no prefix) |
scaffold | none | api: the framework, tables, name and basePath of scaffold api. Setting it adds the scaffold task to gen |
integrations | [] | The integrations init set up. add with no argument sets up the ones that have no files yet |
skills | detected agent folders | agents: the folders skills install writes to (cursor, claude, agents) |
keys | supabase/signing_keys.json | output: where keys writes the signing key |
$env, $ci, ... | none | Overrides per environment |
Flags and the config
Every option a command takes on each run has a config key, so the command line stays short and a fresh checkout behaves the same as yours. A value is picked in this order:
- the flag, for one run;
- the active environment block;
- the config;
- the default.
| Command | Config key |
|---|---|
gen | gen.tasks, gen.watchInterval |
env | env.output, env.prefix |
doctor | doctor.only, doctor.format, doctor.output |
spec | specs.manifest, gen.watchInterval |
scaffold api | scaffold.api |
add | integrations |
skills install | skills.agents |
keys | keys.output |
doctor.output applies when the report uses doctor.format, so
doctor --json prints to stdout even when the config writes SARIF to a
file. scaffold api and add print the config entry for the flags you
passed, so you can paste it once instead of repeating them.
Environments
An environment block overrides part of the config for one environment. Name
blocks under $env, or use the $development, $production, $test and
$ci shorthands:
export default defineConfig({
casing: "camel",
doctor: { ignore: ["BS204"] },
$ci: {
gen: { tasks: ["types", "sql", "spec"] },
doctor: { format: "sarif", output: "doctor.sarif", strict: true },
},
$env: {
staging: { source: { projectRef: "abcdefghijklmnopqrst" } },
},
});The CLI applies the block named by --env, then by $BETTER_SUPABASE_ENV,
and in CI ($CI is set) the ci block. When both $env.<name> and a
shorthand exist for the same name, the shorthand applies last. Objects merge
key by key, while lists and other values replace the base value: above, ci
keeps doctor.ignore and replaces gen.tasks. An environment without a
block uses the config as it is. A block can't hold another block.
Claims
The claims block names the claims every part of better-supabase reads, so a
rename happens in one place. The defaults are:
export default defineConfig({
claims: {
tenant: "tenant_id", // the active tenant, also read from app_metadata
scope: "tenant", // memberships[].scope written by the tenant SQL module
features: "features", // plan features per tenant, from the entitlements module
memberships: "memberships", // the caller's memberships, from the tenant module or a provider's hook
},
});gen writes the names that differ from the defaults into the generated
schema, and sql add and sql sync render them into the SQL modules. Run
both after changing the block.
An adapter that reads these claims itself takes the paths from claimPaths
in better-supabase/config instead of hardcoding them. DEFAULT_CLAIMS holds
the defaults:
import { claimPaths } from "better-supabase/config";
const paths = claimPaths(config.claims);
// { tenant: ["tenant_id", "app_metadata.tenant_id"], features: "features",
// memberships: "memberships", scope: "tenant" }supabase/config.toml
The CLI reads supabase/config.toml for the local database port, the auth
settings doctor checks and bucket drift. When
@supabase/config is
installed, it parses the file, with env() values filled in the same way the
Supabase CLI does. It's an optional peer (it needs effect and
@effect/platform-node). Without it, a built-in parser reads the subset of
TOML that supabase init writes.
JSON configs
{
"$schema": "https://unpkg.com/better-supabase/schemas/config-v1.json",
"casing": "camel",
"plugins": { "timestamps": true }
}The JSON Schema gives editor completion and validation. Generators and
Standard Schema values in json[...].schema need a TypeScript or JavaScript
config; a JSON config can use plain JSON Schema objects.
Print the resolved config
pnpm better-supabase configconfig prints the file it loaded and the active environment, then the
config with every default filled in and that environment's block merged in,
as JSON (--json wraps them in one document, with environment set to the
name or null). better-supabase config --env ci shows what CI will see. Generators show as
their name and apiVersion, and the password in source.dbUrl is redacted.
Use it to check which file the CLI found and what an option resolved to.
gen warns about tables and json keys that match nothing in the
configured schemas, such as a misspelled table name, instead of ignoring them.
Custom generators
import type { Generator } from "better-supabase/config";
export const tableList: Generator = {
apiVersion: 1,
name: "table-list",
generate: ({ model }) => [
{
path: "src/lib/tables.json",
contents: JSON.stringify(
model.tables.map((table) => ({
key: table.key,
columns: table.columns.map((column) => [column.app, column.tsType]),
})),
),
},
],
};A generator receives the schema metadata (meta), the typegen introspection,
the resolved config, an importPath helper and model: the tables and enums
gen emitted, with the TypeScript type it wrote for each column after json
overrides, codecs and enum unions. model is frozen. The generator returns
files, and gen --check covers them too.
apiVersion: 1 names the generator contract it targets. gen refuses a
generator that declares a version it doesn't know, so a generator written for
a later contract fails with a message instead of misreading its input.
Leaving it out means version 1.
gen records the files it wrote in
node_modules/.cache/better-supabase/gen-manifest.json. When a later run no
longer writes one of them (you removed a generator or changed output), it
deletes the file and lists it as removed, and gen --check reports it as no
longer generated. Files that no earlier run wrote are never touched. Prove a
generator with testGenerator.
Last updated on