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.
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 with a screenshot. The native snippets use plain React Native primitives, so they run without a styling library; React Native Reusables serves shadcn-style native components from a working registry if you want them.
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 must be installed (npx better-supabase sql add notifications). Page: https://bettersupabase.com/recipes/notification-bell.
Web, with shadcn components:
npx shadcn add badge buttonnpx shadcn add @better-supabase/notifications-actions-next"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:
npx shadcn add @better-supabase/blocksimport 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
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 must be installed (npx better-supabase sql add notifications). Page: https://bettersupabase.com/recipes/notification-list.
Web, with shadcn components:
npx shadcn add badge empty skeletonnpx shadcn add @better-supabase/notifications-actions-next"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:
npx shadcn add @better-supabase/blocksimport { 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
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 must be installed (npx better-supabase sql add search). Page: https://bettersupabase.com/recipes/command-search.
Web, with shadcn components:
npx shadcn add button dialog input"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:
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
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 must be installed (npx better-supabase sql add onboarding). Page: https://bettersupabase.com/recipes/onboarding-checklist.
Web, with shadcn components:
npx shadcn add button card progress skeletonnpx shadcn add @better-supabase/onboarding-checklist"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:
npx shadcn add @better-supabase/onboarding-checklistimport { 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
| 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) |
Last updated on
Registry
Install better-supabase glue (block wiring, server actions, hooks, route handlers and Expo config) with the shadcn CLI from the @better-supabase registry.
Monorepos
One runtime package owns defineSupabase and the request context; domain packages type their repositories from its db and never import the generated client.