ยทUpdated

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

Updated September 2026: fixed the Realtime filter syntax, moved the channel subscription out of React Query into a useEffect with proper cleanup, added the supabase_realtime publication step that the original version was missing, and corrected the server-side insert example.

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.
Click to expand

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())
);

There is no insert policy on purpose: users should not be able to create notifications for themselves or for others. Notifications are inserted from server code with the admin client (see Creating Notifications).

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!

Enabling Realtime on the Table

Supabase only streams changes for tables that belong to the supabase_realtime publication. Without this step, the subscription connects but never receives any events:

alter publication supabase_realtime add table public.notifications;

Postgres Changes respects RLS: each subscriber only receives INSERT events for rows its select policy allows, so the notifications_read_self policy above also protects the realtime stream. See the Supabase Postgres Changes docs.

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

Supabase Realtime pushes new rows to the browser over a WebSocket. We use React Query for the initial fetch and a plain useEffect for the subscription, because a subscription is a side effect with a lifecycle, not data to cache.

The entry point combines both:

// use-fetch-notifications.ts
import { useEffect } from 'react';
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. useFetchInitialNotifications loads the current, non-dismissed notifications with React Query.
  2. useNotificationsStream subscribes to new rows for the same account while realtime is true.
  3. Both paths call onNotifications, which merges and de-duplicates notifications in the popover's state.

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:

// use-notifications-stream.ts
import { useEffect, useRef } from 'react';
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();
// keep the latest callback without re-subscribing on every render
const onNotificationsRef = useRef(params.onNotifications);
useEffect(() => {
onNotificationsRef.current = params.onNotifications;
}, [params.onNotifications]);
useEffect(() => {
if (!params.enabled) {
return;
}
const channel = client
.channel(`notifications:${params.accountId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `account_id=eq.${params.accountId}`,
},
(payload) => {
onNotificationsRef.current([payload.new as Notification]);
},
)
.subscribe();
return () => {
void client.removeChannel(channel);
};
}, [client, params.accountId, params.enabled]);
}

Three details matter here:

  1. Filter syntax: eq takes a bare value (account_id=eq.<uuid>). The parenthesised form is only for in, e.g. account_id=in.(<uuid1>,<uuid2>).
  2. Channel name: including the account ID gives each account its own channel, so switching accounts doesn't reuse a channel subscribed with the old filter.
  3. Cleanup: client.removeChannel(channel) unsubscribes and removes the channel from the client when the component unmounts or the account changes.

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 must only run in server-side code. Because the table has no insert policy, it needs a client that bypasses RLS (the service-role/secret key), so validate who is allowed to notify whom before calling it.

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 (server-side only)
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
const adminClient = getSupabaseServerAdminClient();
const notificationsService = createNotificationsService(adminClient);
// Info notification
await notificationsService.createNotification({
account_id: userId,
body: "Your export is ready!",
type: "info",
link: "/exports/latest"
});
// Warning notification
await notificationsService.createNotification({
account_id: userId,
body: "Your subscription will expire soon",
type: "warning",
link: "/billing"
});
// Error notification
await notificationsService.createNotification({
account_id: userId,
body: "Payment failed",
type: "error",
link: "/billing/update"
});

Performance Considerations

To keep your notification system running smoothly:

  1. Scale: Postgres Changes authorizes every event per subscriber. Supabase recommends switching to Broadcast if you expect more than ~3,000 concurrent subscribers on the same changes.
  2. Database Indexes: We created an index on (account_id, dismissed, expires_at) to speed up queries.
  3. Cleanup Job: Delete expired notifications on a schedule. On Supabase you can use the pg_cron extension:
select cron.schedule(
'delete-expired-notifications',
'0 3 * * *', -- every day at 03:00 UTC
$$ delete from public.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. Missing publication: If no events arrive, check that the table is in the supabase_realtime publication.
  2. Real-time Cleanup: Always remove real-time channels when components unmount.
  3. Duplicate Notifications: Handle duplicate notifications when merging real-time updates with existing ones.
  4. Time Zones: Use timestamptz for dates to handle different time zones correctly.
  5. 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 Supabase 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).

Ready to get started? Check out the free Makerkit course to learn how to build your SaaS.