Contract-driven code generator for Supabase. Define your backend in a single YAML file, then generate a complete typed Dart client — models, enums, repositories, edge function clients, and storage bucket clients.
# Install globally:
dart pub global activate supabase_client_gen
# Generate from any project:
dart pub global run supabase_client_gen:generate \
--contract docs/contracts/supabase.yaml \
--output lib/generatedWriting Supabase client code by hand means repeating yourself across tables, keeping Dart types in sync with Postgres columns, and wiring up realtime subscriptions manually. This tool reads a single contract file — your backend's source of truth — and produces all the Dart code you need.
One contract. One command. Zero drift.
The same contract always produces byte-identical output (it does not depend on whether a database is reachable), so generated code is safe to commit and gate in CI.
dart pub global activate supabase_client_genOr add as a dev dependency:
dev_dependencies:
supabase_client_gen: ^0.2.0Generated code imports the following — add them to the app that consumes the generated client:
dependencies:
supabase_flutter: ^2.8.0 # repositories, edge functions, storage
equatable: ^2.0.0 # models| Output | Path | Description |
|---|---|---|
| Data models | models/ |
Equatable classes with fromJson/toJson/copyWith, null-safe |
| Enums | enums/enums.dart |
Dart enums from Postgres enum types, with fromString |
| Repositories | repositories/ |
Typed select/insert/update/delete/stream |
| Edge function clients | edge_functions/edge_functions.dart |
Typed wrappers over functions.invoke |
| Storage clients | storage/storage.dart |
Per-bucket upload/download/URL with MIME + size checks |
Every generated file carries a // GENERATED by supabase_client_gen header and
must not be hand-edited.
Nullability comes only from the contract's nullable_fields lists — this is
what keeps generated output deterministic and machine-independent.
To keep those lists honest against the real database, let the DB write them back into the contract:
dart pub global run supabase_client_gen:generate \
--contract docs/contracts/supabase.yaml --output lib/generated \
--with-db --sync-nullability --db-url "$SUPABASE_DB_URL"--sync-nullability reads live column nullability and updates nullable_fields
in the contract (preserving comments/formatting). Generation then reads from the
contract as usual. Use validate --with-db to detect drift without writing.
A supabase.yaml file is the single source of truth for your backend. A complete,
runnable example lives at example/supabase.yaml. The
top-level shape:
contract:
name: my_project_supabase_contract
version: "0.1.0"
date: "2026-01-01"
# Connection details live under project.remote — name and ref are required.
project:
remote:
name: my_project
ref: abcdefghijklmnop
auth:
provider: supabase
planned_sign_in_methods:
- email
data_model:
public:
things:
ownership: workspace
primary_key: id # honoured by update/delete/stream
fields:
id: uuid
workspace_id: uuid # presence makes the repo workspace-scoped
name: text
category: thing_category
notes: text
nullable_fields:
- notes
enum_values:
thing_category: [device, document, other]
client_access:
select: member_of_workspace
insert: member_of_workspace
update: member_of_workspace
delete: member_of_workspace
storage:
buckets:
avatars:
public: true
allowed_mime_types: [image/png, image/jpeg]
file_size_limit_mb: 5
edge_functions:
runtime: deno # non-mapping keys like this are ignored
resolve_thing:
method: POST
required_request_fields: [query]
optional_request_fields: [workspace_id]
realtime:
publication:
allowed_tables:
- public.thingsMalformed contracts fail with a path-qualified message
(e.g. data_model.public.things.primary_key is required) rather than a stack trace.
| Contract Type | Generated Dart Type |
|---|---|
uuid, text |
String |
integer / int4, bigint / int8 |
int |
numeric / decimal |
double |
boolean / bool |
bool |
timestamptz / timestamp / date |
DateTime |
jsonb / json |
Map<String, dynamic> |
vector(N) |
List<double> |
| Custom enum | Named Dart enum |
dart pub global run supabase_client_gen:generate \
--contract <path> \ # Path to supabase.yaml
--output <dir> \ # Output directory for generated code
[--check] \ # Verify generated code is up to date (exit 1 if not)
[--with-db] \ # Connect to the DB (only meaningful with --sync-nullability)
[--sync-nullability] \ # Write live-DB nullability back into the contract
[--db-url <url>] \ # postgres:// connection string (or SUPABASE_DB_URL env)
[--version]dart pub global run supabase_client_gen:validate \
--contract <path> \
--output <dir> \
[--ts <path>] \ # Path to supabase.types.ts
[--migrations <dir>] \ # Supabase migrations directory
[--mode=<mode>] \ # db | types | ts | migrations | all (default)
[--with-db] [--db-url <url>] [--json]Four validation checks:
- DB ↔ Contract — tables, columns, types, enums, and nullability aligned?
- Contract ↔ Generated Code — is the Dart client up to date?
- Contract ↔ TS Types — does
supabase.types.tsmatch the contract? - Migration Freshness — any migrations newer than the contract?
# melos.yaml
scripts:
gen:client:
run: dart pub global run supabase_client_gen:generate
--contract ../../docs/contracts/supabase.yaml --output lib/generated
gen:client:check:
run: dart pub global run supabase_client_gen:generate
--contract ../../docs/contracts/supabase.yaml --output lib/generated --check
validate:all:
run: dart pub global run supabase_client_gen:validate
--contract ../../docs/contracts/supabase.yaml --output lib/generated
--ts ../../docs/generated/supabase.types.ts
--migrations ../../supabase/migrations --mode=all --with-dbA workspace-scoped things table (it has a workspace_id column) yields:
final repo = ThingRepository(supabase.client);
final things = await repo.select(workspaceId: wsId, limit: 50, offset: 0);
final created = await repo.insert({'workspace_id': wsId, 'name': 'Drill'});
final updated = await repo.update(id, {'name': 'Hammer drill'});
await repo.delete(id);
// Realtime (table listed under realtime.publication.allowed_tables):
repo.stream(workspaceId: wsId).listen((things) => print(things));update/delete/stream use the table's declared primary_key. Tables without
a workspace_id column get unscoped select() / stream(). Read-only tables
(all writes edge_function_only) get no mutation methods, but still get a
.stream() if realtime is enabled.
Each client-invoked edge function becomes a typed top-level function:
final result = await resolveThing(query: 'cordless drill', workspaceId: wsId);
// result is Map<String, dynamic> from the function responsefinal avatars = AvatarsBucket(supabase.client);
await avatars.upload('user/$id.png', bytes, contentType: 'image/png');
final url = avatars.getPublicUrl('user/$id.png'); // signed URL for private bucketsUploads are validated against the bucket's allowed_mime_types and
file_size_limit_mb before hitting the network.
import 'dart:io';
import 'package:supabase_client_gen/supabase_client_gen.dart';
void main() {
final contract = loadContract('docs/contracts/supabase.yaml');
final files = ClientGenerator(contract).generate();
for (final entry in files.entries) {
File('lib/generated/${entry.key}')
..createSync(recursive: true)
..writeAsStringSync(entry.value);
}
}MIT — see LICENSE.