Skip to main content

Notifications System

The DAGGH notifications system provides real-time, interactive notifications to keep users informed about group invitations, comments, matches, and other activities. Built with React Query, Supabase real-time subscriptions, and optimistic updates for a seamless user experience.

๐ŸŽฏ Architecture Overviewโ€‹

graph TB
A[User Action] --> B[API Route]
B --> C[Database Update]
B --> D[Notification Service]
D --> E[Create Notification Record]
C --> F[Supabase Real-time]
F --> G[Client Subscription]
G --> H[React Query Cache Update]
H --> I[UI Updates Instantly]

J[NotificationBell] --> K[NotificationsDropdown]
K --> L[NotificationsPage]

M[useNotifications] --> N[API Calls]
O[useNotificationSubscription] --> P[Supabase Channel]
Q[useUnreadNotifications] --> R[Badge Count]

๐Ÿ—๏ธ Core Componentsโ€‹

Database Schemaโ€‹

The notifications system uses a single notifications table with the following structure:

CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_id UUID NOT NULL REFERENCES auth.users(id),
sender_id UUID REFERENCES auth.users(id),
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
action_url TEXT,
data JSONB,
priority TEXT DEFAULT 'normal',
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ
);

Key fields:

  • recipient_id: Who receives the notification
  • sender_id: Who triggered the notification (nullable for system notifications)
  • type: Category of notification (group_invitation, comment_reply, etc.)
  • action_url: Where to navigate when clicked
  • data: Additional context as JSON
  • read_at: Timestamp when marked as read (null = unread)

๐ŸŽฃ Hooks Architectureโ€‹

useNotifications Hookโ€‹

The primary hook for fetching and managing notifications with pagination and filtering.

interface NotificationsResponse {
notifications: Notification[]
total: number
page: number
pageSize: number
}

interface NotificationFilters {
unreadOnly?: boolean
type?: string
}

export function useNotifications(
page = 1,
pageSize = 10,
filters: NotificationFilters = {}
) {
// Returns:
// - notifications: Array of notification objects
// - total: Total count for pagination
// - markAsRead: Function to mark single notification as read
// - markAllAsRead: Function to mark all notifications as read
// - isLoading, isError, error: Standard query states
}

Key Features:

  • Optimistic Updates: UI updates immediately before server confirmation
  • Cache Management: Automatically updates React Query cache
  • Error Handling: Reverts optimistic updates on failure
  • Pagination Support: Built-in pagination with page/pageSize
  • Filtering: Support for unread-only and type filtering

Implementation Details:

// Optimistic update example for mark as read
onMutate: async (notificationId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["notifications"] })

// Snapshot previous value
const previousNotifications = queryClient.getQueriesData({
queryKey: ["notifications"]
})

// Optimistically update the cache
queryClient.setQueriesData(
{ queryKey: ["notifications"] },
(old: NotificationsResponse | undefined) => {
if (!old) return old
return {
...old,
notifications: old.notifications.map((notification) =>
notification.id === notificationId
? { ...notification, read_at: new Date().toISOString() }
: notification
),
}
}
)

// Update unread count simultaneously
queryClient.setQueryData(
["notifications", "unread-count"],
(old: number | undefined) => (old ? Math.max(0, old - 1) : 0)
)

return { previousNotifications }
}

useNotificationSubscription Hookโ€‹

Handles real-time updates via Supabase subscriptions with automatic cache synchronization.

interface UseNotificationSubscriptionProps {
userId?: string
enabled?: boolean
onNewNotification?: (notification: Notification) => void
}

export function useNotificationSubscription({
userId,
enabled = true,
onNewNotification
}: UseNotificationSubscriptionProps = {}) {
// Returns:
// - isSubscribed: Boolean indicating active subscription
// - unsubscribe: Function to manually close subscription
}

Real-time Implementation:

useEffect(() => {
if (!enabled || !userId) return

const channel = supabase
.channel('notifications')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `recipient_id=eq.${userId}`,
}, (payload) => {
const newNotification = payload.new as Notification

// Update React Query cache with new notification
queryClient.setQueriesData(
{ queryKey: ["notifications"] },
(old: any) => {
if (!old) return old
return {
...old,
notifications: [newNotification, ...old.notifications],
total: old.total + 1,
}
}
)

// Update unread count
queryClient.setQueryData(
["notifications", "unread-count"],
(old: { count: number } | undefined) => ({
count: (old?.count || 0) + 1
})
)

// Optional callback for additional actions (toasts, etc.)
onNewNotification?.(newNotification)
})
.subscribe()

return () => supabase.removeChannel(channel)
}, [userId, enabled, queryClient, onNewNotification, supabase])

Why This Pattern Works:

  • Automatic Cache Updates: New notifications appear instantly without refetching
  • Pessimistic Real-time: Server-driven updates ensure data consistency
  • Memory Management: Proper cleanup prevents subscription leaks
  • Conditional Subscriptions: Only subscribes when user is authenticated

useUnreadNotifications Hookโ€‹

Optimized hook for fetching just the unread count for badge display.

export function useUnreadNotifications() {
const unreadCountQuery = useQuery<{ count: number }>({
queryKey: ["notifications", "unread-count"],
queryFn: async () => {
const response = await fetch("/api/notifications/unread-count")
return response.json()
},
refetchInterval: 30000, // Fallback polling every 30s
})

return {
unreadCount: unreadCountQuery.data?.count || 0,
isLoading: unreadCountQuery.isLoading,
// ...
}
}

Performance Considerations:

  • Separate Endpoint: Lightweight query that only returns count
  • Shared Cache Key: Real-time updates automatically update this cache
  • Fallback Polling: Ensures count stays accurate even if real-time fails

๐Ÿ”” UI Componentsโ€‹

NotificationBell Componentโ€‹

The main trigger component with controlled dropdown state for proper navigation handling.

export function NotificationBell({ className }: NotificationBellProps) {
const { user } = useCurrentUser()
const { unreadCount, isLoading } = useUnreadNotifications()
const [isOpen, setIsOpen] = useState(false)

// Real-time subscription
useNotificationSubscription({
userId: user?.id,
enabled: !!user?.id,
})

const handleCloseDropdown = () => {
setIsOpen(false)
}

return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
{hasUnread && (
<Badge variant="destructive" className="...">
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<NotificationsDropdown onNavigate={handleCloseDropdown} />
</DropdownMenuContent>
</DropdownMenu>
)
}

Key Features:

  • Controlled State: Manual dropdown control for navigation handling
  • Badge Logic: Shows count with 99+ cap
  • Real-time Updates: Badge updates instantly via subscription
  • Accessibility: Proper ARIA labels with dynamic unread count

NotificationsDropdown Componentโ€‹

Compact dropdown showing recent notifications with quick actions.

interface NotificationsDropdownProps {
onNavigate?: () => void
}

export function NotificationsDropdown({ onNavigate }: NotificationsDropdownProps) {
const {
notifications,
markAsRead,
markAllAsRead,
// ...
} = useNotifications(1, 10) // Latest 10 notifications

const handleClick = () => {
if (isUnread) {
onMarkAsRead(notification.id)
}
if (notification.action_url) {
onNavigate?.() // Close dropdown BEFORE navigation
router.push(notification.action_url)
}
}

return (
<div className="max-h-96">
{/* Header with bulk actions */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold">Notifications</h3>
{hasUnread && (
<Button onClick={markAllAsRead}>
Mark all read
</Button>
)}
</div>

{/* Scrollable notifications list */}
<ScrollArea className="max-h-80">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={markAsRead}
onNavigate={onNavigate}
/>
))}
</ScrollArea>

{/* Footer with "View All" link */}
<div className="p-3 border-t">
<Button asChild>
<Link href="/notifications" onClick={onNavigate}>
View all notifications
</Link>
</Button>
</div>
</div>
)
}

UX Considerations:

  • Limited Height: Scrollable with max height to prevent overflow
  • Quick Actions: Mark as read directly from dropdown
  • Visual Hierarchy: Clear separation between sections
  • Navigation Closure: Dropdown closes before navigation for smooth UX

๐Ÿ”„ Real-time Data Flowโ€‹

