Web Engineering ·

Building a Notification Center in React: Real-Time Updates, Read State, and Preference Management in Next.js

A practical guide to building an in-app notification center for SaaS products: data model, real-time delivery via SSE, bell component, infinite scroll panel, mark-as-read mechanics, user preferences, and backend fan-out.

Building a Notification Center in React: Real-Time Updates, Read State, and Preference Management in Next.js

Every SaaS product eventually needs an in-app notification center. The requirements sound simple: show a bell, badge it with unread count, open a panel, let users read and dismiss. In practice, you end up making a dozen decisions that compound on each other: how to model read state, whether to push or poll, how to fan out notifications from domain events, how to keep client state consistent with the server without a full refetch after every interaction.

This article walks through building one from scratch in Next.js with TypeScript. Not a toy example: a production-ready architecture with real tradeoffs called out at each step.

Data Model

Start with types before writing any UI. The shape of a notification drives every downstream decision.

// lib/notifications/types.ts

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

export type NotificationCategory =
  | "billing"
  | "security"
  | "activity"
  | "system"
  | "mentions";

export interface Notification {
  id: string;
  userId: string;
  category: NotificationCategory;
  title: string;
  body: string;
  link?: string;
  readAt: string | null; // ISO timestamp, null = unread
  createdAt: string;
  groupKey?: string; // for collapsing similar notifications
  metadata?: Record<string, unknown>;
}

export interface NotificationPreference {
  userId: string;
  category: NotificationCategory;
  channel: NotificationChannel;
  enabled: boolean;
}

export interface NotificationPage {
  items: Notification[];
  nextCursor: string | null;
  totalUnread: number;
}

A few decisions encoded here worth noting. readAt stores a timestamp rather than a boolean: you get the when for free and can show “read 2h ago” in audit logs. groupKey lets you collapse “5 people liked your comment” into one row. nextCursor makes the list cursor-paginated, which holds up better than offset pagination when new notifications arrive between pages.

Backend: Fan-Out from Domain Events

Notifications should not be generated in request handlers. A billing service processing an invoice should emit an event; a notification worker consumes it and creates the row.

// workers/notification-fanout.ts

import { NotificationCategory, NotificationPreference } from "@/lib/notifications/types";
import { db } from "@/lib/db";
import { notificationPreferences, notifications } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";

interface DomainEvent {
  type: string;
  userId: string;
  payload: Record<string, unknown>;
}

async function getEnabledChannels(
  userId: string,
  category: NotificationCategory
): Promise<string[]> {
  const prefs = await db
    .select()
    .from(notificationPreferences)
    .where(
      and(
        eq(notificationPreferences.userId, userId),
        eq(notificationPreferences.category, category)
      )
    );

  // Default to in_app enabled if no preference row exists
  if (prefs.length === 0) return ["in_app"];

  return prefs
    .filter((p) => p.enabled)
    .map((p) => p.channel);
}

export async function handleDomainEvent(event: DomainEvent): Promise<void> {
  const { userId, type, payload } = event;
  const category = eventToCategory(type);
  const channels = await getEnabledChannels(userId, category);

  if (channels.includes("in_app")) {
    await db.insert(notifications).values({
      id: crypto.randomUUID(),
      userId,
      category,
      title: buildTitle(type, payload),
      body: buildBody(type, payload),
      link: buildLink(type, payload),
      readAt: null,
      createdAt: new Date().toISOString(),
      groupKey: buildGroupKey(type, payload),
    });

    // Signal SSE connections for this user
    await notifySSEClients(userId);
  }

  if (channels.includes("email")) {
    await scheduleEmailNotification(userId, type, payload);
  }
}

The notifySSEClients call is where real-time delivery hooks in. We will cover that next.

Real-Time Delivery: SSE vs WebSockets

The two common options for pushing notification events to the client are Server-Sent Events (SSE) and WebSockets.

DimensionSSEWebSockets
DirectionServer to client onlyBidirectional
ProtocolHTTP/1.1 or HTTP/2WS upgrade
ReconnectAutomatic (browser)Manual
Proxy supportBetter (standard HTTP)Varies, some proxies struggle
Next.js Route HandlerYes (Response with ReadableStream)Needs separate server or adapter
Overhead per connectionLowHigher handshake cost
Use case fitNotificationsChat, collaborative editing

