# UI recipes

> Build a notification bell, a notification list, a command search and an onboarding checklist from shadcn or React Native components and the blocks' hooks.

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

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

better-supabase ships no visible UI components. A recipe builds one interface from your own shadcn components (web) or React Native primitives (native) and a block's hook. The web and native code call the same hook, and both come from the example apps, `apps/examples/nextjs` and `apps/examples/expo-powersync`, so they typecheck and run.

Each recipe also has a page at [bettersupabase.com/recipes](https://bettersupabase.com/recipes) with a screenshot. The native snippets use plain React Native primitives, so they run without a styling library; [React Native Reusables](https://reactnativereusables.com) serves shadcn-style native components from a working registry if you want them.

## Notification bell [#notification-bell]

A header button with the unread count, which updates when a notification arrives or is read. Both sides call `useNotifications` from `better-supabase/blocks/notifications/react`; the [notifications block](https://bettersupabase.com/blocks/notifications) must be installed (`npx better-supabase sql add notifications`). Page: [https://bettersupabase.com/recipes/notification-bell](https://bettersupabase.com/recipes/notification-bell).

Web, with shadcn components:

```bash
npx shadcn add badge button
```

```bash
npx shadcn add @better-supabase/notifications-actions-next
```

```tsx title="apps/examples/nextjs/src/features/notifications/components/unread-badge.tsx"
"use client";

import type { NotificationCounts } from "better-supabase/blocks/notifications";

import { useNotifications } from "better-supabase/blocks/notifications/react";
import { BellIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Link } from "@/i18n/navigation";
import { useAuth } from "@/lib/hooks";

import { loadNotificationCounts } from "../notifications-actions";

async function loadCounts(): Promise<readonly NotificationCounts[]> {
  const result = await loadNotificationCounts({});
  return result.ok ? [result.data] : [];
}

const unreadOf = (counts: readonly NotificationCounts[]): number =>
  counts[0]?.unread ?? 0;

/**
 * The unread count from the block's `counts()`, loaded again after each
 * broadcast on the caller's private topic `notifications:<user id>`.
 */
function useUnreadCount() {
  const auth = useAuth();
  const userId = auth.user?.id;
  const { items, count, status } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: loadCounts,
    count: unreadOf,
  });
  return { count: items === undefined ? undefined : count, status };
}

/**
 * The header badge. Part of the static shell, so it loads from the browser
 * instead of adding a database call to every page.
 */
export function UnreadBadge() {
  const t = useExtracted("notifications");
  const { count, status } = useUnreadCount();
  return (
    <Link
      href="/notifications"
      className={buttonVariants({ variant: "ghost", size: "sm" })}
      data-testid="unread-badge"
      data-status={status}
      aria-label={t("Notifications, {count, number} unread", {
        count: count ?? 0,
      })}
    >
      <BellIcon />
      <Badge
        variant={count ? "default" : "secondary"}
        className="tabular-nums"
        data-testid="unread-count"
      >
        {count ?? "–"}
      </Badge>
    </Link>
  );
}
```

React Native, with `Pressable`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/blocks
```

```tsx title="apps/examples/expo-powersync/src/components/notification-bell.tsx"
import type { NotificationCounts } from "better-supabase/blocks/notifications";

import { useNotifications } from "better-supabase/blocks/notifications/react";
import { tenantOf, useAuth, useSupabase } from "better-supabase/react";
import { Pressable, Text, View } from "react-native";

import { blocks } from "../lib/blocks";

const unreadOf = (counts: readonly NotificationCounts[]): number =>
  counts[0]?.unread ?? 0;

/** The unread count, loaded again after each broadcast on `notifications:<user id>`. */
export function NotificationBell({
  onPress,
}: {
  readonly onPress: () => void;
}) {
  const auth = useAuth();
  const supabase = useSupabase();
  const tenant = tenantOf(auth);
  const userId = auth.user?.id;
  const { items, count } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: async () => {
      if (!tenant) return [];
      const result = await blocks(supabase).notifications.counts({ tenant });
      return result.ok ? [result.data] : [];
    },
    count: unreadOf,
  });
  return (
    <Pressable
      onPress={onPress}
      accessibilityRole="button"
      accessibilityLabel={`Notifications, ${String(count)} unread`}
      style={{ flexDirection: "row", alignItems: "center", gap: 8, padding: 8 }}
    >
      <Text>Notifications</Text>
      <View
        style={{
          minWidth: 24,
          borderRadius: 12,
          paddingHorizontal: 8,
          backgroundColor: count ? "#111" : "#e5e5e5",
        }}
      >
        <Text style={{ color: count ? "#fff" : "#111", textAlign: "center" }}>
          {items === undefined ? "–" : count}
        </Text>
      </View>
    </Pressable>
  );
}
```

## Notification list [#notification-list]

The caller's latest notifications with an unread marker, loaded again on every change. Both sides call `useNotifications` from `better-supabase/blocks/notifications/react`; the [notifications block](https://bettersupabase.com/blocks/notifications) must be installed (`npx better-supabase sql add notifications`). Page: [https://bettersupabase.com/recipes/notification-list](https://bettersupabase.com/recipes/notification-list).

Web, with shadcn components:

```bash
npx shadcn add badge empty skeleton
```

```bash
npx shadcn add @better-supabase/notifications-actions-next
```

```tsx title="apps/examples/nextjs/src/features/notifications/components/notification-list.tsx"
"use client";

