Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

How to Build an In-App Notification Center for Your Next.js SaaS -- Notification History, Read State, and a Bell Icon Inbox

October 18, 2026
nextjssaasdrizzle-ormreal-timeboilerplate

You Built Notifications. Users Still Ask "What Did I Miss?"

You added a notification badge to your SaaS. It lights up, the user glances at it, clicks something else, and moves on. An hour later, they email support: "Did someone reply to my post?"

The badge delivered the alert. You never stored the history.

This is the gap between a delivery system and a notification center. Most teams ship the first and forget the second. The fix is a database table, three API routes, and a dropdown -- an afternoon of work that eliminates an entire category of support tickets.

What a Notification Center Needs

Before you write code, get clear on the three things it must do:

  1. Store history -- notifications persist after delivery so users can review them later
  2. Track read state -- users can see what is new and clear the badge
  3. Link to context -- every notification leads somewhere useful (the comment thread, the billing page, the dashboard)

Notification preferences -- letting users opt out of certain types -- are a fourth step most teams add only after users ask. Build the first three first.

The Database Schema

One table handles everything. Add it to your Drizzle schema file:

export const notificationTable = pgTable("notifications", {
  id: uuid("id").defaultRandom().primaryKey(),
  userId: uuid("user_id").notNull().references(() => userTable.id),
  type: text("type").notNull(), // "comment", "mention", "payment_failed"
  title: text("title").notNull(),
  body: text("body").notNull(),
  url: text("url"),             // where to go when clicked
  readAt: timestamp("read_at"), // null = unread
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

Register the table in db/drizzle.ts, then run:

npm run db:generate
npm run db:migrate

That is the entire data model. No separate preferences table until users ask for one.

One rule to get right from the start: notifications are created for the recipient, not the actor. The comment service notifies the post author. The Stripe webhook handler notifies the subscriber. The person who triggered the event never notifies themselves.

The Three Routes You Need

GET  /api/notifications          -- list current user's notifications (newest first)
PATCH /api/notifications/:id     -- mark one notification read
POST /api/notifications/read-all -- mark all unread as read

Each route authenticates with getUserFromRequest(req) from lib/auth.ts and filters by userId. The service layer creates notifications -- called from wherever the triggering event happens, not from the route handlers. Business logic stays out of routes.

Keep the list query light: fetch at most 20 rows, ordered by created_at DESC. You do not need pagination on day one.

The Bell Icon and Dropdown

The UI has two pieces: a badge count and a scrollable list.

Use an SWR hook to fetch /api/notifications every 30 seconds. The unread count is:

const unreadCount = notifications.filter(n => !n.readAt).length;

Display this count as a badge on the bell icon in your header. When the user opens the dropdown, call POST /api/notifications/read-all and update the local SWR cache. The badge clears; the notification history stays visible.

Each row in the list renders the title, a relative timestamp ("2 minutes ago"), and links to notification.url. That URL is what makes the feature worth building: every alert becomes a one-click path to exactly the right place in your app, whether that is a comment thread, a team invite, or the billing settings page.

How This Fits With Real-Time Delivery

If you have already added real-time delivery via SSE, combine both patterns. SSE pushes the badge update immediately so users see something new without refreshing. The notification center stores the record so users can review it later. The two approaches complement each other -- real-time delivery without history is a badge that disappears; history without live delivery is a feature users have to remember to check.

The Next.js SaaS Boilerplate already has what both patterns need: JWT auth via getUserFromRequest, Drizzle ORM for the schema and queries, SWR for client-side data fetching, and shadcn UI for the bell icon and dropdown shell. See the database docs for the migration workflow.

Build This Before Your First Collaboration Feature

If your SaaS handles any async activity -- comments, team invites, mentions, payment events -- a notification center is not optional. It is what keeps users from wondering what they missed and emailing support to find out.

Get the schema and three routes in before you ship the first feature that generates a notification. Retrofitting it later means you can never backfill the events you never stored.

Start with the Next.js SaaS Boilerplate and ship your notification center this afternoon.