For notifications, SSE is the right call. You are pushing events from server to client; the client never needs to send data over the same channel. WebSockets add complexity without adding value here.

// app/api/notifications/stream/route.ts

import { NextRequest } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

// In-memory registry: userId -> Set of controllers
// In production, replace with Redis pub/sub for multi-instance
const sseClients = new Map<string, Set<ReadableStreamDefaultController>>();

export function addSSEClient(
  userId: string,
  controller: ReadableStreamDefaultController
): void {
  if (!sseClients.has(userId)) {
    sseClients.set(userId, new Set());
  }
  sseClients.get(userId)!.add(controller);
}

export function removeSSEClient(
  userId: string,
  controller: ReadableStreamDefaultController
): void {
  sseClients.get(userId)?.delete(controller);
}

export async function notifySSEClients(userId: string): Promise<void> {
  const clients = sseClients.get(userId);
  if (!clients) return;

  const message = `data: ${JSON.stringify({ type: "notification" })}\n\n`;
  const encoded = new TextEncoder().encode(message);

  for (const controller of clients) {
    try {
      controller.enqueue(encoded);
    } catch {
      clients.delete(controller);
    }
  }
}

export async function GET(req: NextRequest) {
  const session = await getServerSession(authOptions);
  if (!session?.user?.id) {
    return new Response("Unauthorized", { status: 401 });
  }

  const userId = session.user.id;
  let controller: ReadableStreamDefaultController;

  const stream = new ReadableStream({
    start(c) {
      controller = c;
      addSSEClient(userId, controller);

      // Send initial heartbeat
      const heartbeat = new TextEncoder().encode(": heartbeat\n\n");
      controller.enqueue(heartbeat);
    },
    cancel() {
      removeSSEClient(userId, controller);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

The in-memory Map works on a single instance. Multi-instance deployments need Redis pub/sub: the fan-out worker publishes to a user-specific channel, and each instance’s SSE handler subscribes and forwards to its local connections.

Notification Provider

Wrap the SSE connection and notification state in a context so any component can subscribe.

// lib/notifications/provider.tsx

"use client";

import React, {
  createContext,
  useContext,
  useEffect,
  useReducer,
  useCallback,
} from "react";
import { Notification, NotificationPage } from "./types";

interface NotificationState {
  items: Notification[];
  totalUnread: number;
  nextCursor: string | null;
  loading: boolean;
}

type Action =
  | { type: "SET_PAGE"; payload: NotificationPage }
  | { type: "APPEND_PAGE"; payload: NotificationPage }
  | { type: "MARK_READ"; ids: string[] }
  | { type: "MARK_ALL_READ" };

function reducer(state: NotificationState, action: Action): NotificationState {
  switch (action.type) {
    case "SET_PAGE":
      return {
        items: action.payload.items,
        totalUnread: action.payload.totalUnread,
        nextCursor: action.payload.nextCursor,
        loading: false,
      };
    case "APPEND_PAGE":
      return {
        items: [...state.items, ...action.payload.items],
        totalUnread: action.payload.totalUnread,
        nextCursor: action.payload.nextCursor,
        loading: false,
      };
    case "MARK_READ": {
      const ids = new Set(action.ids);
      const nowRead = state.items.filter(
        (n) => ids.has(n.id) && n.readAt === null
      ).length;
      return {
        ...state,
        totalUnread: Math.max(0, state.totalUnread - nowRead),
        items: state.items.map((n) =>
          ids.has(n.id) ? { ...n, readAt: new Date().toISOString() } : n
        ),
      };
    }
    case "MARK_ALL_READ":
      return {
        ...state,
        totalUnread: 0,
        items: state.items.map((n) => ({
          ...n,
          readAt: n.readAt ?? new Date().toISOString(),
        })),
      };
    default:
      return state;
  }
}

interface NotificationContextValue {
  state: NotificationState;
  loadMore: () => void;
  markRead: (ids: string[]) => void;
  markAllRead: () => void;
}

const NotificationContext = createContext<NotificationContextValue | null>(null);

export function NotificationProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, {
    items: [],
    totalUnread: 0,
    nextCursor: null,
    loading: true,
  });

  const fetchPage = useCallback(async (cursor?: string) => {
    const url = cursor
      ? `/api/notifications?cursor=${cursor}`
      : "/api/notifications";
    const res = await fetch(url);
    const data: NotificationPage = await res.json();
    dispatch({
      type: cursor ? "APPEND_PAGE" : "SET_PAGE",
      payload: data,
    });
  }, []);

  // Initial load
  useEffect(() => {
    fetchPage();
  }, [fetchPage]);

  // SSE subscription
  useEffect(() => {
    const es = new EventSource("/api/notifications/stream");

    es.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === "notification") {
        // Refetch the first page to pick up new items
        fetchPage();
      }
    };

    es.onerror = () => {
      // Browser will auto-reconnect for SSE
    };

    return () => es.close();
  }, [fetchPage]);

  const loadMore = useCallback(() => {
    if (state.nextCursor) fetchPage(state.nextCursor);
  }, [fetchPage, state.nextCursor]);

  const markRead = useCallback(async (ids: string[]) => {
    // Optimistic update
    dispatch({ type: "MARK_READ", ids });

    await fetch("/api/notifications/read", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ids }),
    });
  }, []);

  const markAllRead = useCallback(async () => {
    dispatch({ type: "MARK_ALL_READ" });
    await fetch("/api/notifications/read-all", { method: "POST" });
  }, []);

  return (
    <NotificationContext.Provider value={{ state, loadMore, markRead, markAllRead }}>
      {children}
    </NotificationContext.Provider>
  );
}

