# 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.

Source: https://bettersupabase.com/docs/registry/ui-recipes/settings-layout

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

Both sides call `useSettings` from `better-supabase/blocks/settings/react`; the [settings block](https://bettersupabase.com/blocks/settings) must be installed (`npx better-supabase add settings`). Its page on bettersupabase.com is [/recipes/settings-layout](https://bettersupabase.com/recipes/settings-layout).

Web, with shadcn components:

```bash
npx shadcn add card field input select skeleton switch
```

```tsx title="apps/examples/nextjs/src/features/settings/components/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>
  );
}
```

React Native, with `ActivityIndicator`, `Pressable`, `ScrollView`, `Switch`, `Text`, `TextInput`, `View` from `react-native`:

```tsx title="apps/examples/expo-powersync/src/components/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>
  );
}
```