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

Notification bell in the Next.js example
The web version in the Next.js example, signed in as the seed user.

2.Add the components

Web: shadcn

terminal
npx shadcn add badge button

Native: 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

terminal
npx shadcn add @better-supabase/notifications-actions-next

Native

terminal
npx shadcn add @better-supabase/blocks

4.Write the component

Web: React and shadcn

View in the example
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>
  );
}

Native: React Native

View in the example
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>
  );
}