All recipes

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.

The settings blockWebReact Native

1.What you build

No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.

2.Add the components

Web: shadcn

terminal
npx shadcn add card field input select skeleton switch

Native: 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
settings-layout.tsx
"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 example
settings-layout.tsx
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, 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>
  );
}