Back to Blog

SaaS Notification Preferences in 2026: Settings UI, Digest Scheduling, and Multi-Channel Management

Build a production SaaS notification preferences system: per-event settings UI, digest scheduling with cron, email/Slack/in-app channel management, and unsubscribe flows.

Viprasol Tech Team
January 21, 2027
13 min read

SaaS Notification Preferences in 2026: Settings UI, Digest Scheduling, and Multi-Channel Management

Notification fatigue kills retention. Users who get too many emails disable all notifications, missing the important ones. Users who get too few feel left out of their team's activity. The solution is giving users precise control: choose which events trigger notifications, which channels receive them (email, Slack, in-app), and whether they want individual alerts or a daily digest.

This post builds the complete notification preference system: the database schema that captures per-user, per-event, per-channel settings; the settings UI with sensible defaults; the send function that respects preferences; and the digest scheduler that batches email-averse users into daily summaries.


Notification Event Taxonomy

Define every notification event your product can generate:

// lib/notifications/events.ts

export type NotificationChannel = "email" | "slack" | "in_app" | "push";

export type NotificationCategory =
  | "team_activity"   // Comments, mentions, task updates
  | "billing"         // Payment failures, plan changes, invoices
  | "security"        // Login from new device, password change
  | "reports"         // Weekly digest, monthly summaries
  | "system";         // Maintenance, outages, announcements

export interface NotificationEventDef {
  id: string;
  label: string;
  description: string;
  category: NotificationCategory;
  defaultChannels: NotificationChannel[];    // On by default
  requiredChannels?: NotificationChannel[];  // Cannot be disabled
  supportedChannels: NotificationChannel[];
  digestEligible: boolean;   // Can be batched into digest
  importance: "high" | "normal" | "low";
}

export const NOTIFICATION_EVENTS: Record<string, NotificationEventDef> = {
  "task.assigned":       { id: "task.assigned",       label: "Task assigned to me",     description: "Someone assigned you a task",                  category: "team_activity", defaultChannels: ["email", "in_app"],     supportedChannels: ["email", "slack", "in_app", "push"], digestEligible: true,  importance: "normal" },
  "task.commented":      { id: "task.commented",      label: "Comment on my task",      description: "Someone commented on a task you're involved in", category: "team_activity", defaultChannels: ["in_app"],              supportedChannels: ["email", "slack", "in_app"],         digestEligible: true,  importance: "low"    },
  "task.mentioned":      { id: "task.mentioned",      label: "Mentioned in a comment",  description: "Someone @mentioned you",                        category: "team_activity", defaultChannels: ["email", "in_app"],     supportedChannels: ["email", "slack", "in_app", "push"], digestEligible: false, importance: "high"   },
  "project.invite":      { id: "project.invite",      label: "Project invitation",      description: "You were added to a project",                   category: "team_activity", defaultChannels: ["email", "in_app"],     supportedChannels: ["email", "in_app"],                  digestEligible: false, importance: "high"   },
  "billing.payment_failed": { id: "billing.payment_failed", label: "Payment failed",   description: "A payment attempt failed",                      category: "billing",       defaultChannels: ["email"],               supportedChannels: ["email", "in_app"],         requiredChannels: ["email"],            digestEligible: false, importance: "high"   },
  "billing.invoice":     { id: "billing.invoice",     label: "New invoice",             description: "An invoice is ready",                           category: "billing",       defaultChannels: ["email"],               supportedChannels: ["email"],                            digestEligible: false, importance: "normal" },
  "security.new_login":  { id: "security.new_login",  label: "New login detected",      description: "Sign-in from a new device or location",         category: "security",      defaultChannels: ["email"],               supportedChannels: ["email"],              requiredChannels: ["email"],            digestEligible: false, importance: "high"   },
  "report.weekly_digest": { id: "report.weekly_digest", label: "Weekly activity digest", description: "Summary of your team's week",                  category: "reports",       defaultChannels: ["email"],               supportedChannels: ["email"],                            digestEligible: false, importance: "low"    },
} as const;

export type NotificationEventId = keyof typeof NOTIFICATION_EVENTS;

Database Schema

