Recipe
Settings layout
Settings pages generated from the settings definition: a section per scope and a control per key, picked by the kind its schema declares. Web and native call useSettings from better-supabase/blocks/settings/react.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add card field input select skeleton switchNative: React Native
The snippet uses ActivityIndicator, Pressable, ScrollView, Switch, Text, TextInput, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.
3.Install the glue
Install the block with npx better-supabase sql add settings. The glue items are copied into your app, so edit them as you need.
Web
None: the hook calls the block from the client.
Native
None: the hook calls the block from the client.
4.Write the component
Web: React and shadcn
View in the example"use client";
import type { SettingEntries } from "better-supabase/blocks/settings";
import type {
SettingField,
SettingsState,
} from "better-supabase/blocks/settings/react";
import { useSettings } from "better-supabase/blocks/settings/react";
import { tenantOf } from "better-supabase/react";
import { useExtracted } from "next-intl";
import { useId } from "react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/lib/hooks";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";
import { settings } from "../settings-definition";
/** The API schema `gen` wrote for the settings block. */
const API_SCHEMA = blockConfig.settings.api;
/** What a control produces; the key's schema validates it before it is stored. */
type ControlValue = string | number | boolean;
/** The control for one field, picked by the kind its schema declares. */
function SettingControl({
id,
field,
value,
disabled,
onChange,
}: {
id: string;
field: SettingField;
value: unknown;
disabled: boolean;
onChange: (value: ControlValue) => void;
}) {
switch (field.kind) {
case "boolean":
return (
<Switch
id={id}
checked={value === true}
disabled={disabled}
onCheckedChange={(checked: boolean) => {
onChange(checked);
}}
/>
);
case "choice": {
const items = field.choices.map((choice) => ({
value: String(choice),
label: String(choice),
}));
return (
<Select
value={String(value)}
items={items}
disabled={disabled}
onValueChange={(next) => {
const choice = field.choices.find((each) => String(each) === next);
if (choice !== undefined) onChange(choice);
}}
>
<SelectTrigger id={id} className="w-48 capitalize">
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="capitalize"
>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case "number":
case "text":
case "json":
return (
<Input
id={id}
className="w-48"
type={field.kind === "number" ? "number" : "text"}
defaultValue={field.kind === "json" ? "" : String(value ?? "")}
disabled={disabled || field.kind === "json"}
onBlur={(event) => {
const next = event.currentTarget.value;
onChange(field.kind === "number" ? Number(next) : next);
}}
/>
);
default: {
const unknown: never = field.kind;
return unknown;
}
}
}
/** One scope of the definition as a card: a row per key, saved on change. */
function SettingsSection<E extends SettingEntries>({
title,
description,
editable,
state,
}: {
title: string;
description: string;
editable: boolean;
state: SettingsState<E>;
}) {
const t = useExtracted("settings");
const errorMessage = useErrorMessage();
const baseId = useId();
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
{state.values === undefined ? (
<Skeleton className="h-16" aria-busy="true" />
) : (
<FieldGroup>
{state.fields.map((field) => (
<Field key={field.key} orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor={`${baseId}-${field.key}`}>
{field.title ?? field.key}
</FieldLabel>
{field.description ? (
<FieldDescription>{field.description}</FieldDescription>
) : null}
{editable ? null : (
<FieldDescription>
{t("Only admins can change this.")}
</FieldDescription>
)}
</FieldContent>
<SettingControl
id={`${baseId}-${field.key}`}
field={field}
value={state.values?.[field.key]}
disabled={!editable || state.saving === field.key}
onChange={(value) => {
void state.setField(field.key, value).then((saved) => {
if (!saved.ok) toast.error(errorMessage(saved.error));
});
}}
/>
</Field>
))}
</FieldGroup>
)}
</CardContent>
</Card>
);
}
/**
* The preferences page, generated from `settings-definition.ts`: a card per
* scope and a control per key, each saved through the settings module.
*/
export function SettingsLayout({
canEditOrganization,
}: {
canEditOrganization: boolean;
}) {
const t = useExtracted("settings");
const organizationId = tenantOf(useAuth()) ?? null;
const user = useSettings(settings, { scope: "user", schema: API_SCHEMA });
const organization = useSettings(settings, {
scope: "organization",
organizationId,
schema: API_SCHEMA,
});
return (
<div className="space-y-6" data-testid="settings-layout">
<SettingsSection
title={t("Your preferences")}
description={t("Only you see these.")}
editable
state={user}
/>
{organizationId ? (
<SettingsSection
title={t("Organization defaults")}
description={t("Shared by everyone in the organization.")}
editable={canEditOrganization}
state={organization}
/>
) : null}
</div>
);
}
Native: React Native
View in the exampleimport type { SettingEntries } from "better-supabase/blocks/settings";
import type {
SettingField,
SettingsState,
} from "better-supabase/blocks/settings/react";
import { useSettings } from "better-supabase/blocks/settings/react";
import { tenantOf, useAuth } from "better-supabase/react";
import {
ActivityIndicator,
Pressable,
ScrollView,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { API_SCHEMA } from "../lib/blocks";
import { settings } from "../lib/settings";
/** What a control produces; the key's schema validates it before it is stored. */
type ControlValue = string | number | boolean;
/** The control for one field, picked by the kind its schema declares. */
function SettingControl({
field,
value,
disabled,
onChange,
}: {
readonly field: SettingField;
readonly value: unknown;
readonly disabled: boolean;
readonly onChange: (value: ControlValue) => void;
}) {
switch (field.kind) {
case "boolean":
return (
<Switch
value={value === true}
disabled={disabled}
onValueChange={onChange}
/>
);
case "choice":
return (
<View style={{ flexDirection: "row", gap: 6 }}>
{field.choices.map((choice) => (
<Pressable
key={String(choice)}
accessibilityRole="radio"
accessibilityState={{ checked: choice === value, disabled }}
disabled={disabled}
onPress={() => {
onChange(choice);
}}
style={{
paddingHorizontal: 10,
paddingVertical: 6,
borderWidth: 1,
borderRadius: 8,
borderColor: choice === value ? "#111" : "#e5e5e5",
}}
>
<Text>{String(choice)}</Text>
</Pressable>
))}
</View>
);
case "number":
case "text":
case "json":
return (
<TextInput
defaultValue={field.kind === "json" ? "" : String(value ?? "")}
editable={!disabled && field.kind !== "json"}
keyboardType={field.kind === "number" ? "numeric" : "default"}
onEndEditing={(event) => {
const next = event.nativeEvent.text;
onChange(field.kind === "number" ? Number(next) : next);
}}
style={{ minWidth: 120, borderWidth: 1, borderRadius: 8, padding: 6 }}
/>
);
default: {
const unknown: never = field.kind;
return unknown;
}
}
}
/** One scope of the definition: a row per key, saved on change. */
function SettingsSection<E extends SettingEntries>({
title,
state,
}: {
readonly title: string;
readonly state: SettingsState<E>;
}) {
if (state.values === undefined) return <ActivityIndicator />;
return (
<View style={{ gap: 12 }}>
<Text style={{ fontSize: 17, fontWeight: "600" }}>{title}</Text>
{state.fields.map((field) => (
<View key={field.key} style={{ flexDirection: "row", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text>{field.title ?? field.key}</Text>
{field.description ? (
<Text style={{ color: "#737373" }}>{field.description}</Text>
) : null}
</View>
<SettingControl
field={field}
value={state.values?.[field.key]}
disabled={state.saving === field.key}
onChange={(value) => void state.setField(field.key, value)}
/>
</View>
))}
{state.error ? (
<Text style={{ color: "#b91c1c" }}>{state.error.message}</Text>
) : null}
</View>
);
}
/** The preferences screen, generated from the settings definition. */
export function SettingsLayout() {
const organizationId = tenantOf(useAuth()) ?? null;
const user = useSettings(settings, { scope: "user", schema: API_SCHEMA });
const organization = useSettings(settings, {
scope: "organization",
organizationId,
schema: API_SCHEMA,
});
return (
<ScrollView contentContainerStyle={{ gap: 24, padding: 16 }}>
<SettingsSection title="Your preferences" state={user} />
{organizationId ? (
<SettingsSection title="Organization defaults" state={organization} />
) : null}
</ScrollView>
);
}