export function useNotifications() {
  const ctx = useContext(NotificationContext);
  if (!ctx) throw new Error("useNotifications must be used within NotificationProvider");
  return ctx;
}

The optimistic update in markRead is intentional: the UI updates immediately, the server call happens in the background. If the server call fails, you have a stale read state until the next page refetch. For a notification center, that is an acceptable tradeoff. You could add rollback logic if your product requires strict consistency here.

Bell Component with Unread Badge

// components/notifications/NotificationBell.tsx

"use client";

import { useState, useRef, useEffect } from "react";
import { useNotifications } from "@/lib/notifications/provider";
import { NotificationPanel } from "./NotificationPanel";

export function NotificationBell() {
  const { state } = useNotifications();
  const [open, setOpen] = useState(false);
  const panelRef = useRef<HTMLDivElement>(null);

  // Close on outside click
  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    if (open) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [open]);

  return (
    <div ref={panelRef} className="relative">
      <button
        onClick={() => setOpen((o) => !o)}
        aria-label={`Notifications${state.totalUnread > 0 ? `, ${state.totalUnread} unread` : ""}`}
        className="relative p-2 rounded-full hover:bg-gray-100"
      >
        <BellIcon className="w-5 h-5" />
        {state.totalUnread > 0 && (
          <span
            aria-hidden="true"
            className="absolute top-0 right-0 min-w-[18px] h-[18px] rounded-full bg-red-500 text-white text-xs flex items-center justify-center px-1"
          >
            {state.totalUnread > 99 ? "99+" : state.totalUnread}
          </span>
        )}
      </button>

      {open && <NotificationPanel onClose={() => setOpen(false)} />}
    </div>
  );
}

function BellIcon({ className }: { className?: string }) {
  return (
    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth={2}
        d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
      />
    </svg>
  );
}

Notification Panel with Infinite Scroll

// components/notifications/NotificationPanel.tsx

"use client";

import { useEffect, useRef } from "react";
import { useNotifications } from "@/lib/notifications/provider";
import { Notification } from "@/lib/notifications/types";
import { formatDistanceToNow } from "date-fns";

interface Props {
  onClose: () => void;
}