import { useNotifications } from "better-supabase/blocks/notifications/react";
import { BellIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";

import { Badge } from "@/components/ui/badge";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/lib/hooks";

import type { NotificationRow } from "../notification-types";

import { loadNotifications } from "../notifications-actions";

async function loadRows(): Promise<readonly NotificationRow[]> {
  const result = await loadNotifications({});
  return result.ok ? result.data : [];
}

/**
 * The caller's latest notifications, loaded again after each broadcast on
 * their private topic `notifications:<user id>`: a new one, or one marked read.
 */
export function NotificationList() {
  const t = useExtracted("notifications");
  const format = useFormatter();
  const auth = useAuth();
  const userId = auth.user?.id;
  const { items } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: loadRows,
  });
  if (items === undefined) return <NotificationListSkeleton />;
  if (items.length === 0) {
    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <BellIcon />
          </EmptyMedia>
          <EmptyTitle>{t("Nothing here yet")}</EmptyTitle>
          <EmptyDescription>
            {t("Send yourself a notification to see it arrive.")}
          </EmptyDescription>
        </EmptyHeader>
      </Empty>
    );
  }
  return (
    <ul className="divide-y rounded-xl border">
      {items.map((notification) => (
        <li
          key={notification.id}
          className="flex items-center gap-3 px-4 py-3 text-sm"
        >
          <span className="flex-1">{notification.title}</span>
          {notification.readAt ? null : <Badge>{t("New")}</Badge>}
          <span className="text-muted-foreground text-xs tabular-nums">
            {format.dateTime(new Date(notification.createdAt), {
              dateStyle: "medium",
              timeStyle: "short",
            })}
          </span>
        </li>
      ))}
    </ul>
  );
}

function NotificationListSkeleton() {
  return (
    <div className="space-y-2" aria-busy="true">
      {Array.from({ length: 4 }, (_, index) => (
        <Skeleton key={index} className="h-11 w-full" />
      ))}
    </div>
  );
}
```

React Native, with `ActivityIndicator`, `FlatList`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/blocks
```

```tsx title="apps/examples/expo-powersync/src/components/notification-list.tsx"
import { useNotifications } from "better-supabase/blocks/notifications/react";
import { tenantOf, useAuth, useSupabase } from "better-supabase/react";
import { ActivityIndicator, FlatList, Text, View } from "react-native";

import { blocks } from "../lib/blocks";

/**
 * The caller's latest notifications, loaded again after each broadcast on
 * their private topic `notifications:<user id>`: a new one, or one marked read.
 */
export function NotificationList() {
  const auth = useAuth();
  const supabase = useSupabase();
  const tenant = tenantOf(auth);
  const userId = auth.user?.id;
  const { items } = useNotifications({
    topic: userId ? `notifications:${userId}` : null,
    load: async () => {
      if (!tenant) return [];
      const result = await blocks(supabase).notifications.list({
        tenant,
        limit: 20,
      });
      return result.ok ? result.data : [];
    },
  });
  if (items === undefined) return <ActivityIndicator />;
  return (
    <FlatList
      data={items}
      keyExtractor={(item) => item.id}
      ListEmptyComponent={<Text>Nothing here yet</Text>}
      renderItem={({ item }) => (
        <View style={{ flexDirection: "row", gap: 8, paddingVertical: 12 }}>
          <Text style={{ flex: 1 }}>{item.data.title}</Text>
          {item.readAt ? null : <Text style={{ fontWeight: "600" }}>New</Text>}
          <Text style={{ color: "#737373" }}>
            {item.createdAt.toLocaleString(undefined, {
              dateStyle: "medium",
              timeStyle: "short",
            })}
          </Text>
        </View>
      )}
    />
  );
}
```