-- Stores user's preferences per event per channel
CREATE TABLE notification_preferences (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  event_id    TEXT NOT NULL,          -- "task.assigned", "billing.payment_failed", etc.
  channel     TEXT NOT NULL,          -- "email" | "slack" | "in_app" | "push"
  enabled     BOOLEAN NOT NULL DEFAULT TRUE,
  
  -- Digest settings (per event, per channel)
  digest_mode TEXT,                   -- NULL = immediate | 'daily' | 'weekly'
  digest_hour INTEGER,                -- 0-23: what hour to send digest (user's local time)
  
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  
  UNIQUE (user_id, event_id, channel)
);

CREATE INDEX idx_notif_prefs_user ON notification_preferences(user_id);

-- Unsubscribe tokens (for one-click email unsubscribe)
CREATE TABLE notification_unsubscribe_tokens (
  token       TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(32), 'base64url'),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  event_id    TEXT,           -- NULL = unsubscribe from all
  channel     TEXT,           -- NULL = all channels
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  used_at     TIMESTAMPTZ
);

-- Pending digest items (notifications queued for digest delivery)
CREATE TABLE notification_digest_queue (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  event_id    TEXT NOT NULL,
  channel     TEXT NOT NULL,
  payload     JSONB NOT NULL,
  scheduled_for TIMESTAMPTZ NOT NULL,  -- When this digest should be sent
  sent_at     TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_digest_queue_pending ON notification_digest_queue(channel, scheduled_for)
  WHERE sent_at IS NULL;

๐Ÿš€ 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

Preference Resolution Service

// lib/notifications/preferences.ts
import { db } from "@/lib/db";
import { NOTIFICATION_EVENTS, NotificationChannel, NotificationEventId } from "./events";
import { cache } from "react";

interface ResolvedPreference {
  enabled: boolean;
  digestMode: "immediate" | "daily" | "weekly" | null;
  digestHour: number;
}

/**
 * Get a user's preference for a specific event+channel combination.
 * Falls back to the event's default if no preference is saved.
 */
export async function getUserPreference(
  userId: string,
  eventId: string,
  channel: NotificationChannel
): Promise<ResolvedPreference> {
  const eventDef = NOTIFICATION_EVENTS[eventId];

  // Required channels cannot be disabled
  if (eventDef?.requiredChannels?.includes(channel)) {
    return { enabled: true, digestMode: "immediate", digestHour: 9 };
  }

  const saved = await db.notificationPreference.findUnique({
    where: { userId_eventId_channel: { userId, eventId, channel } },
  });

  if (saved) {
    return {
      enabled: saved.enabled,
      digestMode: (saved.digestMode as any) ?? null,
      digestHour: saved.digestHour ?? 9,
    };
  }

  // Fall back to event defaults
  const isDefaultOn = eventDef?.defaultChannels.includes(channel) ?? false;
  return {
    enabled: isDefaultOn,
    digestMode: eventDef?.digestEligible ? "daily" : null,
    digestHour: 9,
  };
}

/**
 * Load all preferences for a user (for the settings UI).
 * Returns a map of eventId โ†’ channel โ†’ preference.
 */
export const getAllUserPreferences = cache(async (userId: string) => {
  const saved = await db.notificationPreference.findMany({
    where: { userId },
  });

  const savedMap = new Map(
    saved.map((p) => [`${p.eventId}:${p.channel}`, p])
  );

  // Build full preference map with defaults for unsaved events
  const result: Record<string, Record<string, ResolvedPreference>> = {};

  for (const [eventId, eventDef] of Object.entries(NOTIFICATION_EVENTS)) {
    result[eventId] = {};
    for (const channel of eventDef.supportedChannels) {
      const key = `${eventId}:${channel}`;
      const saved = savedMap.get(key);
      const isRequired = eventDef.requiredChannels?.includes(channel);
      const isDefaultOn = eventDef.defaultChannels.includes(channel);

      result[eventId][channel] = {
        enabled: isRequired ? true : (saved?.enabled ?? isDefaultOn),
        digestMode: (saved?.digestMode as any) ?? (eventDef.digestEligible ? "daily" : null),
        digestHour: saved?.digestHour ?? 9,
      };
    }
  }

  return result;
});

Notification Send Service (Preference-Aware)

// lib/notifications/send.ts
import { db } from "@/lib/db";
import { getUserPreference } from "./preferences";
import { sendEmail } from "@/lib/email/sender";
import { sendSlackMessage } from "@/lib/slack/sender";
import { createInAppNotification } from "@/lib/notifications/in-app";
import { NOTIFICATION_EVENTS, NotificationChannel } from "./events";

interface SendNotificationParams {
  userId: string;
  eventId: string;
  payload: {
    subject?: string;
    body: string;
    html?: string;
    data?: Record<string, unknown>;
    actionUrl?: string;
  };
}

export async function sendNotification({
  userId,
  eventId,
  payload,
}: SendNotificationParams) {
  const eventDef = NOTIFICATION_EVENTS[eventId];
  if (!eventDef) {
    console.warn(`Unknown notification event: ${eventId}`);
    return;
  }

  const user = await db.user.findUnique({
    where: { id: userId },
    select: { email: true, name: true, slackWebhookUrl: true, timezone: true },
  });

  if (!user) return;

  const channels: NotificationChannel[] = eventDef.supportedChannels as NotificationChannel[];

  await Promise.allSettled(
    channels.map(async (channel) => {
      const pref = await getUserPreference(userId, eventId, channel);
      if (!pref.enabled) return;

      // In-app: always immediate
      if (channel === "in_app") {
        await createInAppNotification({ userId, eventId, payload });
        return;
      }

      // Digest mode: queue for later delivery
      if (pref.digestMode && pref.digestMode !== "immediate" && eventDef.digestEligible) {
        const scheduledFor = getNextDigestTime(pref.digestMode, pref.digestHour, user.timezone);
        await db.notificationDigestQueue.create({
          data: {
            userId,
            eventId,
            channel,
            payload: payload as any,
            scheduledFor,
          },
        });
        return;
      }

      // Immediate delivery
      switch (channel) {
        case "email":
          if (user.email) {
            await sendEmail({
              to: user.email,
              subject: payload.subject ?? eventDef.label,
              html: payload.html ?? `<p>${payload.body}</p>`,
              text: payload.body,
              unsubscribeEventId: eventId,
              userId,
            });
          }
          break;
        case "slack":
          if (user.slackWebhookUrl) {
            await sendSlackMessage({
              webhookUrl: user.slackWebhookUrl,
              text: payload.body,
              actionUrl: payload.actionUrl,
            });
          }
          break;
      }
    })
  );
}

function getNextDigestTime(
  mode: "daily" | "weekly",
  hour: number,
  timezone: string = "UTC"
): Date {
  const now = new Date();
  const target = new Date();
  target.setUTCHours(hour, 0, 0, 0);

  if (target <= now) {
    target.setUTCDate(target.getUTCDate() + (mode === "weekly" ? 7 : 1));
  }

  return target;
}

๐Ÿ’ก 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

Digest Sender (Cron Job)

// lib/notifications/digest-sender.ts
import { db } from "@/lib/db";
import { sendEmail } from "@/lib/email/sender";
import { withCronLock } from "@/lib/cron/advisory-lock";
import { pool } from "@/lib/db/pool";

export async function sendPendingDigests() {
  await withCronLock({
    jobName: "send-notification-digests",
    pool,
    fn: async () => {
      // Fetch all due digest items grouped by user+channel
      const pending = await db.$queryRaw<Array<{
        user_id: string;
        channel: string;
        user_email: string;
        items: Array<{ eventId: string; payload: any; createdAt: Date }>;
      }>>`
        SELECT
          q.user_id,
          q.channel,
          u.email AS user_email,
          json_agg(
            json_build_object(
              'eventId', q.event_id,
              'payload', q.payload,
              'createdAt', q.created_at
            ) ORDER BY q.created_at
          ) AS items,
          COUNT(*) AS item_count
        FROM notification_digest_queue q
        JOIN users u ON u.id = q.user_id
        WHERE q.sent_at IS NULL
          AND q.scheduled_for <= now()
        GROUP BY q.user_id, q.channel, u.email
        HAVING COUNT(*) > 0
        LIMIT 100
      `;

      for (const group of pending) {
        try {
          await deliverDigest(group);

          // Mark as sent
          await db.notificationDigestQueue.updateMany({
            where: {
              userId: group.user_id,
              channel: group.channel,
              sentAt: null,
              scheduledFor: { lte: new Date() },
            },
            data: { sentAt: new Date() },
          });
        } catch (err) {
          console.error(`Failed to send digest for user ${group.user_id}:`, err);
        }
      }
    },
  });
}

async function deliverDigest(group: any) {
  if (group.channel === "email" && group.user_email) {
    const itemCount = group.items.length;
    const subject = `Your ${itemCount} notification${itemCount > 1 ? "s" : ""} summary`;

    const html = `
      <h2>Here's what happened while you were away</h2>
      <ul>
        ${group.items.map((item: any) => `
          <li>
            <strong>${item.payload.subject ?? item.eventId}</strong>
            <p>${item.payload.body}</p>
            ${item.payload.actionUrl ? `<a href="${item.payload.actionUrl}">View โ†’</a>` : ""}
          </li>
        `).join("")}
      </ul>
      <p><a href="${process.env.NEXT_PUBLIC_APP_URL}/settings/notifications">
        Manage notification preferences
      </a></p>
    `;

    await sendEmail({
      to: group.user_email,
      subject,
      html,
      text: group.items.map((i: any) => i.payload.body).join("\n\n"),
    });
  }
}

Notification Preferences UI

// components/NotificationPreferences/NotificationPreferences.tsx
"use client";

import { useState, useTransition } from "react";
import { NOTIFICATION_EVENTS, NotificationChannel } from "@/lib/notifications/events";

const CHANNEL_LABELS: Record<NotificationChannel, string> = {
  email: "Email",
  slack: "Slack",
  in_app: "In-app",
  push: "Push",
};

const CATEGORY_LABELS = {
  team_activity: "Team Activity",
  billing: "Billing",
  security: "Security",
  reports: "Reports",
  system: "System",
};

interface Preferences {
  [eventId: string]: {
    [channel: string]: { enabled: boolean; digestMode: string | null };
  };
}

export function NotificationPreferences({
  initialPrefs,
  availableChannels,
}: {
  initialPrefs: Preferences;
  availableChannels: NotificationChannel[];
}) {
  const [prefs, setPrefs] = useState(initialPrefs);
  const [isPending, startTransition] = useTransition();
  const [savedAt, setSavedAt] = useState<Date | null>(null);

  const updatePref = (eventId: string, channel: string, key: string, value: any) => {
    setPrefs((prev) => ({
      ...prev,
      [eventId]: {
        ...prev[eventId],
        [channel]: { ...prev[eventId]?.[channel], [key]: value },
      },
    }));

    startTransition(async () => {
      await fetch("/api/settings/notifications", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ eventId, channel, [key]: value }),
      });
      setSavedAt(new Date());
    });
  };

  // Group events by category
  const byCategory = Object.entries(NOTIFICATION_EVENTS).reduce(
    (acc, [id, def]) => {
      if (!acc[def.category]) acc[def.category] = [];
      acc[def.category].push({ id, ...def });
      return acc;
    },
    {} as Record<string, any[]>
  );

  return (
    <div className="space-y-8">
      <div className="flex items-center justify-between">
        <h2 className="text-lg font-semibold">Notification Preferences</h2>
        {savedAt && (
          <span className="text-xs text-green-600">
            Saved {savedAt.toLocaleTimeString()}
          </span>
        )}
      </div>

      {Object.entries(byCategory).map(([category, events]) => (
        <div key={category}>
          <h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
            {CATEGORY_LABELS[category as keyof typeof CATEGORY_LABELS] ?? category}
          </h3>

          <div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
            {/* Header row */}
            <div className="flex items-center px-4 py-2 bg-gray-50">
              <div className="flex-1 text-xs text-gray-400">Event</div>
              {availableChannels.map((ch) => (
                <div key={ch} className="w-20 text-center text-xs text-gray-400">
                  {CHANNEL_LABELS[ch]}
                </div>
              ))}
            </div>

            {events.map((event) => (
              <div key={event.id} className="flex items-center px-4 py-3">
                <div className="flex-1">
                  <p className="text-sm font-medium text-gray-900">{event.label}</p>
                  <p className="text-xs text-gray-400">{event.description}</p>
                </div>

                {availableChannels.map((channel) => {
                  const pref = prefs[event.id]?.[channel];
                  const isRequired = event.requiredChannels?.includes(channel);
                  const isSupported = event.supportedChannels.includes(channel);

                  if (!isSupported) {
                    return <div key={channel} className="w-20" />;
                  }

                  return (
                    <div key={channel} className="w-20 flex justify-center">
                      <button
                        type="button"
                        disabled={isRequired || isPending}
                        onClick={() => updatePref(event.id, channel, "enabled", !pref?.enabled)}
                        className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
                          ${pref?.enabled ? "bg-blue-600" : "bg-gray-200"}
                          ${isRequired ? "cursor-not-allowed opacity-60" : "cursor-pointer"}
                        `}
                        aria-label={`${pref?.enabled ? "Disable" : "Enable"} ${event.label} via ${CHANNEL_LABELS[channel]}`}
                        title={isRequired ? "This notification cannot be disabled" : undefined}
                      >
                        <span
                          className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform
                            ${pref?.enabled ? "translate-x-5" : "translate-x-1"}
                          `}
                        />
                      </button>
                    </div>
                  );
                })}
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

One-Click Unsubscribe (Email Compliance)

// app/api/unsubscribe/route.ts
export async function GET(req: NextRequest) {
  const token = req.nextUrl.searchParams.get("token");
  if (!token) return NextResponse.redirect("/");

  const record = await db.notificationUnsubscribeToken.findUnique({
    where: { token },
    include: { user: true },
  });

  if (!record || record.usedAt) {
    return NextResponse.redirect("/?error=invalid_unsubscribe_link");
  }

  // Apply unsubscribe
  if (record.eventId && record.channel) {
    await db.notificationPreference.upsert({
      where: { userId_eventId_channel: { userId: record.userId, eventId: record.eventId, channel: record.channel } },
      create: { userId: record.userId, eventId: record.eventId, channel: record.channel, enabled: false },
      update: { enabled: false },
    });
  } else {
    // Unsubscribe from all email notifications
    const events = Object.keys(NOTIFICATION_EVENTS).filter((id) => {
      const def = NOTIFICATION_EVENTS[id];
      return !def.requiredChannels?.includes("email");
    });

    await db.notificationPreference.createMany({
      data: events.map((eventId) => ({
        userId: record.userId, eventId, channel: "email", enabled: false,
      })),
      skipDuplicates: false,
    });
    await db.notificationPreference.updateMany({
      where: { userId: record.userId, channel: "email" },
      data: { enabled: false },
    });
  }

  await db.notificationUnsubscribeToken.update({
    where: { token },
    data: { usedAt: new Date() },
  });

  return NextResponse.redirect("/settings/notifications?unsubscribed=true");
}

Cost and Timeline

ComponentTimelineCost (USD)
Event taxonomy + schema0.5โ€“1 day$400โ€“$800
Preference resolution service1 day$600โ€“$1,000
Preference-aware send service1โ€“2 days$800โ€“$1,600
Digest queue + cron sender1โ€“2 days$800โ€“$1,600
Preferences settings UI2โ€“3 days$1,600โ€“$2,500
One-click unsubscribe0.5 day$400
Full notification preference system2 weeks$7,000โ€“$12,000

See Also


Working With Viprasol

We build notification systems for SaaS products โ€” from simple email alerts through full multi-channel preference management with digest scheduling. Our team has shipped notification infrastructure for products with hundreds of thousands of active users.

What we deliver:

  • Notification event taxonomy and channel configuration
  • Preference-aware send service (immediate vs. digest)
  • Digest scheduling and batching with cron
  • Preferences settings UI with per-event channel toggles
  • One-click unsubscribe with CAN-SPAM compliance

Explore our SaaS development services or contact us to build your notification preference system.

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.