All recipes
Recipe
Notification bell
A header button with the unread count, which updates when a notification arrives or is read. Web and native call useNotifications from better-supabase/blocks/notifications/react.
1.What you build

2.Add the components
Web: shadcn
npx shadcn add badge buttonNative: React Native
The snippet uses Pressable, Text, 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 notifications. The glue items are copied into your app, so edit them as you need.
Web
npx shadcn add @better-supabase/notifications-actions-nextNative
npx shadcn add @better-supabase/blocks4.Write the component
Web: React and shadcn
View in the example"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>
);
}
Native: React Native
View in the exampleimport 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>
);
}