export function NotificationPanel({ onClose }: Props) {
  const { state, loadMore, markRead, markAllRead } = useNotifications();
  const sentinelRef = useRef<HTMLDivElement>(null);

  // Infinite scroll via IntersectionObserver
  useEffect(() => {
    if (!sentinelRef.current || !state.nextCursor) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) loadMore();
      },
      { threshold: 0.1 }
    );

    observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, [loadMore, state.nextCursor]);

  return (
    <div className="absolute right-0 mt-2 w-96 bg-white rounded-xl shadow-xl border border-gray-200 z-50 flex flex-col max-h-[500px]">
      <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
        <h2 className="font-semibold text-sm">Notifications</h2>
        {state.totalUnread > 0 && (
          <button
            onClick={markAllRead}
            className="text-xs text-blue-600 hover:underline"
          >
            Mark all read
          </button>
        )}
      </div>

      <div className="overflow-y-auto flex-1">
        {state.items.length === 0 && !state.loading && (
          <p className="text-sm text-gray-500 text-center py-8">
            No notifications yet.
          </p>
        )}

        {state.items.map((n) => (
          <NotificationRow key={n.id} notification={n} onRead={markRead} />
        ))}

        {state.nextCursor && (
          <div ref={sentinelRef} className="py-4 text-center">
            <span className="text-xs text-gray-400">Loading more...</span>
          </div>
        )}
      </div>
    </div>
  );
}

