Settings
Per-user and per-organization settings with a Standard Schema per key, defaults, typed get and set, and matching pg_jsonschema checks.
The settings block stores preferences as key-value rows: one table for each
user's own settings and one for organization settings. You declare the keys
once with a Standard Schema each (Zod, Valibot, ArkType), and the block gives
you typed get, set and reset that validate before they write.
better-supabase sql add settings # adds tenant and access as well| Table | Key | Who reads | Who writes |
|---|---|---|---|
user_settings | (user_id, key) | the user | the user |
organization_settings | (organization_id, key) | members with settings.read | members with settings.update |
platform_settings | key | each key's read rule | each key's platform permission |
The default roles give admin both permissions and member the first.
Rename them with sql.modules.settings.permissions. Both tables keep
updated_by and updated_at, and the functions run as the caller, so the
row level security policies decide every read and write.
Declaring settings
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { defineSettings } from "better-supabase/blocks/settings";
import * as v from "valibot";
export const settings = defineSettings({
user: {
theme: {
schema: toStandardJsonSchema(v.picklist(["light", "dark", "system"])),
default: "system",
},
digest: { schema: v.object({ weekly: v.boolean() }) },
},
organization: {
defaultRole: {
schema: toStandardJsonSchema(v.picklist(["member", "viewer"])),
default: "member",
},
},
});A key without a default reads as undefined until it is set. A stored
value that no longer matches its schema (after you narrow an enum, for
example) also reads as the default, so old rows never break a page.
Reading and writing
import { rpcTransport } from "better-supabase/blocks/settings";
import { settings } from "@/lib/settings";
const client = settings.connect({
transport: rpcTransport(supabase, { schema: "api" }),
});
const theme = await client.user.get("theme").orThrow(); // "light" | "dark" | "system"
await client.user.set("theme", "dark").orThrow();
await client.user.reset("theme"); // back to the default
const all = await client.organization.get(organizationId).orThrow();
await client.organization.set(organizationId, "defaultRole", "viewer");set returns a validation error with the schema's issues when the value
doesn't match, and an invalid_input error for a key you didn't declare;
neither reaches the database. Use sqlTransport(postgres.asUser(claims))
instead of rpcTransport to call the functions over Postgres. With
rpcTransport, set sql.modules.settings.api to an exposed schema such as
api and pass rpcTransport(supabase, { schema: "api" }); see
SQL modules.
Platform settings
Settings for the whole product, such as fee rates, routing rules or an
announcement text an admin console edits, go in the platform scope. Each
key can name the platform permission (is_platform) that may change it and
who reads it: public (also signed out), authenticated (the default) or
staff (holders of the key's permission). Keys guarded by different
permissions share one table:
export const platformSettings = defineSettings({
platform: {
invoiceFeePercent: {
schema: v.pipe(v.number(), v.minValue(0), v.maxValue(10)),
default: 0,
permission: "platform.billing.manage",
read: "public",
},
aiDefaults: {
schema: v.object({ model: v.string() }),
permission: "platform.ai.manage",
read: "staff",
},
},
});Pass the definition as sql.modules.settings.options.schemas, so the
module writes the permission and read rule of each key into the policies.
When schemas lists platform keys, only those keys can be written: a key
the config doesn't list is refused, so a typo or a stale key never lands in
platform_settings. Rows that exist without a listed key are read with
options.platform.permission (default settings.manage, renamed with
permissions.platform) and options.platform.read. Without listed keys,
any key can be written with that permission.
const admin = platformSettings.connect({
transport: sqlTransport(postgres.asUser(claims)),
});
await admin.platform.set("invoiceFeePercent", 1.5).orThrow();
const fee = await admin.platform.get("invoiceFeePercent").orThrow();A key a caller may not read comes back as its default, and a write without
the key's permission fails with forbidden.
Checks in the database
Pass the definition to the module, and keys whose schema implements
Standard JSON Schema get a
pg_jsonschema check, so writes that skip TypeScript are held to the same
shape. Zod 4 schemas implement it directly; wrap Valibot schemas in
toStandardJsonSchema:
import { defineConfig } from "better-supabase/config";
import { settings } from "./src/lib/settings.ts";
export default defineConfig({
sql: {
modules: {
settings: { options: { schemas: settings } },
},
},
});better-supabase sql add jsonb-schemas settingsThe checks need the jsonb-schemas module, which installs pg_jsonschema.
Each one is named bs_json_value_<key> and applies only to rows with that
key. options.schemas also takes plain { user, organization } maps of key to
JSON Schema. Keys whose schema has no JSON Schema (digest above) are
validated in TypeScript only.
Options
| Option | Default | What it does |
|---|---|---|
schemas | none | A defineSettings() result or { user, organization, platform } JSON Schemas |
platform | { permission: "settings.manage", read: "authenticated" } | The permission and read rule of platform keys that don't set their own |
Last updated on
API keys
Hashed API keys for tenants and users, with scopes, expiry, rotation, a rate limit per key and an apiKey caller in every server adapter.
Usage and quotas
Count usage per tenant and meter with idempotent increments, enforce quotas per tenant or plan in RLS and RPCs, and report usage to Stripe meters.