Designs and the resolver
How better-supabase/preset reads a preset name or a design, resolves its blocks and writes the config and wiring Create shows.
better-supabase/preset is the model behind preset in the config and
behind Create. It runs on every
runtime, including the browser: the resolver reads a generated catalog of
the blocks and never imports the SQL renderers.
import {
presetConfigSource,
presetFromSpec,
resolvePreset,
} from "better-supabase/preset";
const preset = presetFromSpec({ api: "api", blocks: ["comments"] });
const resolved = resolvePreset(preset);
// resolved.blocks: comments, plus tenant, access and updated-at, each with a reason
console.log(await presetConfigSource(resolved));The preset key
The config's preset key takes a preset name, such as "saas", or an object
with the conventions that differ from the defaults and the blocks to install:
import { defineConfig } from "better-supabase/config";
export default defineConfig({
preset: { casing: "camel", access: "provider", blocks: ["profiles", "tags"] },
});presetSpec(preset) turns a Preset into that value: the name when the preset
equals a named one, otherwise the object with only the fields that differ.
presetFromSpec and presetOfSpec read it back; presetOfSpec takes an
unknown value and throws for an unknown field or value. CONVENTION_FIELDS
lists the fields in a fixed order.
Conventions
Every field has a default, so a preset sets only what differs.
| Field | Values | Default | What it sets |
|---|---|---|---|
framework | next, vite, tanstack-start, expo, sveltekit, nuxt, hono, astro, react-router | next | the glue Create shows |
platforms | web, native, both | web | the glue Create shows; native needs expo |
casing | snake, camel | snake | casing |
idType | uuid, text, bigint, integer | uuid | blocks.permissions.idType |
scope | tenant, user, none | tenant | database.sql.owner; under user or none, blocks that need tenants can't install, and the rest need neither tenant nor access |
tenantName | organization, workspace, team | organization | blocks.memberships.names.organization; a block whose tables or columns the name would take over can't install |
access | roles, catalog, provider, custom | roles | blocks.permissions.model |
api | none, api | none | database.sql.api |
integrations | next, hono, orpc, edge, expo, mcp, client, react (any number) | none | integrations |
Designs
A design is plain JSON: the preset, the app tables,
extra columns on block tables and where each table sits on the Create canvas.
It rides on a Create URL as ?design=, compressed:
import {
createUrl,
encodeDesign,
namedPreset,
presetAndDesignOf,
presetInstallCommand,
resolvePreset,
} from "better-supabase/preset";
const resolved = resolvePreset(namedPreset("crm"));
const design = {
appTables: { projects: { columns: { name: { type: "text" } } } },
extraColumns: { organizations: { tier: "text" } },
positions: { "public.projects": [120, 40] },
};
await createUrl(resolved, design);
// https://app.bettersupabase.com/create?design=0...
await presetInstallCommand(resolved, design);
// npx better-supabase init --preset "https://app.bettersupabase.com/create?design=0..."
const { preset, design: read } = await presetAndDesignOf(
await createUrl(resolved, design),
);createUrl writes ?preset=<name> for a named preset with nothing else, and
?design=<code> with the preset inside the design otherwise.
presetAndDesignOf reads a preset name, a Create URL or a bare design code: the
design's own preset comes first, then a preset= parameter, then the default
preset. The preset key takes a name or an object, as in the config.
A design code starts with 0, which no preset name starts with, followed by
base64url of the design's JSON compressed with deflate-raw. It uses
CompressionStream, so encoding and decoding are async and run on every
runtime.
The JSON's own format goes in a version key. designFormat(design) returns
the lowest format that reads the design: 1 when a table or column uses a key
added after the first release (format, options, min, max, comment,
identity, or a table's id, unique, indexes or comment), 2 for a
table subject, 3 for rollups or lookups, 4 for events or subject
versions, 5 for column access, 6 for column encrypted or table
auditReads, 7 for preset, and 0 otherwise. DESIGN_FORMAT is the
newest format this release reads; a code with a higher version fails with a
message to update better-supabase instead of losing keys.
Codes from 0.14
Releases up to 0.14 wrote a separate preset code (?preset=1...). Reading one
now throws an error that names better-supabase codemod 0.15, which turns a
code in the config's preset into the name or object it holds, and a 0.14
design file into a design with its preset inside.
decodeDesign and designOf reject what isn't a design: an unknown key, a
table that tables wouldn't accept, a column type outside a plain SQL
type, a block with no table for extra columns, or a code that inflates past
256 KB. encodeDesign throws a DesignParseError for an invalid design, and a
DesignTooLongError (a DesignParseError with the code's length) when a valid
design would pass MAX_DESIGN_CODE_LENGTH (6000 characters); write a
design file then. Check for DesignTooLongError to tell the
two apart.
parseDesign(value) checks the shape of a design object, such as a JSON body
or an AI tool's input, with the same rules, and returns it with its empty
parts dropped. It throws a DesignParseError naming the first problem. To
check a design against a preset as well, use
checkDesign.
safeParseDesign(value) runs the same checks without throwing, so a route
handler can answer a bad body with a 400 and the issue:
import { safeParseDesign } from "better-supabase/preset";
const result = safeParseDesign(await request.json());
if (!result.ok) {
return Response.json({ issues: result.issues }, { status: 400 });
}
// result.design is the checked designdesignSchema is the same check as a Standard Schema
(v1), for a library that takes one, such as a form library, a router or an AI
SDK tool. Its output is the design with its empty parts dropped, and each
issue's path lists the keys of the JSON Pointer below:
import { designSchema } from "better-supabase/preset";
const result = await designSchema["~standard"].validate(body);
// { value: { appTables: { ... } } } or { issues: [{ message, path: ["appTables", "tasks"] }] }schemas/design-v1.json
is the JSON Schema of a design's shape, for an editor or a tool outside
TypeScript. It accepts what designSchema accepts for the shape; the names,
defaults and limits are checked only by designSchema and parseDesign.
A DesignParseError is a TypeError with a code and a path, and each
issue in issues is the same three keys as plain JSON. path is a
JSON Pointer to the key at fault,
such as /appTables/tasks/columns/title/type, or its nearest parent when
the key is missing. The codes are stable; the messages may change.
| Code | What it means |
|---|---|
invalid_design | a value has the wrong type, such as a design that isn't an object |
unknown_key | a key a design or design file doesn't have |
unsupported_format | version is newer than this release reads |
too_large | more tables, extra columns or positions than a design may hold |
unknown_block | extraColumns names a block that doesn't exist |
no_extra_columns | extraColumns names a block that takes no extra columns |
block_not_installed | extraColumns names a block the preset doesn't install |
invalid_name | a column or table name that isn't a lower snake identifier |
invalid_type | an extra column whose type isn't a SQL type |
invalid_position | a canvas position that isn't two finite numbers |
invalid_app_table | an app table that tables rejects |
invalid_code | a design code that is malformed, damaged or too long to read |
too_long | a valid design whose code is too long for a URL |
invalid_file | a design file that isn't JSON or lacks its keys |
invalid_preset | a preset that isn't a preset name or object, or a file without one |
Design files
A design too long for a URL travels as a file instead. designFileSource
writes it, and init --preset and add --preset read it from a path ending
in .json. DESIGN_FILE is the default name, better-supabase.design.json.
import {
defaultPreset,
designFileSource,
readDesignFile,
resolvePreset,
} from "better-supabase/preset";
const source = designFileSource(resolvePreset(defaultPreset()), design);
// {
// "version": 7,
// "preset": "saas",
// "appTables": { ... },
// "extraColumns": { ... }
// }
const { preset, design: read } = readDesignFile(source);The file is the design JSON with its version and preset, with no length
limit. readDesignFile checks it with the same rules as parseDesign, needs a
preset, and throws a DesignParseError for anything else.
safeDesignFileSource(resolved, design) returns { ok: true, source } or the
issues instead of throwing.
Two helpers check a design before it is encoded. appReservedColumns(tenant)
lists the columns every app table can get from its scope (id,
created_at, updated_at, user_id and <tenant>_id), which a design's
columns may not name. isAppDefault(type, value) says whether a column of
type accepts value as its default.
diffDesigns(before, after) lists what changes between two designs, and
diffAppTables(before, after, { keepMissing }) does the same for two
appTables objects; with keepMissing: true, a table missing from after
is kept instead of dropped, as add --preset merges a design. Each DesignChange
has a kind, the table and column, a one-sentence summary, and
breaking. A dropped or renamed table or column, a type change and a column
made required without a default are breaking, and their steps give the
expand-and-contract migration that makes them safe. A change to a table's
rollups or lookups is the safe table.computed, because their view keeps
no rows. A change to its events is the safe table.events and a change to
its subject's versions is the safe table.versions, because each only
replaces a trigger:
import { diffAppTables } from "better-supabase/preset";
const changes = diffAppTables(
{ notes: { columns: { body: { type: "jsonb" } } } },
{ notes: { columns: { body: { type: "text" } } } },
);
// [{ kind: "column.type", table: "notes", column: "body", breaking: true,
// summary: "Changes notes.body from jsonb to text.", steps: [...] }]The AI instructions for a design are at /create/prompt.md?design=<code>,
or /create/prompt.md?preset=<name> for a named preset.
| Function | What it does |
|---|---|
designConfig | the preset's config with the design merged in; throws for extra columns on a block it doesn't install |
createUrl | the /create URL for a preset and design; throws DesignTooLongError past the URL limit |
presetAndDesignOf | the preset and design a name, a /create URL or a design code holds |
parseDesign | checks the shape of a design object and drops its empty parts; throws a DesignParseError |
designSchema | parseDesign as a Standard Schema (v1) |
safeParseDesign | parseDesign without the throw: { ok: true, design } or { ok: false, issues } |
designFileSource | the design file: the design JSON with its preset |
safeDesignFileSource | designFileSource without the throw: { ok: true, source } or { ok: false, issues } |
readDesignFile | the preset and design from a design file; throws a DesignParseError for anything else |
The default preset
DEFAULT_PRESET_NAME is "saas", the preset /create and init start
from. defaultPreset() returns it as a Preset, so an app that opens a new
design doesn't hard-code it.
Resolving
resolvePreset(preset) returns:
| Key | What it holds |
|---|---|
blocks | every block to install, in install order, with selected, requiredBy and a reason such as "required by comments" |
integrations | the soft integrations active between blocks in the set (comments works with notifications) |
conflicts | what can't install and why: a block that needs tenants under scope user, or Expo on web only |
manifests | the block manifests that cover the set, for lib/blocks.ts |
What a block requires can depend on the access model: under provider and
custom, access no longer requires tenant. presetBlockConflict says
why one block can't install before it is selected, which is how Create
disables a block.
defineConfig throws when a preset has conflicts, listing each one.
Generated files
presetConfigSource, presetInstallCommand and presetApplyCommand are
async, because the URL in their output is.
| Function | Writes |
|---|---|
presetConfigSource | better-supabase.config.ts with every block, each added block marked with its reason |
presetBlocksSource | lib/blocks.ts: the blocks with a TypeScript client, wired with defineBlocks |
presetInstallCommand | npx better-supabase init --preset <name or URL>, which starts a project from the preset |
presetApplyCommand | npx better-supabase add --preset <name or URL>, which applies it to an existing project |
presetGlue | the registry glue items for the preset's framework and platforms; glueCommand installs them |
scripts/build-catalog.ts writes the catalog (src/preset/catalog.generated.ts)
from the SQL module registry and the block manifests, and
pnpm catalog:check fails when it is stale.
Last updated on