function NotificationRow({
  notification: n,
  onRead,
}: {
  notification: Notification;
  onRead: (ids: string[]) => void;
}) {
  const isUnread = n.readAt === null;

  return (
    <div
      className={`flex gap-3 px-4 py-3 border-b border-gray-50 hover:bg-gray-50 cursor-pointer ${
        isUnread ? "bg-blue-50/40" : ""
      }`}
      onClick={() => {
        if (isUnread) onRead([n.id]);
        if (n.link) window.location.href = n.link;
      }}
    >
      {isUnread && (
        <span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
      )}
      <div className={isUnread ? "" : "ml-5"}>
        <p className="text-sm font-medium text-gray-900">{n.title}</p>
        <p className="text-xs text-gray-500 mt-0.5">{n.body}</p>
        <p className="text-xs text-gray-400 mt-1">
          {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
        </p>
      </div>
    </div>
  );
}

Notification Preferences UI

Users need per-category, per-channel control. Keep the preference model simple: one row per (category, channel) pair. Fetch them once, mutate optimistically.

// components/notifications/PreferencesPanel.tsx

"use client";

import { useEffect, useState } from "react";
import {
  NotificationCategory,
  NotificationChannel,
  NotificationPreference,
} from "@/lib/notifications/types";

const CATEGORIES: NotificationCategory[] = [
  "billing",
  "security",
  "activity",
  "system",
  "mentions",
];

const CHANNELS: NotificationChannel[] = ["in_app", "email", "push"];

export function NotificationPreferences() {
  const [prefs, setPrefs] = useState<NotificationPreference[]>([]);

  useEffect(() => {
    fetch("/api/notifications/preferences")
      .then((r) => r.json())
      .then(setPrefs);
  }, []);

  function isEnabled(category: NotificationCategory, channel: NotificationChannel) {
    const pref = prefs.find(
      (p) => p.category === category && p.channel === channel
    );
    return pref?.enabled ?? channel === "in_app"; // default in_app on
  }

  async function toggle(
    category: NotificationCategory,
    channel: NotificationChannel
  ) {
    const current = isEnabled(category, channel);

    // Optimistic update
    setPrefs((prev) => {
      const existing = prev.find(
        (p) => p.category === category && p.channel === channel
      );
      if (existing) {
        return prev.map((p) =>
          p.category === category && p.channel === channel
            ? { ...p, enabled: !current }
            : p
        );
      }
      return [
        ...prev,
        { userId: "", category, channel, enabled: !current },
      ];
    });

    await fetch("/api/notifications/preferences", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ category, channel, enabled: !current }),
    });
  }

  return (
    <div className="overflow-x-auto">
      <table className="min-w-full text-sm">
        <thead>
          <tr>
            <th className="text-left py-2 pr-4 font-medium text-gray-700">
              Category
            </th>
            {CHANNELS.map((ch) => (
              <th key={ch} className="text-center py-2 px-4 font-medium text-gray-700 capitalize">
                {ch.replace("_", " ")}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {CATEGORIES.map((cat) => (
            <tr key={cat} className="border-t border-gray-100">
              <td className="py-3 pr-4 capitalize text-gray-800">{cat}</td>
              {CHANNELS.map((ch) => (
                <td key={ch} className="py-3 px-4 text-center">
                  <input
                    type="checkbox"
                    checked={isEnabled(cat, ch)}
                    onChange={() => toggle(cat, ch)}
                    className="w-4 h-4 rounded accent-blue-600 cursor-pointer"
                  />
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Read State: Client-Side vs Server-Side Tracking

Two approaches exist for recording when a notification was read:

Server-side tracking (recommended): The client calls POST /api/notifications/read with IDs. The server writes readAt timestamps. The source of truth lives in the database. Survives page refreshes, multiple devices, incognito tabs.

Client-side tracking: Store read IDs in localStorage or a cookie. Zero server calls. Works for low-stakes notifications where “read” only matters for the current session. Breaks across devices and does not support audit trails.

For SaaS products where users care about missing a billing alert or security event, server-side tracking is non-negotiable. The only question is whether you batch the write (fire-and-forget per notification) or debounce it. Debouncing by 500ms with a flush on panel close reduces write amplification without adding perceivable latency.

Polling vs Push: Tradeoffs

PollingSSE
Implementation complexityLowMedium
Server loadScales with interval x usersProportional to open connections
LatencyInterval-dependent (5-30s typical)Near-instant
Works behind all proxiesYesSometimes not (depends on proxy buffering)
Serverless compatibilityYesPartial (limited connection duration)
Mobile/battery impactHigherLower

If you are on serverless (Vercel Functions, Cloudflare Workers), SSE connections are limited by max execution time. Vercel caps function duration; a persistent SSE stream will time out. The workaround is to use an external pub/sub layer: Upstash Redis, Ably, or Pusher Channels handles the persistent connection; your API routes just publish events. The client connects to the external service directly, or through a thin proxy.

Polling with a 15-second interval is a completely reasonable starting point for most SaaS products. Build the API first, add SSE when you have users and latency data to justify it.

Production Considerations

Notification volume and UX. If a single user can receive hundreds of notifications per day (activity feeds, CI results), the unread count badge becomes meaningless noise. Cap the badge at 99+. Consider grouping by groupKey to collapse “User A commented, User B commented, User C commented” into “3 people commented on your post.” Store groupKey on creation; the query groups by it before returning the page.

Read state on scroll. Marking a notification read only on explicit click is the safest behavior. Auto-marking on scroll (intersection observer per row) reduces friction but requires careful debouncing. If the panel closes before the request completes, you lose the mark-read signal. Buffer pending IDs and flush on panel close.

Fan-out at scale. If a domain event should notify many users (a project was archived and 200 members need to know), insert in batches rather than one row per call. Use a queue (BullMQ, SQS) for fan-out so the event emitter does not block on bulk inserts.

Indexing. The notifications table needs at minimum: (userId, readAt, createdAt DESC) for the unread-first list query, and (userId, groupKey, createdAt DESC) for grouped views. Without these, the unread count query will scan the full table as notification volume grows.

Cleanup. Notifications older than 90 days rarely surface. Run a daily job that hard-deletes or archives old rows. Keep the table small enough that index scans stay fast.

A Note on Notification Grouping

When the groupKey field is populated, the list query should collapse rows:

// Pseudo-query using Drizzle
const grouped = await db
  .selectDistinctOn([notifications.groupKey], {
    id: sql`max(${notifications.id})`,
    groupKey: notifications.groupKey,
    title: notifications.title,
    count: sql<number>`count(*)`,
    readAt: notifications.readAt,
    createdAt: sql`max(${notifications.createdAt})`,
  })
  .from(notifications)
  .where(eq(notifications.userId, userId))
  .orderBy(notifications.groupKey, desc(notifications.createdAt));

Pass count through to the client so the row can render “3 people commented” instead of showing one entry per event.

Closing

A notification center touches the data layer, real-time infrastructure, client state management, and UI all at once. The pieces are each straightforward; the integration work is where the bugs live. Build the polling version first: a clean data model, a reliable GET /api/notifications with cursor pagination, and a POST /api/notifications/read. Add SSE when you have users to justify the infrastructure overhead. Ship preferences from day one: users who cannot control what they receive will mute everything, which defeats the point.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.