Every SaaS needs a notification bell. Users expect to know when something happens -- a new comment, a payment, a teammate mention -- without refreshing the page. Building it from scratch takes longer than it should.
Here is the full pattern: schema, service, API routes, and a polling hook that keeps the badge live without a WebSocket server.
One notifications table covers every event type you will ever need:
// modules/notification/notification.schema.ts
import { pgTable, text, boolean, timestamp, uuid } from 'drizzle-orm/pg-core';
export const notificationTable = pgTable('notifications', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
type: text('type').notNull(),
title: text('title').notNull(),
body: text('body').notNull(),
link: text('link'),
read: boolean('read').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
type is a plain string -- no enum. You will add event types over the product's lifetime; a string column means a new type is a one-liner in your service, not a migration.
Two jobs here: creating notifications and reading them.
// modules/notification/notification.service.ts
export const notificationService = {
async create(userId: string, type: string, title: string, body: string, link?: string) {
return notificationRepo.create({ userId, type, title, body, link });
},
async listForUser(userId: string, limit = 20) {
return notificationRepo.findByUser(userId, limit);
},
async getUnreadCount(userId: string) {
return notificationRepo.countUnread(userId);
},
async markAllRead(userId: string) {
return notificationRepo.markAllRead(userId);
},
};
Other services call notificationService.create() as a side effect. When a comment is posted:
// inside commentService.create()
await notificationService.create(
post.authorId,
'comment',
'New comment on your post',
`${commenter.name} replied to "${post.title}"`,
`/posts/${post.slug}`
);
No event bus, no queue -- just a direct call. For most SaaS apps at launch, this is the right level of complexity.
Two routes cover everything the frontend needs:
// app/api/notifications/route.ts
export async function GET(req: Request) {
const user = await getUserFromRequest(req);
const [notifications, unreadCount] = await Promise.all([
notificationService.listForUser(user.id),
notificationService.getUnreadCount(user.id),
]);
return Response.json({ notifications, unreadCount });
}
export async function PATCH(req: Request) {
const user = await getUserFromRequest(req);
await notificationService.markAllRead(user.id);
return Response.json({ ok: true });
}
Add a PATCH /api/notifications/[id] route if you need per-notification tracking. For most apps, "mark all read" when the user opens the panel is enough.
You do not need a WebSocket server. A 30-second poll is undetectable to users and costs almost nothing on Neon's serverless plan.
// hooks/api/useNotifications.ts
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
import { fetcher } from '@/lib/fetcher';
const KEY = '/api/notifications';
export function useNotifications() {
const { data, mutate } = useSWR(KEY, fetcher, { refreshInterval: 30_000 });
const { trigger: markAllRead } = useSWRMutation(
KEY,
(url) => fetch(url, { method: 'PATCH' }).then((r) => r.json()),
{ onSuccess: () => mutate() }
);
return {
notifications: data?.notifications ?? [],
unreadCount: data?.unreadCount ?? 0,
markAllRead,
};
}
// components/layout/NotificationBell.tsx
'use client';
import { Bell } from 'lucide-react';
import { useNotifications } from '@/hooks/api/useNotifications';
export function NotificationBell() {
const { unreadCount, markAllRead } = useNotifications();
return (
<button className="relative" onClick={() => markAllRead()} aria-label="Notifications">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] text-white font-medium">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
);
}
Drop <NotificationBell /> into your header and you have a live unread count that clears when users open the panel.
With this boilerplate, the Drizzle client, JWT auth, and SWR fetcher are already wired. You add the notifications schema, run npm run db:generate && npm run db:migrate, and start calling notificationService.create() from any service that generates an event.
Total time to a working notification bell: under an hour.
Clone the repo, follow the module pattern above, and ship. No extra infrastructure required -- just Postgres and the API routes you already have.
Get the boilerplate and have notifications running before lunch.