All recipes

Recipe

Notification list

The caller's latest notifications with an unread marker, loaded again on every change. Web and native call useNotifications from better-supabase/blocks/notifications/react.

1.What you build

Notification list 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 empty skeleton

Native: React Native

The snippet uses ActivityIndicator, FlatList, 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
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>
  );
}

Native: React Native

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