Building a Real-time Notification System with Supabase and Next.js

Learn how to build a complete real-time notification system using Supabase and Next.js. Includes live updates, dismissible notifications, and Row Level Security.

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:

  1. Initial Fetch: We fetch the notifications from the database and display them in the UI.
  2. Real-time Updates: We subscribe to real-time updates from Supabase and update the notifications in the UI.
  3. User Interaction: The user interacts with the notifications, such as dismissing or clicking on a notification.
  4. Notification Update: We mark the notification as read or dismissed and update the database.
  5. 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 types
create 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 queries
create index idx_notifications_account_dismissed
on 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 RLS
alter table public.notifications enable row level security;
-- Allow users to read their own notifications
create policy notifications_read_self
on public.notifications for select
to authenticated
using (
account_id = (select auth.uid())
);
-- Allow users to dismiss their notifications
create policy notifications_update_self
on public.notifications for update
to authenticated
using (
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.

  1. onNotifications: This callback is called when new notifications are fetched. It updates the notifications state with the new notifications.
  2. dismissNotification: This callback is called when a notification is dismissed. It uses the useDismissNotification hook to dismiss the notification in the database.
  3. useFetchNotifications: This hook is responsible for fetching initial notifications and subscribing to real-time updates. We will discuss this hook in the next section.
  4. useDismissNotification: This hook is responsible for dismissing a notification in the database.
  5. 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.ts
import { 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:

  1. We use the useQuery hook to subscribe to the real-time channel. We pass the accountId as the filter to the channel.
  2. We use the useEffect hook to clean up the subscription when the component unmounts.
  3. When a new notification is received, we call the onNotifications callback with the new notifications.
  4. 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.ts
import { 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 layout
export default async function AppLayout() {
const user = await requireUser();
return (
<header>
<NotificationsPopover
accountId={user.id}
realtime={true}
/>
</header>
);
}

Some examples of notifications:

// Creating notifications
const supabaseClient = getSupabaseClient();
const notificationsService = createNotificationsService(supabaseClient);
// Info notification
await notificationsService.createNotification({
accountId: userId,
body: "Your export is ready!",
type: "info",
link: "/exports/latest"
});
// Warning notification
await notificationsService.createNotification({
accountId: userId,
body: "Your subscription will expire soon",
type: "warning",
link: "/billing"
});
// Error notification
await notificationsService.createNotification({
accountId: userId,
body: "Payment failed",
type: "error",
link: "/billing/update"
});

Performance Considerations

To keep your notification system running smoothly:

  1. Database Indexes: We created an index on (account_id, dismissed, expires_at) to speed up queries.
  2. Cleanup Job: Set up a cron job to delete expired notifications:
delete from notifications where expires_at < now();
  1. 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

  1. Real-time Cleanup: Always unsubscribe from real-time channels when components unmount.
  2. Duplicate Notifications: Handle duplicate notifications when merging real-time updates with existing ones.
  3. Time Zones: Use timestamptz for dates to handle different time zones correctly.
  4. Error States: Handle network errors and show appropriate UI feedback.

Next Steps

You can extend this system by:

  1. Adding notification categories or priorities
  2. Supporting rich content in notifications
  3. Adding sound or desktop notifications
  4. Implementing notification preferences
  5. Supporting batch operations (mark all as read)
  6. 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).