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.
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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Event taxonomy + schema | 0.5โ1 day | $400โ$800 |
| Preference resolution service | 1 day | $600โ$1,000 |
| Preference-aware send service | 1โ2 days | $800โ$1,600 |
| Digest queue + cron sender | 1โ2 days | $800โ$1,600 |
| Preferences settings UI | 2โ3 days | $1,600โ$2,500 |
| One-click unsubscribe | 0.5 day | $400 |
| Full notification preference system | 2 weeks | $7,000โ$12,000 |
See Also
- SaaS Activity Feed โ The in-app notification source
- SaaS Email Sequences โ Automated email campaigns (distinct from transactional)
- SaaS Feature Flags Advanced โ Feature-flagging new notification types
- React Compound Components โ Building the toggle UI with compound components
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.
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.