How Real-time Updates Workโ€‹

  1. User Action Triggers Notification:

    // Example: Group invitation
    const { data: sentInvitations } = await supabase
    .from("group_memberships")
    .insert(invitationRecords)

    // Send notification
    await notificationService.sendGroupInvitationNotification(
    recipientId,
    senderId,
    groupName,
    groupId
    )
  2. Notification Created in Database:

    // NotificationService creates record
    const { data } = await supabase
    .from("notifications")
    .insert({
    recipient_id: recipientId,
    sender_id: senderId,
    type: "group_invitation",
    title: "New Group Invitation",
    message: `You've been invited to join "${groupName}"`,
    action_url: `/groups/${groupId}`,
    data: { group_id: groupId, group_name: groupName }
    })
  3. Supabase Triggers Real-time Event:

    // Client subscription receives INSERT event
    .on('postgres_changes', {
    event: 'INSERT',
    schema: 'public',
    table: 'notifications',
    filter: `recipient_id=eq.${userId}`,
    }, (payload) => {
    // payload.new contains the new notification
    })
  4. Client Updates Cache Immediately:

    // Add to existing notifications list
    queryClient.setQueriesData(
    { queryKey: ["notifications"] },
    (old) => ({
    ...old,
    notifications: [newNotification, ...old.notifications],
    total: old.total + 1,
    })
    )

    // Update unread count
    queryClient.setQueryData(
    ["notifications", "unread-count"],
    (old) => ({ count: (old?.count || 0) + 1 })
    )
  5. UI Updates Automatically:

    • Bell badge shows new count
    • Dropdown shows new notification
    • No manual refresh needed

Why This Approach is Effectiveโ€‹

Immediate Feedback: Users see updates instantly without polling or manual refresh.

Efficient: Only updates relevant data, doesn't refetch entire lists.

Consistent: Server-driven updates ensure all clients see the same data.

Scalable: Supabase handles connection management and broadcasting.

Fallback Safe: If real-time fails, polling and manual refresh still work.

๐Ÿ“ก API Integrationโ€‹

NotificationService Classโ€‹

Centralized service for creating notifications with consistent formatting.

export class NotificationService {
private supabase = createServiceClient()

async sendNotification(notificationData: NotificationData) {
const { data, error } = await (await this.supabase)
.from("notifications")
.insert({
recipient_id: notificationData.recipient_id,
sender_id: notificationData.sender_id,
type: notificationData.type,
title: notificationData.title,
message: notificationData.message,
action_url: notificationData.action_url,
data: notificationData.data || null,
priority: notificationData.priority || "normal",
})
.select()
.single()

if (error) throw error
return data
}

// Specific notification types with consistent formatting
async sendGroupInvitationNotification(
recipientId: string,
senderId: string,
groupName: string,
groupId: string
) {
return this.sendNotification({
recipient_id: recipientId,
sender_id: senderId,
type: "group_invitation",
title: "New Group Invitation",
message: `You've been invited to join the group "${groupName}"`,
action_url: `/groups/${groupId}`,
data: { group_id: groupId, group_name: groupName },
priority: "high",
})
}
}

export const notificationService = new NotificationService()

Integration in Existing Featuresโ€‹

Group Invitations Example:

// In /api/groups/invitations route
export async function POST(request: NextRequest) {
// ... create invitations logic ...

// Send notifications to invited users
try {
const notifications = newUserIds.map((userId) => ({
recipient_id: userId,
sender_id: user.id,
type: "group_invitation",
title: "New Group Invitation",
message: `You've been invited to join the group "${group.title}"`,
action_url: `/profile/groups/my-invites`,
data: { group_id, group_name: group.title },
priority: "high",
}))

await notificationService.sendBulkNotifications(notifications)
} catch (notificationError) {
// Log but don't fail the invitation creation
console.error("Failed to send invitation notifications:", notificationError)
}

return NextResponse.json({ /* success response */ })
}

๐ŸŽจ Styling and Designโ€‹

Notification Types and Visual Designโ€‹

Each notification type has distinctive styling for quick recognition:

const getNotificationIcon = (type: string) => {
switch (type) {
case "group_invitation": return "๐Ÿ‘ฅ"
case "comment_reply": return "๐Ÿ’ฌ"
case "swipe_match": return "๐Ÿ’ซ"
case "group_activity": return "๐ŸŽฌ"
default: return "๐Ÿ””"
}
}

const getNotificationColor = (type: string) => {
switch (type) {
case "group_invitation":
return "bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800"
case "comment_reply":
return "bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800"
// ...
}
}

Responsive Designโ€‹

  • Desktop: Bell in header with dropdown
  • Mobile: Bell in mobile navigation menu
  • Tablet: Adaptive dropdown sizing

Dark Mode Supportโ€‹

All components support automatic dark mode switching via your theme system:

// Example of dark mode aware styling
className={cn(
"bg-background border-border", // Adapts to theme
isUnread && "shadow-sm",
"hover:bg-accent/50" // Theme-aware hover states
)}

๐Ÿš€ Performance Optimizationsโ€‹

React Query Optimizationsโ€‹

Stale Time Management: Different cache strategies for different data types:

// Long-lived data
queryClient.setQueryDefaults(["notifications", "unread-count"], {
staleTime: 2 * 60 * 1000, // 2 minutes
})

// Frequently changing data
queryClient.setQueryDefaults(["notifications"], {
staleTime: 30 * 1000, // 30 seconds
})

Optimistic Updates: Immediate UI feedback with automatic rollback on failure.

Selective Updates: Real-time subscriptions only update affected cache entries.

Supabase Optimizationsโ€‹

Filtered Subscriptions: Only listen to relevant changes:

filter: `recipient_id=eq.${userId}` // Only this user's notifications

Connection Pooling: Reuse Supabase connections across components.

Cleanup Management: Proper subscription cleanup prevents memory leaks.

Bundle Size Optimizationโ€‹

Lazy Loading: Notifications page is code-split:

const NotificationsPage = lazy(() => import("./NotificationsPage"))

Tree Shaking: Only import needed utilities:

import { formatDistanceToNow } from "date-fns"
// Instead of importing entire date-fns library

๐Ÿงช Testing Strategiesโ€‹

Unit Testing Hooksโ€‹

import { renderHook } from '@testing-library/react'
import { useNotifications } from '../useNotifications'

test('should mark notification as read optimistically', async () => {
const { result } = renderHook(() => useNotifications())

act(() => {
result.current.markAsRead('notification-id')
})

// Should update immediately (optimistic)
expect(result.current.notifications[0].read_at).toBeTruthy()
})

Integration Testingโ€‹

test('should receive real-time notifications', async () => {
// Setup test user and subscription
// Trigger notification creation
// Assert UI updates automatically
})

E2E Testingโ€‹

test('notification flow end-to-end', async () => {
// User A invites User B to group
// User B should see notification immediately
// Clicking notification navigates to correct page
// Notification marked as read
})

๐Ÿ”ฎ Future Enhancementsโ€‹

Planned Featuresโ€‹

  1. Push Notifications: Browser/mobile push for offline users
  2. Email Digests: Periodic email summaries for inactive users
  3. Notification Preferences: Per-type notification settings
  4. Bulk Actions: Select multiple notifications for actions
  5. Notification Templates: Customizable notification formats

Scalability Considerationsโ€‹

  • Database Indexing: Optimize queries with proper indexes
  • Archival Strategy: Move old notifications to archive tables
  • Rate Limiting: Prevent notification spam
  • Batching: Group similar notifications to reduce noise
  • Database Schema: See the notifications table structure in the Core Components section above
  • Real-time Features: Implemented via Supabase subscriptions as documented in the Real-time Data Flow section
  • Group Management: Integration points covered in the API Integration section above
  • API Reference: Covered in the API Integration section above

๐Ÿค Contributingโ€‹

When adding new notification types:

  1. Add to NotificationService: Create specific method for the type
  2. Update UI Components: Add icon and styling for the type
  3. Add Integration: Update relevant API routes to send notifications
  4. Test Thoroughly: Ensure real-time updates work correctly
  5. Document: Update this documentation with new patterns

The notifications system is designed to be extensible - follow these patterns for consistent implementation across all features.