## Command search [#command-search]

A search dialog over the search index, with favorites and recents before the user types. Both sides call `useCommandSearch` from `better-supabase/blocks/search/react`; the [search block](https://bettersupabase.com/blocks/search) must be installed (`npx better-supabase sql add search`). Page: [https://bettersupabase.com/recipes/command-search](https://bettersupabase.com/recipes/command-search).

Web, with shadcn components:

```bash
npx shadcn add button dialog input
```

```tsx title="apps/examples/nextjs/src/features/search/components/command-search.tsx"
"use client";

import { useCommandSearch } from "better-supabase/blocks/search/react";
import { SearchIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Link } from "@/i18n/navigation";

/** `blocks.search.api` in better-supabase.config.ts. */
const API_SCHEMA = "api";

/** Cmd+K over the search index: customers by name, plus recents and favorites. */
export function CommandSearch() {
  const t = useExtracted("search");
  const search = useCommandSearch({ schema: API_SCHEMA });
  const heading = (key: string) => {
    if (key === "favorites") return t("Favorites");
    if (key === "recents") return t("Recent");
    if (key === "customer") return t("Customers");
    return key;
  };
  return (
    <>
      <Button
        variant="ghost"
        size="sm"
        aria-label={t("Search")}
        aria-keyshortcuts="Meta+K Control+K"
        onClick={() => {
          search.setOpen(true);
        }}
      >
        <SearchIcon />
        <span className="hidden sm:inline">{t("Search")}</span>
        <kbd className="text-muted-foreground hidden text-xs sm:inline">⌘K</kbd>
      </Button>
      <Dialog open={search.open} onOpenChange={search.setOpen}>
        <DialogContent className="gap-4 sm:max-w-lg">
          <DialogHeader>
            <DialogTitle>{t("Search")}</DialogTitle>
            <DialogDescription>
              {t("Find a customer by name, even with a typo.")}
            </DialogDescription>
          </DialogHeader>
          <Input
            type="search"
            value={search.query}
            onChange={(event) => {
              search.setQuery(event.target.value);
            }}
            placeholder={t("Search customers")}
            aria-label={t("Search customers")}
          />
          <div className="max-h-80 overflow-y-auto" aria-live="polite">
            {search.error === undefined ? null : (
              <p className="text-destructive text-sm">
                {t("Search failed. Try again.")}
              </p>
            )}
            {search.groups.length === 0 && !search.loading ? (
              <p className="text-muted-foreground text-sm">
                {search.query.trim() === ""
                  ? t("Type to search.")
                  : t("No matches.")}
              </p>
            ) : null}
            {search.groups.map((group) => (
              <section key={group.key} className="mb-3">
                <h3 className="text-muted-foreground mb-1 text-xs font-medium">
                  {heading(group.key)}
                </h3>
                <ul>
                  {group.items.map((item) => (
                    <li key={`${item.subjectType}:${item.subjectId}`}>
                      <Link
                        href={item.url ?? "/"}
                        onClick={() => void search.select(item)}
                        className="hover:bg-muted focus-visible:bg-muted block rounded-md px-2 py-1.5 outline-none"
                      >
                        {item.title ?? item.subjectId}
                      </Link>
                    </li>
                  ))}
                </ul>
              </section>
            ))}
          </div>
        </DialogContent>
      </Dialog>
    </>
  );
}
```

React Native, with `Modal`, `Pressable`, `SectionList`, `Text`, `TextInput`, `View` from `react-native`:

```tsx title="apps/examples/expo-powersync/src/components/command-search.tsx"
import { useCommandSearch } from "better-supabase/blocks/search/react";
import {
  Modal,
  Pressable,
  SectionList,
  Text,
  TextInput,
  View,
} from "react-native";

import { API_SCHEMA } from "../lib/blocks";

function heading(key: string): string {
  if (key === "favorites") return "Favorites";
  if (key === "recents") return "Recent";
  if (key === "customer") return "Customers";
  return key;
}

/** Search over the search index: customers by name, plus recents and favorites. */
export function CommandSearch() {
  // Native has no Cmd+K, so the button opens it.
  const search = useCommandSearch({ schema: API_SCHEMA, shortcut: false });
  return (
    <>
      <Pressable
        accessibilityRole="button"
        onPress={() => {
          search.setOpen(true);
        }}
        style={{ padding: 8 }}
      >
        <Text>Search</Text>
      </Pressable>
      <Modal
        visible={search.open}
        animationType="slide"
        onRequestClose={() => {
          search.setOpen(false);
        }}
      >
        <View style={{ flex: 1, gap: 12, padding: 16 }}>
          <TextInput
            value={search.query}
            onChangeText={search.setQuery}
            placeholder="Search customers"
            accessibilityLabel="Search customers"
            style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
          />
          {search.error === undefined ? null : (
            <Text>Search failed. Try again.</Text>
          )}
          <SectionList
            sections={search.groups.map((group) => ({
              key: group.key,
              title: heading(group.key),
              data: group.items,
            }))}
            keyExtractor={(item) => `${item.subjectType}:${item.subjectId}`}
            renderSectionHeader={({ section }) => (
              <Text style={{ color: "#737373", marginTop: 12 }}>
                {section.title}
              </Text>
            )}
            renderItem={({ item }) => (
              <Pressable
                accessibilityRole="link"
                onPress={() => void search.select(item)}
                style={{ paddingVertical: 8 }}
              >
                <Text>{item.title ?? item.subjectId}</Text>
              </Pressable>
            )}
            ListEmptyComponent={
              search.loading ? null : (
                <Text>
                  {search.query.trim() === ""
                    ? "Type to search."
                    : "No matches."}
                </Text>
              )
            }
          />
          <Pressable
            accessibilityRole="button"
            onPress={() => {
              search.setOpen(false);
            }}
          >
            <Text>Close</Text>
          </Pressable>
        </View>
      </Modal>
    </>
  );
}
```

## Onboarding checklist [#onboarding-checklist]

The organization's getting-started steps with progress, hidden once every step is done. Both sides call `useOnboarding` from `better-supabase/blocks/onboarding/react`; the [onboarding block](https://bettersupabase.com/blocks/onboarding) must be installed (`npx better-supabase sql add onboarding`). Page: [https://bettersupabase.com/recipes/onboarding-checklist](https://bettersupabase.com/recipes/onboarding-checklist).

Web, with shadcn components:

```bash
npx shadcn add button card progress skeleton
```

```bash
npx shadcn add @better-supabase/onboarding-checklist
```

```tsx title="apps/examples/nextjs/src/features/dashboard/components/onboarding-card.tsx"
"use client";

import { useOnboarding } from "better-supabase/blocks/onboarding/react";
import { tenantOf } from "better-supabase/react";
import { CheckCircle2Icon, CircleIcon } from "lucide-react";
import { useExtracted } from "next-intl";

import { buttonVariants } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { gettingStarted } from "@/features/onboarding/checklist";
import { Link } from "@/i18n/navigation";
import { useAuth } from "@/lib/hooks";

/** `blocks.onboarding.api` in better-supabase.config.ts. */
const API_SCHEMA = "api";

/** The organization's getting-started checklist; hidden once every step is done. */
export function OnboardingCard() {
  const t = useExtracted("dashboard");
  const organizationId = tenantOf(useAuth()) ?? null;
  const { progress } = useOnboarding(gettingStarted, {
    organizationId,
    schema: API_SCHEMA,
  });
  if (!organizationId) return null;
  if (progress === undefined) return <OnboardingCardSkeleton />;
  if (progress.done) return null;
  const titles = {
    customer: t("Add your first customer"),
    invite: t("Invite a teammate"),
    "api-key": t("Create an API key"),
    plan: t("Review your plan"),
  };
  return (
    <Card data-testid="onboarding">
      <CardHeader>
        <CardTitle>{t("Get started")}</CardTitle>
        <CardDescription>
          {t("{completed, number} of {total, number} steps done", {
            completed: progress.completed,
            total: progress.total,
          })}
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        <Progress value={(progress.completed / progress.total) * 100} />
        <ul className="divide-y">
          {progress.steps.map((step) => (
            <li key={step.id} className="flex items-center gap-3 py-2">
              {step.completed ? (
                <CheckCircle2Icon className="text-primary size-4" />
              ) : (
                <CircleIcon className="text-muted-foreground size-4" />
              )}
              <span
                className={
                  step.completed
                    ? "text-muted-foreground flex-1 line-through"
                    : "flex-1"
                }
              >
                {titles[step.id]}
              </span>
              {step.completed ? null : (
                <Link
                  href={step.href ?? "/"}
                  className={buttonVariants({ variant: "outline", size: "sm" })}
                >
                  {t("Start")}
                </Link>
              )}
            </li>
          ))}
        </ul>
      </CardContent>
    </Card>
  );
}

export function OnboardingCardSkeleton() {
  return <Skeleton className="h-64 rounded-xl" aria-busy="true" />;
}
```

React Native, with `ActivityIndicator`, `Text`, `View` from `react-native`:

```bash
npx shadcn add @better-supabase/onboarding-checklist
```

```tsx title="apps/examples/expo-powersync/src/components/onboarding-checklist.tsx"
import { useOnboarding } from "better-supabase/blocks/onboarding/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { ActivityIndicator, Text, View } from "react-native";

import { API_SCHEMA } from "../lib/blocks";
import { gettingStarted } from "../lib/onboarding";

/** The organization's getting-started checklist; hidden once every step is done. */
export function OnboardingChecklist() {
  const organizationId = tenantOf(useAuth()) ?? null;
  const { progress } = useOnboarding(gettingStarted, {
    organizationId,
    schema: API_SCHEMA,
  });
  if (!organizationId) return null;
  if (progress === undefined) return <ActivityIndicator />;
  if (progress.done) return null;
  return (
    <View style={{ gap: 8, padding: 16, borderWidth: 1, borderRadius: 12 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Get started</Text>
      <Text style={{ color: "#737373" }}>
        {progress.completed} of {progress.total} steps done
      </Text>
      <View style={{ height: 6, borderRadius: 3, backgroundColor: "#e5e5e5" }}>
        <View
          style={{
            height: 6,
            borderRadius: 3,
            backgroundColor: "#111",
            width: `${(progress.completed / progress.total) * 100}%`,
          }}
        />
      </View>
      {progress.steps.map((step) => (
        <Text
          key={step.id}
          style={
            step.completed
              ? { color: "#737373", textDecorationLine: "line-through" }
              : undefined
          }
        >
          {step.completed ? "✓" : "○"} {step.title}
        </Text>
      ))}
    </View>
  );
}
```

## Planned [#planned]

| Recipe                | Block           | Waits for                                                                               |
| --------------------- | --------------- | --------------------------------------------------------------------------------------- |
| Comment thread        | `comments`      | a client controller and React hook for the comments block (plan 003, workstream 4)      |
| Organization switcher | `organizations` | a client controller and React hook for the organizations block (plan 003, workstream 4) |
| Members table         | `organizations` | a client controller and React hook for the organizations block (plan 003, workstream 4) |
| Invite dialog         | `invitations`   | a client controller and React hook for the invitations block (plan 003, workstream 4)   |
| Plan picker           | `billing`       | a client controller and React hook for the billing block (plan 003, workstream 4)       |
| Usage meter           | `usage`         | a client controller and React hook for the usage block (plan 003, workstream 4)         |
| Feature gate          | `entitlements`  | a client controller and React hook for the entitlements block (plan 003, workstream 4)  |
| API keys table        | `api-keys`      | a client controller and React hook for the api-keys block (plan 003, workstream 4)      |
| Audit log table       | `audit`         | a client controller and React hook for the audit block (plan 003, workstream 4)         |
| Tag input             | `tags`          | a client controller and React hook for the tags block (plan 003, workstream 4)          |
| Settings layout       | `settings`      | a client controller and React hook for the settings block (plan 003, workstream 4)      |