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

In-app notifications in Next.js App Router with Drizzle ORM -- unread count, mark as read, and live polling

June 20, 2026
nextjsdrizzle-ormsaasneon-db

Every SaaS needs a notification bell. Users expect to know when something happens -- a new comment, a payment, a teammate mention -- without refreshing the page. Building it from scratch takes longer than it should.

Here is the full pattern: schema, service, API routes, and a polling hook that keeps the badge live without a WebSocket server.

The schema

One notifications table covers every event type you will ever need:

// modules/notification/notification.schema.ts
import { pgTable, text, boolean, timestamp, uuid } from 'drizzle-orm/pg-core';
 
export const notificationTable = pgTable('notifications', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull(),
  type: text('type').notNull(),
  title: text('title').notNull(),
  body: text('body').notNull(),
  link: text('link'),
  read: boolean('read').notNull().default(false),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

type is a plain string -- no enum. You will add event types over the product's lifetime; a string column means a new type is a one-liner in your service, not a migration.

The service

Two jobs here: creating notifications and reading them.

// modules/notification/notification.service.ts
export const notificationService = {
  async create(userId: string, type: string, title: string, body: string, link?: string) {
    return notificationRepo.create({ userId, type, title, body, link });
  },
 
  async listForUser(userId: string, limit = 20) {
    return notificationRepo.findByUser(userId, limit);
  },
 
  async getUnreadCount(userId: string) {
    return notificationRepo.countUnread(userId);
  },
 
  async markAllRead(userId: string) {
    return notificationRepo.markAllRead(userId);
  },
};

Other services call notificationService.create() as a side effect. When a comment is posted:

// inside commentService.create()
await notificationService.create(
  post.authorId,
  'comment',
  'New comment on your post',
  `${commenter.name} replied to "${post.title}"`,
  `/posts/${post.slug}`
);

No event bus, no queue -- just a direct call. For most SaaS apps at launch, this is the right level of complexity.

The API routes

Two routes cover everything the frontend needs:

// app/api/notifications/route.ts
export async function GET(req: Request) {
  const user = await getUserFromRequest(req);
  const [notifications, unreadCount] = await Promise.all([
    notificationService.listForUser(user.id),
    notificationService.getUnreadCount(user.id),
  ]);
  return Response.json({ notifications, unreadCount });
}
 
export async function PATCH(req: Request) {
  const user = await getUserFromRequest(req);
  await notificationService.markAllRead(user.id);
  return Response.json({ ok: true });
}

Add a PATCH /api/notifications/[id] route if you need per-notification tracking. For most apps, "mark all read" when the user opens the panel is enough.

The polling hook

You do not need a WebSocket server. A 30-second poll is undetectable to users and costs almost nothing on Neon's serverless plan.

// hooks/api/useNotifications.ts
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
import { fetcher } from '@/lib/fetcher';
 
const KEY = '/api/notifications';
 
export function useNotifications() {
  const { data, mutate } = useSWR(KEY, fetcher, { refreshInterval: 30_000 });
 
  const { trigger: markAllRead } = useSWRMutation(
    KEY,
    (url) => fetch(url, { method: 'PATCH' }).then((r) => r.json()),
    { onSuccess: () => mutate() }
  );
 
  return {
    notifications: data?.notifications ?? [],
    unreadCount: data?.unreadCount ?? 0,
    markAllRead,
  };
}

The notification bell

// components/layout/NotificationBell.tsx
'use client';
import { Bell } from 'lucide-react';
import { useNotifications } from '@/hooks/api/useNotifications';
 
export function NotificationBell() {
  const { unreadCount, markAllRead } = useNotifications();
 
  return (
    <button className="relative" onClick={() => markAllRead()} aria-label="Notifications">
      <Bell className="h-5 w-5" />
      {unreadCount > 0 && (
        <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] text-white font-medium">
          {unreadCount > 9 ? '9+' : unreadCount}
        </span>
      )}
    </button>
  );
}

Drop <NotificationBell /> into your header and you have a live unread count that clears when users open the panel.

What ships with the boilerplate

With this boilerplate, the Drizzle client, JWT auth, and SWR fetcher are already wired. You add the notifications schema, run npm run db:generate && npm run db:migrate, and start calling notificationService.create() from any service that generates an event.

Total time to a working notification bell: under an hour.

Try it

Clone the repo, follow the module pattern above, and ship. No extra infrastructure required -- just Postgres and the API routes you already have.

Get the boilerplate and have notifications running before lunch.