Back to Blog

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.

Viprasol Tech Team
March 12, 2027
13 min read

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

ScopeTeamTimelineCost Range
Basic notification list (no real-time)1 dev2โ€“3 days$600โ€“1,200
SSE real-time + unread badge1 dev1 week$1,500โ€“3,000
Full system (SSE + preferences + domain hooks + email)1โ€“2 devs2โ€“3 weeks$5,000โ€“10,000
Enterprise (push, digest, multi-channel routing, analytics)2โ€“3 devs4โ€“6 weeks$12,000โ€“25,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.