Add In-App Notifications to SaaS Boilerplates 2026
TL;DR
For production SaaS: Knock ($0 to start) or Novu (open source) handles multi-channel notifications (in-app, email, SMS, push) with a pre-built notification center component. For simple in-app only: custom DB table + Server-Sent Events. The bell icon + dropdown pattern is 3 files: a notifications DB table, a GET /api/notifications route, and a <NotificationBell> component.
Key Takeaways
- Knock: Managed notification service, React component, $0 free tier
- Novu: Open source, self-hostable, supports 30+ providers (email, SMS, push)
- DIY: Postgres notifications table + SSE for real-time, good for simple cases
- Toast notifications:
sonnerorreact-hot-toastfor ephemeral feedback - Pattern: Mark as read, pagination, preference management
Option 1: Custom DB + SSE (Simple)
// schema.prisma:
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
type String // "team_invite", "payment_failed", "new_comment"
title String
message String
href String? // Optional link
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read, createdAt])
}
// lib/notifications.ts — send notification:
export async function createNotification(
userId: string,
notification: {
type: string;
title: string;
message: string;
href?: string;
}
) {
await db.notification.create({
data: { userId, ...notification },
});
}
// Usage throughout your app:
await createNotification(userId, {
type: 'team_invite',
title: 'Team Invitation',
message: `${inviter.name} invited you to join ${team.name}`,
href: `/teams/${team.id}/accept`,
});
// app/api/notifications/route.ts:
export async function GET() {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const notifications = await db.notification.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
take: 20,
});
const unreadCount = await db.notification.count({
where: { userId: session.user.id, read: false },
});
return Response.json({ notifications, unreadCount });
}
// Mark all read:
export async function PATCH() {
const session = await auth();
await db.notification.updateMany({
where: { userId: session.user.id, read: false },
data: { read: true },
});
return Response.json({ success: true });
}
// components/NotificationBell.tsx:
'use client';
import { useState } from 'react';
import useSWR from 'swr';
import { Bell } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
export function NotificationBell() {
const { data, mutate } = useSWR('/api/notifications');
const [open, setOpen] = useState(false);
const markAllRead = async () => {
await fetch('/api/notifications', { method: 'PATCH' });
mutate();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{data?.unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
>
{data.unreadCount > 9 ? '9+' : data.unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="font-semibold">Notifications</h3>
{data?.unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead}>
Mark all read
</Button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{data?.notifications?.length === 0 ? (
<p className="text-center text-muted-foreground py-8">No notifications</p>
) : (
data?.notifications?.map((n: any) => (
<a
key={n.id}
href={n.href ?? '#'}
className={`block px-4 py-3 hover:bg-muted border-b ${!n.read ? 'bg-blue-50 dark:bg-blue-950/20' : ''}`}
>
<p className="text-sm font-medium">{n.title}</p>
<p className="text-sm text-muted-foreground">{n.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(n.createdAt).toRelative?.() ?? new Date(n.createdAt).toLocaleDateString()}
</p>
</a>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
Option 2: Knock (Managed)
npm install @knocklabs/react
// providers/KnockProvider.tsx:
'use client';
import { KnockProvider, KnockFeedProvider } from '@knocklabs/react';
import '@knocklabs/react/dist/index.css';
export function AppKnockProvider({ userId, userToken, children }: {
userId: string;
userToken: string;
children: React.ReactNode;
}) {
return (
<KnockProvider
apiKey={process.env.NEXT_PUBLIC_KNOCK_PUBLIC_API_KEY!}
userId={userId}
userToken={userToken}
>
<KnockFeedProvider feedId={process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID!}>
{children}
</KnockFeedProvider>
</KnockProvider>
);
}
// components/KnockNotificationBell.tsx:
import { NotificationIconButton, NotificationFeedPopover } from '@knocklabs/react';
import { useRef, useState } from 'react';
export function KnockNotificationBell() {
const [open, setOpen] = useState(false);
const buttonRef = useRef(null);
return (
<>
<NotificationIconButton
ref={buttonRef}
onClick={() => setOpen(!open)}
/>
<NotificationFeedPopover
buttonRef={buttonRef}
isVisible={open}
onClose={() => setOpen(false)}
/>
</>
);
}
// Send notification via Knock API:
import Knock from '@knocklabs/node';
const knock = new Knock(process.env.KNOCK_API_KEY!);
await knock.workflows.trigger('team-invite', {
recipients: [userId],
data: {
inviterName: inviter.name,
teamName: team.name,
inviteUrl: `https://yourapp.com/teams/${team.id}/accept`,
},
});
Decision Guide
Use Knock if:
→ Multi-channel (in-app + email + push + SMS)
→ Want pre-built React notification center
→ Need workflow builder (not in code)
→ $0 free tier, scales with usage
Use Novu if:
→ Open source and self-hosted
→ Need 30+ provider integrations
→ B2B where customer data sovereignty matters
Use custom DIY if:
→ Simple in-app only (no email/push needed)
→ Cost matters, already have Postgres
→ 5-10 notification types max
Find SaaS boilerplates with notifications at StarterPick.
Decision Framework: Shipping in Production
Most teams fail to ship this capability because they optimize for perfect architecture before they validate user behavior. The practical path is to define one narrow success metric, ship the smallest production-safe version, and then expand scope only after observing real usage. For SaaS teams, this means designing around operational clarity first: clear ownership, clear rollback paths, and clear instrumentation.
Start by deciding what must be true on day one and what can wait until day thirty. Day-one requirements should focus on reliability, observability, and user comprehension. If users cannot understand what happened, trust erodes quickly. If your team cannot debug problems in minutes, support cost explodes. This is why logs, idempotency, and explicit state transitions matter more than visual polish in the first release.
A useful rubric is to split requirements into three buckets: required for correctness, required for supportability, and required for growth. Correctness is the narrow technical contract the feature must satisfy. Supportability includes alerts, admin visibility, and failure messaging. Growth includes advanced automation, richer UX, and integrations that extend the core feature. Teams that mix these buckets early usually ship late and maintain fragile systems.
For architecture choices, prefer the smallest set of moving parts your current team can operate confidently. A "simpler" stack with strong team familiarity will beat a theoretically better stack that requires weeks of new operational learning. In 2026, speed-to-feedback is still a major competitive advantage for early-stage products, so choose patterns that reduce coordination overhead between product, engineering, and support.
30-Day Rollout Plan
Week 1 should focus on contracts and guardrails. Define your domain events, persistence model, permission boundaries, and failure semantics. Write down what happens when dependencies are slow, unavailable, or return unexpected payloads. Add request IDs and structured logs before launch so you can trace flows across services. Build basic dashboards for throughput, error rate, and latency.
Week 2 should focus on end-to-end completion paths. Implement the happy path with one representative user journey, then cover at least three common failure scenarios. Add deterministic retries where appropriate and idempotency keys wherever duplicate delivery or duplicate clicks are likely. Keep feature flags on every risky branch so you can disable behavior without emergency deploys.
Week 3 should focus on user-facing trust. Improve states and messages for pending, success, and failure outcomes. Add meaningful timeline entries or activity logs in your product where users can verify what happened. Build internal admin views for support teammates so incident response does not depend on engineering digging through raw logs.
Week 4 should focus on hardening and scale. Validate high-volume scenarios using replay traffic or synthetic load. Tune queue concurrency, query indexes, and backoff strategies from measured bottlenecks rather than assumptions. Define an incident runbook with clear ownership and escalation paths. If a capability has legal or compliance implications, add explicit evidence trails and retention policies.
Common Failure Modes
A frequent failure mode is hidden coupling between UI actions and backend execution. When UI state assumes immediate backend completion, users see stale or contradictory states during retries and delayed processing. Prevent this by modeling asynchronous states explicitly and rendering those states directly in the interface.
Another failure mode is missing idempotency around write operations. Duplicate submits, webhook retries, and browser refreshes are common in production. If you do not enforce idempotency keys and uniqueness constraints, you will eventually create duplicate records, duplicate charges, or duplicate side effects. Fixing this after launch is painful because data cleanup requires case-by-case handling.
Teams also underestimate support workflows. A feature that technically works can still overwhelm support if there is no internal visibility into user-level events. Add searchable logs, event timelines, and actor metadata so support can answer "what happened" without escalating every ticket to engineering.
A final failure mode is over-broad initial scope. Shipping one reliable workflow is better than shipping five partial workflows with inconsistent behavior. Product quality is judged by reliability under edge conditions, not by checklist length. Keep the first release narrow, then expand from observed demand.
Integration Guidance for Starter Kits
Starter kits accelerate initial setup, but each kit encodes assumptions about auth, data access, background processing, and deployment. Before adding major features, map those assumptions so your implementation aligns with existing conventions. If the starter uses server actions and route handlers, keep your new logic in that model rather than introducing parallel patterns unless there is a clear benefit.
For data schema changes, prefer additive migrations with backward compatibility. Add nullable fields first, deploy readers that can handle old and new shapes, then backfill, and finally enforce constraints. This staged approach reduces downtime risk and avoids blocking deploys when data volume grows.
For API boundaries, keep internal contracts typed and versioned. Even if you run a monorepo, treat cross-module calls as contracts. This prevents accidental breaking changes and makes future extraction easier if you split services later. Add minimal contract tests at module boundaries where regressions would be expensive.
For deployment, define environment parity rules. At minimum, local and staging should use the same auth modes, queue semantics, and feature flags. Divergent environments cause "works on staging" failures that are hard to diagnose and expensive to fix under pressure.
Internal Links and Next Reads
To keep implementation decisions consistent across the site taxonomy, use these references while planning follow-up work:
- Best SaaS starter kits ranked 2026
- SaaS boilerplate vs custom build
- Best Next.js SaaS boilerplates 2026
- Prisma vs Drizzle for boilerplates
- Authentication setup in Next.js boilerplates
Methodology & Sources
This article was expanded using a repeatable editorial workflow: identify thin sections, preserve existing technical examples, add production-focused implementation guidance, and include operational failure analysis so the content is useful beyond copy-paste snippets. Claims about platform behavior and integration patterns were aligned with primary documentation.
Primary references:
- Stripe Billing docs: meter-based usage billing and event reporting
- Next.js docs: App Router, Route Handlers, and deployment behavior
- Vercel docs: cron jobs, serverless execution model, and limits
- Auth.js and Clerk docs: session handling and authentication flows
- Inngest, BullMQ, and Trigger.dev docs: background execution and retries
- Svix docs: webhook signing, retry windows, and delivery semantics
Full Story: Operating Model That Scales
The difference between a tutorial-grade implementation and a production-grade implementation is rarely a single library choice. The difference is operating discipline. Teams that ship durable systems define explicit contracts between product requirements and technical behavior, then enforce those contracts through tests, observability, and rollout controls. This section gives a practical operating model that you can apply immediately inside a starter-kit codebase.
Begin with an execution contract written in plain language. It should state what input is accepted, what output is guaranteed, what failure classes exist, and what user-visible behavior appears for each failure class. Keep the document short enough to read in one sitting. If the contract cannot be understood quickly by product and support, your implementation is too implicit and incident response will be slow.
Next, define the event model for your feature before adding UI polish. Every meaningful state transition should map to one domain event with a stable name. Stable events let you build analytics, alerting, and support timelines without rewriting instrumentation every sprint. They also make future integrations easier because external systems consume event contracts, not implementation details.
Treat retries as a first-class behavior, not an edge case. In distributed systems, duplicate delivery and delayed execution are routine. Your system should behave correctly when the same command arrives twice, when dependent services return timeouts, and when users refresh or re-submit actions. Idempotency keys, uniqueness constraints, and deterministic retry backoff are the minimum baseline.
Design user feedback around certainty levels. There is a real distinction between "request accepted," "processing," and "completed." Avoid collapsing these states into one success message. Users trust systems that communicate uncertainty honestly and then resolve that uncertainty predictably. If work is async, show pending state and expected completion windows.
Build support tooling in parallel with feature development. The fastest way to reduce engineering interrupts is to give support agents a searchable event timeline with actor, timestamp, payload hash, and current processing state. Include links to related objects such as workspace, subscription, job run, and webhook delivery attempt. This visibility converts vague bug reports into actionable reproduction steps.
For security-sensitive workflows, enforce policy at service boundaries rather than within UI components. UI checks are helpful for user experience, but authorization must be evaluated server-side on each sensitive action. Log denials with enough context to diagnose misconfigured roles. Over time, these denial logs become useful signals for policy refinement.
When working from starter kits, avoid broad rewrites in the first month. Instead, adopt a strangle pattern: keep existing conventions, add capability behind feature flags, and move one slice at a time. This lowers regression risk and keeps diff size manageable for code review. Large cross-cutting rewrites often look elegant in architecture diagrams but carry high delivery risk under real deadlines.
Use release rings to limit blast radius. Roll out to internal users first, then a small external cohort, then general availability. Pair each ring with explicit exit criteria: error budget, support ticket volume, and median completion latency. If criteria are not met, pause rollout and fix the dominant failure mode rather than shipping around it.
Cost control should be measured, not guessed. Add basic usage and cost telemetry from day one, especially for features that call paid APIs, run background compute, or trigger outbound delivery. Surface these metrics weekly so product and engineering can align on pricing and quota decisions before margin problems emerge.
Finally, codify ownership. Every subsystem needs a directly responsible owner, a backup owner, and an escalation path. Ownership should include runbook maintenance, alert tuning, and dependency upgrades. Features without clear ownership become reliability debt and eventually block roadmap velocity.
Production Readiness Checklist
Use this checklist before declaring a feature complete:
- Contract defined: inputs, outputs, and failure classes documented
- Idempotency enforced for all mutating operations
- Structured logs include correlation IDs and actor metadata
- Alerting configured for error spikes and queue lag
- Admin/support timeline available for debugging user incidents
- Feature flags in place for rapid rollback
- Security boundaries validated at server endpoints
- Data migrations staged for backward compatibility
- Load tests or replay tests run against expected peak traffic
- Incident runbook published with owner and escalation path
If more than two checklist items are missing, treat the feature as beta, not finished. This framing helps set realistic expectations with stakeholders and protects your team from rushed launch commitments.
Methodology Notes
Editorially, this expansion prioritizes operational depth over surface-level implementation snippets. The goal is to make each article useful for teams moving from prototype to dependable production behavior. The recommendations align with current platform documentation and established SaaS delivery patterns in 2026, with emphasis on reliability, supportability, and controlled rollout.