Add In-App Notifications to Any SaaS Boilerplate 2026
·StarterPick Team
notificationsknocknovussesaas-boilerplate2026
TL;DR
For production SaaS: Knock ($0 to start) or Novu (open source) handles multi-channel notifications (in-app, email, SMS, push) with a pre-built notification center component. For simple in-app only: custom DB table + Server-Sent Events. The bell icon + dropdown pattern is 3 files: a notifications DB table, a GET /api/notifications route, and a <NotificationBell> component.
Key Takeaways
- Knock: Managed notification service, React component, $0 free tier
- Novu: Open source, self-hostable, supports 30+ providers (email, SMS, push)
- DIY: Postgres notifications table + SSE for real-time, good for simple cases
- Toast notifications:
sonnerorreact-hot-toastfor ephemeral feedback - Pattern: Mark as read, pagination, preference management
Option 1: Custom DB + SSE (Simple)
// schema.prisma:
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
type String // "team_invite", "payment_failed", "new_comment"
title String
message String
href String? // Optional link
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read, createdAt])
}
// lib/notifications.ts — send notification:
export async function createNotification(
userId: string,
notification: {
type: string;
title: string;
message: string;
href?: string;
}
) {
await db.notification.create({
data: { userId, ...notification },
});
}
// Usage throughout your app:
await createNotification(userId, {
type: 'team_invite',
title: 'Team Invitation',
message: `${inviter.name} invited you to join ${team.name}`,
href: `/teams/${team.id}/accept`,
});
// app/api/notifications/route.ts:
export async function GET() {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const notifications = await db.notification.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
take: 20,
});
const unreadCount = await db.notification.count({
where: { userId: session.user.id, read: false },
});
return Response.json({ notifications, unreadCount });
}
// Mark all read:
export async function PATCH() {
const session = await auth();
await db.notification.updateMany({
where: { userId: session.user.id, read: false },
data: { read: true },
});
return Response.json({ success: true });
}
// components/NotificationBell.tsx:
'use client';
import { useState } from 'react';
import useSWR from 'swr';
import { Bell } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
export function NotificationBell() {
const { data, mutate } = useSWR('/api/notifications');
const [open, setOpen] = useState(false);
const markAllRead = async () => {
await fetch('/api/notifications', { method: 'PATCH' });
mutate();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{data?.unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
>
{data.unreadCount > 9 ? '9+' : data.unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="font-semibold">Notifications</h3>
{data?.unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead}>
Mark all read
</Button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{data?.notifications?.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No notifications</p>
) : (
data?.notifications?.map((n: any) => (
<a
key={n.id}
href={n.href ?? '#'}
className={`block px-4 py-3 hover:bg-muted border-b ${!n.read ? 'bg-blue-50 dark:bg-blue-950/20' : ''}`}
>
<p className="text-sm font-medium">{n.title}</p>
<p className="text-sm text-muted-foreground">{n.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(n.createdAt).toRelative?.() ?? new Date(n.createdAt).toLocaleDateString()}
</p>
</a>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
Option 2: Knock (Managed)
npm install @knocklabs/react
// providers/KnockProvider.tsx:
'use client';
import { KnockProvider, KnockFeedProvider } from '@knocklabs/react';
import '@knocklabs/react/dist/index.css';
export function AppKnockProvider({ userId, userToken, children }: {
userId: string;
userToken: string;
children: React.ReactNode;
}) {
return (
<KnockProvider
apiKey={process.env.NEXT_PUBLIC_KNOCK_PUBLIC_API_KEY!}
userId={userId}
userToken={userToken}
>
<KnockFeedProvider feedId={process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID!}>
{children}
</KnockFeedProvider>
</KnockProvider>
);
}
// components/KnockNotificationBell.tsx:
import { NotificationIconButton, NotificationFeedPopover } from '@knocklabs/react';
import { useRef, useState } from 'react';
export function KnockNotificationBell() {
const [open, setOpen] = useState(false);
const buttonRef = useRef(null);
return (
<>
<NotificationIconButton
ref={buttonRef}
onClick={() => setOpen(!open)}
/>
<NotificationFeedPopover
buttonRef={buttonRef}
isVisible={open}
onClose={() => setOpen(false)}
/>
</>
);
}
// Send notification via Knock API:
import Knock from '@knocklabs/node';
const knock = new Knock(process.env.KNOCK_API_KEY!);
await knock.workflows.trigger('team-invite', {
recipients: [userId],
data: {
inviterName: inviter.name,
teamName: team.name,
inviteUrl: `https://yourapp.com/teams/${team.id}/accept`,
},
});
Decision Guide
Use Knock if:
→ Multi-channel (in-app + email + push + SMS)
→ Want pre-built React notification center
→ Need workflow builder (not in code)
→ $0 free tier, scales with usage
Use Novu if:
→ Open source and self-hosted
→ Need 30+ provider integrations
→ B2B where customer data sovereignty matters
Use custom DIY if:
→ Simple in-app only (no email/push needed)
→ Cost matters, already have Postgres
→ 5-10 notification types max
Find SaaS boilerplates with notifications at StarterPick.