Skip to main content

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: sonner or react-hot-toast for 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.

Comments