Real-time notifications help keep users engaged with your application by informing them about important events as they happen.
In this guide, we'll build a complete notification system using Supabase and Next.js that supports real-time updates, different notification types, and smart notification management.
Here's what we'll build:
- A notification system that delivers updates in real-time
- Support for different notification types (info, warning, error)
- A notification popover with an unread counter
- Dismissible notifications that expire automatically
- Row Level Security to keep notifications private
NB: The code may not work as is on your system as the dependencies may change, but the concepts are the same. Try to use the code as a reference, but don't copy it directly.
The overview of the Notifications system
Let's analyze the flow of the notification system:
- Initial Fetch: We fetch the notifications from the database and display them in the UI.
- Real-time Updates: We subscribe to real-time updates from Supabase and update the notifications in the UI.
- User Interaction: The user interacts with the notifications, such as dismissing or clicking on a notification.
- Notification Update: We mark the notification as read or dismissed and update the database.
- Expiration: We delete the notifications that have expired.
Setting Up the Database
First, let's create our notifications table in Supabase. The below is a simplified version of Makerkit's database schema, but it's more than enough for a notification system.
-- Create notification typescreate type public.notification_type as enum('info', 'warning', 'error');create table public.notifications ( id bigint generated always as identity primary key, account_id uuid not null references public.accounts (id), type public.notification_type not null default 'info', body varchar(5000) not null, link varchar(255), dismissed boolean not null default false, expires_at timestamptz default (now() + interval '1 month'), created_at timestamptz not null default now());-- Create an index for efficient queriescreate index idx_notifications_account_dismissedon notifications (account_id, dismissed, expires_at);
Each notification has:
- An
account_id
linking it to a user or team - A
type
for different notification styles (info/warning/error) - A
body
containing the notification message - An optional
link
for clickable notifications - A
dismissed
flag to track read status - An
expires_at
timestamp for automatic cleanup
NB: we're referencing an account_id
that is a foreign key to the accounts
table. This is because we want to keep the notifications private to the user. Feel free to use a different foreign key that matches your own database schema.
Securing the Notifications
Users should only see notifications meant for them. We'll use Supabase Row Level Security (RLS) to enforce this:
-- Enable RLSalter table public.notifications enable row level security;-- Allow users to read their own notificationscreate policy notifications_read_selfon public.notifications for selectto authenticatedusing ( account_id = (select auth.uid()));-- Allow users to dismiss their notificationscreate policy notifications_update_selfon public.notifications for updateto authenticatedusing ( account_id = (select auth.uid()));
We assume that the account_id
is a foreign key to the accounts
table and it matches the user's ID.
That means, you will have an accounts
table whose ID is the same as the user's ID.
You can alternatively use directly the auth.users
table as foreign key of the account_id
column in the notifications
table. Up to you!
Building the UI Components
In the next section, we will build a notification popover that shows notifications and an unread counter. We will use the libraries lucide-react
and Shadcn UI, however, you can use any UI library you want.
The imports to Shadcn UI components are prefixed with @kit/ui
rather than the more conventional @components/ui
. This is because Makerkit uses Turborepo, which is a monorepo. The ui
package is located in the packages/ui
directory and named @kit/ui
.
Let's create a notification popover that shows notifications and an unread counter:
'use client';import { useCallback, useEffect, useState } from 'react';import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react';import { useTranslation } from 'react-i18next';import { Database } from '@kit/supabase/database';import { Button } from '@kit/ui/button';import { If } from '@kit/ui/if';import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';import { Separator } from '@kit/ui/separator';import { cn } from '@kit/ui/utils';import { useDismissNotification, useFetchNotifications } from '../hooks';type Notification = Database['public']['Tables']['notifications']['Row'];type PartialNotification = Pick< Notification, 'id' | 'body' | 'dismissed' | 'type' | 'created_at' | 'link'>;export function NotificationsPopover(params: { realtime: boolean; accountId: string; onClick?: (notification: PartialNotification) => void;}) { const { i18n, t } = useTranslation(); const [open, setOpen] = useState(false); const [notifications, setNotifications] = useState<PartialNotification[]>([]); const onNotifications = useCallback( (notifications: PartialNotification[]) => { setNotifications((existing) => { const unique = new Set(existing.map((notification) => notification.id)); const notificationsFiltered = notifications.filter( (notification) => !unique.has(notification.id), ); return [...notificationsFiltered, ...existing]; }); }, [], ); const dismissNotification = useDismissNotification(); useFetchNotifications({ onNotifications, accountId: params.accountId, realtime: params.realtime, }); const timeAgo = (createdAt: string) => { const date = new Date(createdAt); let time: number; const daysAgo = Math.floor( (new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24), ); const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto', }); if (daysAgo < 1) { time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60)); if (time < 5) { return t('common:justNow'); } if (time < 60) { return formatter.format(-time, 'minute'); } const hours = Math.floor(time / 60); return formatter.format(-hours, 'hour'); } const unit = (() => { const minutesAgo = Math.floor( (new Date().getTime() - date.getTime()) / (1000 * 60), ); if (minutesAgo <= 60) { return 'minute'; } if (daysAgo <= 1) { return 'hour'; } if (daysAgo <= 30) { return 'day'; } if (daysAgo <= 365) { return 'month'; } return 'year'; })(); const text = formatter.format(-daysAgo, unit); return text.slice(0, 1).toUpperCase() + text.slice(1); }; useEffect(() => { return () => { setNotifications([]); }; }, []); return ( <Popover modal open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button className={'relative h-9 w-9'} variant={'ghost'}> <Bell className={'min-h-4 min-w-4'} /> <span className={cn( `fade-in animate-in zoom-in absolute right-1 top-1 mt-0 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`, { hidden: !notifications.length, }, )} > {notifications.length} </span> </Button> </PopoverTrigger> <PopoverContent className={'flex w-full max-w-96 flex-col p-0 lg:min-w-64'} align={'start'} collisionPadding={20} sideOffset={10} > <div className={'flex items-center px-3 py-2 text-sm font-semibold'}> {t('common:notifications')} </div> <Separator /> <If condition={!notifications.length}> <div className={'px-3 py-2 text-sm'}> {t('common:noNotifications')} </div> </If> <div className={ 'flex max-h-[60vh] flex-col divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800' } > {notifications.map((notification) => { const maxChars = 100; let body = t(notification.body, { defaultValue: notification.body, }); if (body.length > maxChars) { body = body.substring(0, maxChars) + '...'; } const Icon = () => { switch (notification.type) { case 'warning': return <TriangleAlert className={'h-4 text-yellow-500'} />; case 'error': return <CircleAlert className={'text-destructive h-4'} />; default: return <Info className={'h-4 text-blue-500'} />; } }; return ( <div key={notification.id.toString()} className={cn( 'min-h-18 flex flex-col items-start justify-center space-y-0.5 px-3 py-2', )} onClick={() => { if (params.onClick) { params.onClick(notification); } }} > <div className={'flex w-full items-start justify-between'}> <div className={'flex items-start justify-start space-x-2 py-2'} > <div className={'py-0.5'}> <Icon /> </div> <div className={'flex flex-col space-y-1'}> <div className={'text-sm'}> <If condition={notification.link} fallback={body}> {(link) => ( <a href={link} className={'hover:underline'}> {body} </a> )} </If> </div> <span className={'text-muted-foreground text-xs'}> {timeAgo(notification.created_at)} </span> </div> </div> <div className={'py-2'}> <Button className={'max-h-6 max-w-6'} size={'icon'} variant={'ghost'} onClick={() => { setNotifications((existing) => { return existing.filter( (existingNotification) => existingNotification.id !== notification.id, ); }); return dismissNotification(notification.id); }} > <XIcon className={'h-3'} /> </Button> </div> </div> </div> ); })} </div> </PopoverContent> </Popover> );}
The parts that "matter the most" are here:
const [open, setOpen] = useState(false);const [notifications, setNotifications] = useState<PartialNotification[]>([]);const onNotifications = useCallback( (notifications: PartialNotification[]) => { setNotifications((existing) => { const unique = new Set(existing.map((notification) => notification.id)); const notificationsFiltered = notifications.filter( (notification) => !unique.has(notification.id), ); return [...notificationsFiltered, ...existing]; }); }, [],);const dismissNotification = useDismissNotification();useFetchNotifications({ onNotifications, accountId: params.accountId, realtime: params.realtime,});
The state of the notifications is stored in the notifications
state variable. This is okay when the state of the notifications is only used in the Popover component. However, when the state of the notifications is used elsewhere, it would be more appropriate to use a global state management library like Zustand or Jotai or simply the Context API.
onNotifications
: This callback is called when new notifications are fetched. It updates thenotifications
state with the new notifications.dismissNotification
: This callback is called when a notification is dismissed. It uses theuseDismissNotification
hook to dismiss the notification in the database.useFetchNotifications
: This hook is responsible for fetching initial notifications and subscribing to real-time updates. We will discuss this hook in the next section.useDismissNotification
: This hook is responsible for dismissing a notification in the database.useNotificationsStream
: This hook is responsible for subscribing to real-time updates and updating the notifications state.
We will look at the code for these hooks in the next sections.
Setting Up Real-time Updates
The real magic happens with Supabase's real-time capabilities.
We'll use React Query to manage the subscription:
// use-fetch-notifications.tsimport { useEffect } from 'react';import { useQuery } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';import { useNotificationsStream } from './use-notifications-stream';type Notification = { id: number; body: string; dismissed: boolean; type: 'info' | 'warning' | 'error'; created_at: string; link: string | null;};export function useFetchNotifications({ onNotifications, accountId, realtime,}: { onNotifications: (notifications: Notification[]) => unknown; accountId: string; realtime: boolean;}) { const { data: initialNotifications } = useFetchInitialNotifications({ accountId, }); useNotificationsStream({ onNotifications, accountId, enabled: realtime, }); useEffect(() => { if (initialNotifications) { onNotifications(initialNotifications); } }, [initialNotifications, onNotifications]);}
The useSupabase
hook is a simple hook to access the Supabase client. We use it to get the Supabase client and pass it to the useQuery
hook.
Please provide your own implementation of the useSupabase
hook if you want to use a different client.
To explain the above, let's break down the code:
- We use the
useQuery
hook to subscribe to the real-time channel. We pass theaccountId
as the filter to the channel. - We use the
useEffect
hook to clean up the subscription when the component unmounts. - When a new notification is received, we call the
onNotifications
callback with the new notifications. - Some state management is required to keep track of the notifications. We use the
useState
hook to store the notifications.
This hook combines both initial data fetching and real-time updates. The initial fetch is handled by:
import { useQuery } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';function useFetchInitialNotifications(props: { accountId: string }) { const client = useSupabase(); const now = new Date().toISOString(); return useQuery({ queryKey: ['notifications', ...props.accountId], queryFn: async () => { const { data } = await client .from('notifications') .select( `id, body, dismissed, type, created_at, link `, ) .eq('account_id', props.accountId) .eq('dismissed', false) .gt('expires_at', now) .order('created_at', { ascending: false }) .limit(10); return data; }, refetchOnMount: false, refetchOnWindowFocus: false, });}
The real-time updates are handled by a separate hook:
import { useEffect } from 'react';import { useQuery } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';type Notification = { id: number; body: string; dismissed: boolean; type: 'info' | 'warning' | 'error'; created_at: string; link: string | null;};export function useNotificationsStream(params: { onNotifications: (notifications: Notification[]) => void; accountId: string; enabled: boolean;}) { const client = useSupabase(); const { data: subscription } = useQuery({ enabled: params.enabled, queryKey: ['realtime-notifications', ...params.accountId], queryFn: () => { const channel = client.channel('notifications-channel'); return channel .on( 'postgres_changes', { event: 'INSERT', schema: 'public', filter: `account_id=eq.(${params.accountId})`, table: 'notifications', }, (payload) => { params.onNotifications([payload.new as Notification]); }, ) .subscribe(); }, }); useEffect(() => { return () => { void subscription?.unsubscribe(); }; }, [subscription]);}
Dismissing Notifications
Notifications can be dismissed using this hook:
// use-dismiss-notification.tsimport { useCallback } from 'react';import { useSupabase } from '@kit/supabase/hooks/use-supabase';export function useDismissNotification() { const client = useSupabase(); return useCallback( async (notification: number) => { const { error } = await client .from('notifications') .update({ dismissed: true }) .eq('id', notification); if (error) { throw error; } }, [client], );}
Creating Notifications
To send notifications, we'll create a service NotificationsService
. This service will be responsible for creating, updating, and deleting notifications.
NB: this service needs to be exclusively used in the server-side code.
import 'server-only';import { SupabaseClient } from '@supabase/supabase-js';import { Database } from '@kit/supabase/database';type Notification = Database['public']['Tables']['notifications'];export function createNotificationsService(client: SupabaseClient<Database>) { return new NotificationsService(client);}class NotificationsService { constructor(private readonly client: SupabaseClient<Database>) {} async createNotification(params: Notification['Insert']) { const { error } = await this.client.from('notifications').insert(params); if (error) { throw error; } }}
Using the Notification System
Here's how to use it in your application:
// In your layoutexport default async function AppLayout() { const user = await requireUser(); return ( <header> <NotificationsPopover accountId={user.id} realtime={true} /> </header> );}
Some examples of notifications:
// Creating notificationsconst supabaseClient = getSupabaseClient();const notificationsService = createNotificationsService(supabaseClient);// Info notificationawait notificationsService.createNotification({ accountId: userId, body: "Your export is ready!", type: "info", link: "/exports/latest"});// Warning notificationawait notificationsService.createNotification({ accountId: userId, body: "Your subscription will expire soon", type: "warning", link: "/billing"});// Error notificationawait notificationsService.createNotification({ accountId: userId, body: "Payment failed", type: "error", link: "/billing/update"});
Performance Considerations
To keep your notification system running smoothly:
- Database Indexes: We created an index on
(account_id, dismissed, expires_at)
to speed up queries. - Cleanup Job: Set up a cron job to delete expired notifications:
delete from notifications where expires_at < now();
- Pagination: Limit the number of notifications fetched:
const { data } = await client .from('notifications') .select() .eq('dismissed', false) .gt('expires_at', new Date().toISOString()) .order('created_at', { ascending: false }) .limit(10);
Common Pitfalls
- Real-time Cleanup: Always unsubscribe from real-time channels when components unmount.
- Duplicate Notifications: Handle duplicate notifications when merging real-time updates with existing ones.
- Time Zones: Use timestamptz for dates to handle different time zones correctly.
- Error States: Handle network errors and show appropriate UI feedback.
Next Steps
You can extend this system by:
- Adding notification categories or priorities
- Supporting rich content in notifications
- Adding sound or desktop notifications
- Implementing notification preferences
- Supporting batch operations (mark all as read)
- Support global state management for notifications
Conclusion
You now have a robust notification system that:
- Delivers real-time updates
- Handles different notification types
- Manages notification lifecycle
- Scales well with proper indexing
- Keeps notifications secure with RLS
🚀 Makerkit - A production-ready Next.js SaaS boilerplate with realtime notifications 💡
Looking for a production-ready Next.js SaaS Boilerplate? MakerKit provides a comprehensive boilerplate that extends beyond basic Next.js and Supabase integration, offering everything you need to launch your SaaS product faster.
Among these features, we have a realtime notification system that delivers updates in real-time, helps you manage your notifications, and keeps your notifications secure with Row Level Security (RLS).