SaaS In-App Notification Center: Real-Time SSE, Read/Unread State, and User Preferences
Build a production SaaS in-app notification center with Server-Sent Events for real-time delivery, read/unread tracking, notification types, user preferences, and a React notification bell component.
Every SaaS product needs to tell users what's happening: a teammate commented on their task, a payment failed, an export is ready. Email handles async notifications. Push handles mobile. In-app notifications handle the user who's already looking at your product โ they're the fastest feedback loop you have, and they drive re-engagement within the session.
This guide builds a complete in-app notification system: database schema, real-time delivery via SSE, React notification bell with unread count, and per-user preference management.
Database Schema
CREATE TYPE notification_type AS ENUM (
'task_assigned',
'task_commented',
'task_completed',
'mention',
'invite_accepted',
'payment_failed',
'export_ready',
'team_joined',
'billing_update',
'system'
);
CREATE TYPE notification_channel AS ENUM ('in_app', 'email', 'push');
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
type notification_type NOT NULL,
title TEXT NOT NULL,
body TEXT,
action_url TEXT,
entity_type TEXT, -- 'task', 'project', 'invoice', etc.
entity_id UUID,
metadata JSONB NOT NULL DEFAULT '{}',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_notif_user_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE AND is_archived = FALSE;
CREATE INDEX idx_notif_user_all ON notifications(user_id, created_at DESC) WHERE is_archived = FALSE;
CREATE INDEX idx_notif_workspace ON notifications(workspace_id, created_at DESC);
CREATE INDEX idx_notif_entity ON notifications(entity_type, entity_id) WHERE entity_type IS NOT NULL;
-- User notification preferences
CREATE TABLE notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notification_type notification_type NOT NULL,
in_app BOOLEAN NOT NULL DEFAULT TRUE,
email BOOLEAN NOT NULL DEFAULT TRUE,
push BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, notification_type)
);
// prisma/schema.prisma additions
model Notification {
id String @id @default(uuid())
workspaceId String
userId String
actorId String?
type NotificationType
title String
body String?
actionUrl String?
entityType String?
entityId String?
metadata Json @default("{}")
isRead Boolean @default(false)
readAt DateTime?
isArchived Boolean @default(false)
archivedAt DateTime?
createdAt DateTime @default(now())
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
actor User? @relation("NotificationActor", fields: [actorId], references: [id], onDelete: SetNull)
@@index([userId, createdAt(sort: Desc)])
@@index([workspaceId, createdAt(sort: Desc)])
}
model NotificationPreference {
id String @id @default(uuid())
userId String
notificationType NotificationType
inApp Boolean @default(true)
email Boolean @default(true)
push Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, notificationType])
}
enum NotificationType {
TASK_ASSIGNED
TASK_COMMENTED
TASK_COMPLETED
MENTION
INVITE_ACCEPTED
PAYMENT_FAILED
EXPORT_READY
TEAM_JOINED
BILLING_UPDATE
SYSTEM
}
Notification Service
// lib/notifications/service.ts
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { sendNotificationEmail } from "@/lib/email/notification-email";
import type { NotificationType } from "@prisma/client";
interface CreateNotificationInput {
workspaceId: string;
userId: string;
actorId?: string;
type: NotificationType;
title: string;
body?: string;
actionUrl?: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, unknown>;
}
export async function createNotification(
input: CreateNotificationInput
): Promise<void> {
// Check user preferences before creating
const pref = await prisma.notificationPreference.findUnique({
where: {
userId_notificationType: {
userId: input.userId,
notificationType: input.type,
},
},
});
// Default to enabled if no preference record exists
const inAppEnabled = pref?.inApp ?? true;
const emailEnabled = pref?.email ?? true;
if (!inAppEnabled) return; // Skip entirely if in-app disabled
const notification = await prisma.notification.create({
data: {
workspaceId: input.workspaceId,
userId: input.userId,
actorId: input.actorId,
type: input.type,
title: input.title,
body: input.body,
actionUrl: input.actionUrl,
entityType: input.entityType,
entityId: input.entityId,
metadata: input.metadata ?? {},
},
include: {
actor: { select: { name: true, image: true } },
},
});
// Publish to Redis for real-time delivery
await redis.publish(
`notifications:${input.userId}`,
JSON.stringify({
id: notification.id,
type: notification.type,
title: notification.title,
body: notification.body,
actionUrl: notification.actionUrl,
actor: notification.actor,
createdAt: notification.createdAt.toISOString(),
})
);
// Queue email if enabled (non-blocking)
if (emailEnabled && input.actorId !== input.userId) {
sendNotificationEmail({
userId: input.userId,
notificationId: notification.id,
type: input.type,
title: input.title,
body: input.body,
actionUrl: input.actionUrl,
}).catch((err) =>
console.error("Failed to send notification email:", err)
);
}
}
// Bulk create for broadcasts (e.g., system announcements)
export async function broadcastToWorkspace(
workspaceId: string,
notification: Omit<CreateNotificationInput, "workspaceId" | "userId">
): Promise<void> {
const members = await prisma.workspaceMember.findMany({
where: { workspaceId },
select: { userId: true },
});
// Create all notifications in one transaction
await prisma.notification.createMany({
data: members.map((m) => ({
workspaceId,
userId: m.userId,
type: notification.type,
title: notification.title,
body: notification.body,
actionUrl: notification.actionUrl,
entityType: notification.entityType,
entityId: notification.entityId,
metadata: notification.metadata ?? {},
})),
});
// Publish to each user's SSE channel
await Promise.all(
members.map((m) =>
redis.publish(
`notifications:${m.userId}`,
JSON.stringify({
type: notification.type,
title: notification.title,
body: notification.body,
actionUrl: notification.actionUrl,
createdAt: new Date().toISOString(),
})
)
)
);
}
// Mark notifications as read
export async function markAsRead(
userId: string,
notificationIds: string[]
): Promise<void> {
await prisma.notification.updateMany({
where: {
id: { in: notificationIds },
userId,
isRead: false,
},
data: {
isRead: true,
readAt: new Date(),
},
});
}
export async function markAllAsRead(userId: string): Promise<void> {
await prisma.notification.updateMany({
where: { userId, isRead: false, isArchived: false },
data: { isRead: true, readAt: new Date() },
});
}
export async function getUnreadCount(userId: string): Promise<number> {
return prisma.notification.count({
where: { userId, isRead: false, isArchived: false },
});
}
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Domain Hooks
// lib/notifications/hooks.ts
// Called from domain events โ keeps notification logic co-located with triggers
import { createNotification } from "./service";
export async function onTaskAssigned(params: {
workspaceId: string;
taskId: string;
taskTitle: string;
assigneeId: string;
assignedById: string;
taskUrl: string;
}) {
if (params.assigneeId === params.assignedById) return; // Don't notify yourself
await createNotification({
workspaceId: params.workspaceId,
userId: params.assigneeId,
actorId: params.assignedById,
type: "TASK_ASSIGNED",
title: "Task assigned to you",
body: params.taskTitle,
actionUrl: params.taskUrl,
entityType: "task",
entityId: params.taskId,
});
}
export async function onMentioned(params: {
workspaceId: string;
mentionedUserId: string;
mentionedById: string;
context: string; // "task comment", "project description", etc.
contextTitle: string;
actionUrl: string;
entityType: string;
entityId: string;
}) {
if (params.mentionedUserId === params.mentionedById) return;
await createNotification({
workspaceId: params.workspaceId,
userId: params.mentionedUserId,
actorId: params.mentionedById,
type: "MENTION",
title: `You were mentioned in ${params.context}`,
body: params.contextTitle,
actionUrl: params.actionUrl,
entityType: params.entityType,
entityId: params.entityId,
});
}
export async function onPaymentFailed(params: {
workspaceId: string;
ownerId: string;
invoiceId: string;
amount: number;
currency: string;
}) {
await createNotification({
workspaceId: params.workspaceId,
userId: params.ownerId,
type: "PAYMENT_FAILED",
title: "Payment failed",
body: `We couldn't charge your card for $${(params.amount / 100).toFixed(2)}. Please update your payment method.`,
actionUrl: "/billing",
entityType: "invoice",
entityId: params.invoiceId,
metadata: { invoiceId: params.invoiceId, amount: params.amount },
});
}
export async function onExportReady(params: {
workspaceId: string;
userId: string;
exportId: string;
entityType: string;
rowCount: number;
downloadUrl: string;
}) {
await createNotification({
workspaceId: params.workspaceId,
userId: params.userId,
type: "EXPORT_READY",
title: "Your export is ready",
body: `${params.rowCount.toLocaleString()} ${params.entityType} exported`,
actionUrl: params.downloadUrl,
entityType: "export",
entityId: params.exportId,
});
}
API Routes
// app/api/notifications/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const unreadOnly = searchParams.get("unread") === "true";
const cursor = searchParams.get("cursor");
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50);
const [notifications, unreadCount] = await Promise.all([
prisma.notification.findMany({
where: {
userId: session.user.id,
isArchived: false,
...(unreadOnly ? { isRead: false } : {}),
...(cursor ? { createdAt: { lt: new Date(cursor) } } : {}),
},
orderBy: { createdAt: "desc" },
take: limit + 1,
select: {
id: true,
type: true,
title: true,
body: true,
actionUrl: true,
entityType: true,
entityId: true,
isRead: true,
readAt: true,
createdAt: true,
actor: { select: { name: true, image: true } },
},
}),
prisma.notification.count({
where: { userId: session.user.id, isRead: false, isArchived: false },
}),
]);
const hasMore = notifications.length > limit;
const items = hasMore ? notifications.slice(0, limit) : notifications;
const nextCursor = hasMore
? items[items.length - 1].createdAt.toISOString()
: null;
return NextResponse.json({ notifications: items, unreadCount, nextCursor });
}
// app/api/notifications/read/route.ts
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { ids, all } = await req.json();
if (all) {
await markAllAsRead(session.user.id);
} else if (Array.isArray(ids) && ids.length > 0) {
await markAsRead(session.user.id, ids);
}
return NextResponse.json({ success: true });
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
SSE Endpoint for Real-Time Delivery
// app/api/notifications/stream/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { redis } from "@/lib/redis";
import { getUnreadCount } from "@/lib/notifications/service";
export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // SSE requires Node.js runtime
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: object) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
};
// Send initial unread count
const unreadCount = await getUnreadCount(userId);
send("connected", { unreadCount });
// Subscribe to Redis pub/sub
const subscriber = redis.duplicate();
await subscriber.subscribe(`notifications:${userId}`);
subscriber.on("message", (_channel, message) => {
const notification = JSON.parse(message);
send("notification", notification);
});
// Heartbeat every 25s (keep connection alive through proxies)
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(": heartbeat\n\n"));
} catch {
clearInterval(heartbeat);
}
}, 25_000);
// Cleanup on client disconnect
req.signal.addEventListener("abort", () => {
clearInterval(heartbeat);
subscriber.unsubscribe(`notifications:${userId}`).catch(() => {});
subscriber.quit().catch(() => {});
try {
controller.close();
} catch {}
});
},
});
return new NextResponse(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // Disable nginx buffering
},
});
}
React Notification Bell Component
// components/notifications/notification-bell.tsx
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Bell } from "lucide-react";
import { NotificationPanel } from "./notification-panel";
import { useNotifications } from "@/hooks/use-notifications";
export function NotificationBell() {
const [isOpen, setIsOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const { unreadCount } = useNotifications();
// Close on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [isOpen]);
return (
<div className="relative" ref={panelRef}>
<button
onClick={() => setIsOpen((o) => !o)}
className="relative p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[16px] h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{isOpen && (
<NotificationPanel onClose={() => setIsOpen(false)} />
)}
</div>
);
}
// hooks/use-notifications.ts
"use client";
import {
useState,
useEffect,
useCallback,
useRef,
} from "react";
interface NotificationItem {
id: string;
type: string;
title: string;
body?: string;
actionUrl?: string;
isRead: boolean;
createdAt: string;
actor?: { name?: string; image?: string };
}
interface UseNotificationsReturn {
notifications: NotificationItem[];
unreadCount: number;
hasMore: boolean;
isLoading: boolean;
markAsRead: (ids: string[]) => Promise<void>;
markAllAsRead: () => Promise<void>;
loadMore: () => void;
}
export function useNotifications(): UseNotificationsReturn {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const fetchNotifications = useCallback(async (nextCursor?: string) => {
setIsLoading(true);
const params = new URLSearchParams({ limit: "20" });
if (nextCursor) params.set("cursor", nextCursor);
const res = await fetch(`/api/notifications?${params}`);
const data = await res.json();
setNotifications((prev) =>
nextCursor ? [...prev, ...data.notifications] : data.notifications
);
setUnreadCount(data.unreadCount);
setCursor(data.nextCursor);
setHasMore(!!data.nextCursor);
setIsLoading(false);
}, []);
// Initial fetch
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
// SSE connection for real-time updates
useEffect(() => {
const es = new EventSource("/api/notifications/stream");
eventSourceRef.current = es;
es.addEventListener("connected", (e) => {
const { unreadCount } = JSON.parse(e.data);
setUnreadCount(unreadCount);
});
es.addEventListener("notification", (e) => {
const notification = JSON.parse(e.data) as NotificationItem;
// Prepend new notification and increment unread count
setNotifications((prev) => [
{ ...notification, isRead: false },
...prev,
]);
setUnreadCount((n) => n + 1);
// Show browser notification if permitted
if (Notification.permission === "granted") {
new Notification(notification.title, {
body: notification.body,
icon: "/icon-192.png",
});
}
});
es.onerror = () => {
// EventSource auto-reconnects on error โ no manual handling needed
};
return () => {
es.close();
};
}, []);
const markAsRead = useCallback(async (ids: string[]) => {
setNotifications((prev) =>
prev.map((n) =>
ids.includes(n.id) ? { ...n, isRead: true } : n
)
);
setUnreadCount((count) => Math.max(0, count - ids.filter((id) =>
notifications.find((n) => n.id === id && !n.isRead)
).length));
await fetch("/api/notifications/read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
}, [notifications]);
const markAllAsRead = useCallback(async () => {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
setUnreadCount(0);
await fetch("/api/notifications/read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ all: true }),
});
}, []);
const loadMore = useCallback(() => {
if (cursor && !isLoading) fetchNotifications(cursor);
}, [cursor, isLoading, fetchNotifications]);
return {
notifications,
unreadCount,
hasMore,
isLoading,
markAsRead,
markAllAsRead,
loadMore,
};
}
// components/notifications/notification-panel.tsx
"use client";
import { useRef, useEffect } from "react";
import { CheckCheck, X, ExternalLink } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { useNotifications } from "@/hooks/use-notifications";
import { NOTIFICATION_ICONS } from "./notification-icons";
interface NotificationPanelProps {
onClose: () => void;
}
export function NotificationPanel({ onClose }: NotificationPanelProps) {
const { notifications, hasMore, isLoading, markAsRead, markAllAsRead, loadMore } =
useNotifications();
const bottomRef = useRef<HTMLDivElement>(null);
// Infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
loadMore();
}
},
{ threshold: 0.5 }
);
if (bottomRef.current) observer.observe(bottomRef.current);
return () => observer.disconnect();
}, [hasMore, isLoading, loadMore]);
return (
<div className="absolute right-0 top-full mt-2 w-96 max-h-[500px] bg-white rounded-xl shadow-xl border border-gray-200 flex flex-col overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="font-semibold text-gray-900">Notifications</h3>
<div className="flex items-center gap-2">
<button
onClick={markAllAsRead}
className="text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* List */}
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<Bell className="w-8 h-8 mb-2" />
<p className="text-sm">No notifications yet</p>
</div>
) : (
notifications.map((n) => (
<NotificationRow
key={n.id}
notification={n}
onRead={() => markAsRead([n.id])}
/>
))
)}
<div ref={bottomRef} className="h-1" />
{isLoading && (
<div className="py-3 text-center text-sm text-gray-400">
Loading...
</div>
)}
</div>
</div>
);
}
function NotificationRow({
notification,
onRead,
}: {
notification: any;
onRead: () => void;
}) {
const Icon = NOTIFICATION_ICONS[notification.type] ?? Bell;
const handleClick = () => {
if (!notification.isRead) onRead();
if (notification.actionUrl) {
window.location.href = notification.actionUrl;
}
};
return (
<div
onClick={handleClick}
className={`flex gap-3 px-4 py-3 hover:bg-gray-50 cursor-pointer transition border-b border-gray-50 last:border-0 ${
!notification.isRead ? "bg-blue-50/40" : ""
}`}
>
{/* Actor avatar or type icon */}
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center mt-0.5">
{notification.actor?.image ? (
<img
src={notification.actor.image}
alt={notification.actor.name ?? ""}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<Icon className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm ${!notification.isRead ? "font-medium text-gray-900" : "text-gray-700"}`}>
{notification.title}
</p>
{notification.body && (
<p className="text-xs text-gray-500 mt-0.5 truncate">{notification.body}</p>
)}
<p className="text-xs text-gray-400 mt-1">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
{!notification.isRead && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-500 rounded-full mt-2" />
)}
</div>
);
}
Notification Preferences UI
// app/settings/notifications/page.tsx
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { NotificationPreferencesForm } from "./preferences-form";
const NOTIFICATION_TYPES = [
{ type: "TASK_ASSIGNED", label: "Task assigned to you", description: "When someone assigns a task to you" },
{ type: "TASK_COMMENTED", label: "Task comments", description: "When someone comments on your tasks" },
{ type: "MENTION", label: "Mentions", description: "When someone @mentions you" },
{ type: "INVITE_ACCEPTED", label: "Invite accepted", description: "When someone joins via your invite" },
{ type: "PAYMENT_FAILED", label: "Payment failed", description: "Billing and payment issues" },
{ type: "EXPORT_READY", label: "Export ready", description: "When your data exports complete" },
] as const;
export default async function NotificationPreferencesPage() {
const session = await auth();
if (!session?.user) redirect("/auth/signin");
const preferences = await prisma.notificationPreference.findMany({
where: { userId: session.user.id },
});
const prefMap = Object.fromEntries(
preferences.map((p) => [p.notificationType, p])
);
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<h1 className="text-2xl font-semibold mb-2">Notification Preferences</h1>
<p className="text-gray-500 text-sm mb-8">
Choose how and when you receive notifications.
</p>
<NotificationPreferencesForm
types={NOTIFICATION_TYPES}
initialPreferences={prefMap}
/>
</div>
);
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic notification list (no real-time) | 1 dev | 2โ3 days | $600โ1,200 |
| SSE real-time + unread badge | 1 dev | 1 week | $1,500โ3,000 |
| Full system (SSE + preferences + domain hooks + email) | 1โ2 devs | 2โ3 weeks | $5,000โ10,000 |
| Enterprise (push, digest, multi-channel routing, analytics) | 2โ3 devs | 4โ6 weeks | $12,000โ25,000 |
See Also
- SaaS Activity Feed Architecture
- SaaS Notification Preferences System
- React Server Actions and Forms
- SaaS Slack Integration with OAuth and Block Kit
- SaaS Webhook System with Delivery Guarantees
Working With Viprasol
In-app notifications are the invisible glue that keeps users engaged within your product. Done well, they feel instant and relevant. Done poorly, they're noise that users learn to ignore. Our team builds notification systems that are real-time, preference-aware, and connected to the domain events that actually matter to your users.
What we deliver:
- PostgreSQL notification schema with preference management
- SSE real-time delivery with Redis pub/sub
- React notification bell, panel, and infinite scroll list
- Domain hooks wired to your business events
- Email and push notification integration
Talk to our team about your notification system โ
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.