Build a Production Notification System with Claude Code
Design a production notification system with Claude Code: DB, API, React UI, idempotency, retries, and tests.
A notification system is not just a bell icon. The production failures are duplicate delivery, wrong unread counts, email floods, lost retries, and leaking private data. This guide shows how to ask Claude Code for a notification system that keeps the in-app record as the source of truth, then adds email and push as controlled delivery channels.
The key is to define the boundary before asking Claude Code to generate files. In-app notifications should be written to the database first. Email and push should be side effects created from a delivery queue. User preferences, idempotency, batching, rate limiting, privacy, and retry logic belong in the service layer, not in a React component.
For official references, use MDN Notifications API for browser notification behavior, Next.js Route Handlers for API routes, and PostgreSQL CREATE TABLE for the DDL. If you connect email later, follow an official provider guide such as Resend with Next.js, but connect it after the queue, not directly inside the request handler. Claude Code basics are covered in the Claude Code documentation.
System Boundary
Split the notification system into four layers.
| Layer | Responsibility | Common production bug |
|---|---|---|
| Event source | Payment failed, comment added, job finished | The same event arrives more than once |
| Notification service | Save, dedupe, read state, preferences, rate limits | Business rules leak into UI code |
| Delivery queue | Email, push, webhook retries | External outage drops the notification |
| UI | Notification center, unread badge, browser alerts | Badge count drifts across tabs and devices |
Do not start with push. Browser notifications require permission and secure contexts, and mobile behavior often needs a Service Worker. Email has deliverability, unsubscribe, cost, and complaint handling. A safer production boundary is: write the in-app notification, then enqueue external delivery only when preferences and severity allow it.
Use a prompt like this with Claude Code:
Build a notification system for Next.js App Router and PostgreSQL.
In-app notifications are the source of truth. Email and push only go into a delivery queue.
Requirements: unread state, idempotency_key, batching, user preferences, rate limit, retry queue, privacy-safe payload.
Implement DB schema, service, route handler, React notification center, and Vitest tests.
Plain definitions help the model. Idempotency means that running the same operation twice does not create extra results. For notifications, the same payment webhook should create one “payment failed” notification. Batching means grouping noisy events, such as ten comments, into a digest. A retry queue is a durable list of external delivery jobs that can be tried again after a provider failure.
Use Cases
First use case: SaaS operations alerts. Billing failures, sync failures, or background job errors may need both an in-app record and email. A failed job can emit hundreds of events in a minute, so email must be limited. Use immediate delivery only for critical events and batch the rest.
Second use case: team collaboration. Mentions, comments, and review requests need accurate unread state. Users expect “mark read”, “mark all read”, and a stable badge. Do not put the full comment body in email or push; store a short summary and send the user to the authenticated page.
Third use case: content and revenue operations. For a site like ClaudeCodeLab, notifications can track publish checks, broken CTA links, free PDF delivery, product onboarding, and consultation reminders. They should support the revenue path without becoming noise. Internal links such as Claude Code productivity tips and form validation with Claude Code help users continue to the next practical topic.
Fourth use case: admin audit notifications. Permission changes, API key creation, and data exports should be easy to review later. The notification body should avoid private details and link to an authenticated audit record.
Database Schema
This PostgreSQL schema separates the in-app source of truth from delivery jobs and preferences.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE notification_preferences (
user_id text PRIMARY KEY,
in_app_enabled boolean NOT NULL DEFAULT true,
email_enabled boolean NOT NULL DEFAULT false,
push_enabled boolean NOT NULL DEFAULT false,
digest_minutes integer NOT NULL DEFAULT 5,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
event_name text NOT NULL,
title text NOT NULL,
body text NOT NULL,
target_url text,
severity text NOT NULL DEFAULT 'info',
data jsonb NOT NULL DEFAULT '{}'::jsonb,
idempotency_key text,
batch_key text,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX notifications_user_idempotency_unique
ON notifications (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE INDEX notifications_user_created_idx
ON notifications (user_id, created_at DESC);
CREATE INDEX notifications_user_unread_idx
ON notifications (user_id, created_at DESC)
WHERE read_at IS NULL;
CREATE TABLE notification_delivery_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id uuid NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
channel text NOT NULL CHECK (channel IN ('email', 'push')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
attempts integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 5,
available_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notification_delivery_queue_pick_idx
ON notification_delivery_queue (status, available_at, created_at);
CREATE TABLE notification_rate_limits (
user_id text NOT NULL,
channel text NOT NULL,
window_start timestamptz NOT NULL,
count integer NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel, window_start)
);
Use read_at instead of keeping both is_read and read_at. Two read fields eventually disagree. If you need deletion, prefer archived_at over physical deletion so the queue and audit path remain consistent.
Notification Service
Install the runtime packages before using this file.
npm install pg
npm install -D @types/pg vitest
Create lib/notification-service.ts. The service stores the in-app notification first, deduplicates with idempotency_key, and queues email or push only when preferences and severity allow it.
import { Pool, type PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export type NotificationSeverity = "info" | "success" | "warning" | "critical";
export type ExternalChannel = "email" | "push";
export type NotificationRow = {
id: string;
user_id: string;
event_name: string;
title: string;
body: string;
target_url: string | null;
severity: NotificationSeverity;
data: Record<string, unknown>;
idempotency_key: string | null;
batch_key: string | null;
read_at: string | null;
created_at: string;
};
type Preferences = {
in_app_enabled: boolean;
email_enabled: boolean;
push_enabled: boolean;
digest_minutes: number;
};
export type CreateNotificationInput = {
userId: string;
eventName: string;
title: string;
body: string;
targetUrl?: string;
severity?: NotificationSeverity;
data?: Record<string, unknown>;
resourceId?: string;
idempotencyKey?: string;
batchKey?: string;
};
export function makeIdempotencyKey(input: {
userId: string;
eventName: string;
resourceId?: string;
}) {
if (!input.resourceId) return null;
return `${input.userId}:${input.eventName}:${input.resourceId}`;
}
export function shouldQueueExternal(input: {
channel: ExternalChannel;
severity: NotificationSeverity;
preferences: Pick<Preferences, "email_enabled" | "push_enabled">;
}) {
if (input.channel === "email" && !input.preferences.email_enabled) return false;
if (input.channel === "push" && !input.preferences.push_enabled) return false;
return input.severity === "warning" || input.severity === "critical";
}
async function ensurePreferences(client: PoolClient, userId: string) {
const result = await client.query<Preferences>(
`
INSERT INTO notification_preferences (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO UPDATE
SET updated_at = notification_preferences.updated_at
RETURNING in_app_enabled, email_enabled, push_enabled, digest_minutes
`,
[userId],
);
return result.rows[0];
}
async function consumeRateLimit(
client: PoolClient,
userId: string,
channel: ExternalChannel,
limitPerMinute: number,
) {
const result = await client.query<{ count: number }>(
`
INSERT INTO notification_rate_limits (user_id, channel, window_start, count)
VALUES ($1, $2, date_trunc('minute', now()), 1)
ON CONFLICT (user_id, channel, window_start)
DO UPDATE SET count = notification_rate_limits.count + 1
RETURNING count
`,
[userId, channel],
);
return result.rows[0].count <= limitPerMinute;
}
async function queueDelivery(
client: PoolClient,
notificationId: string,
channel: ExternalChannel,
availableAt: Date,
) {
await client.query(
`
INSERT INTO notification_delivery_queue (notification_id, channel, available_at)
VALUES ($1, $2, $3)
`,
[notificationId, channel, availableAt],
);
}
export async function createNotification(input: CreateNotificationInput) {
const client = await pool.connect();
const severity = input.severity ?? "info";
const idempotencyKey =
input.idempotencyKey ??
makeIdempotencyKey({
userId: input.userId,
eventName: input.eventName,
resourceId: input.resourceId,
});
try {
await client.query("BEGIN");
const preferences = await ensurePreferences(client, input.userId);
if (!preferences.in_app_enabled) {
await client.query("COMMIT");
return null;
}
const result = await client.query<NotificationRow & { inserted: boolean }>(
`
WITH inserted AS (
INSERT INTO notifications (
user_id, event_name, title, body, target_url, severity,
data, idempotency_key, batch_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
ON CONFLICT (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL
DO NOTHING
RETURNING *, true AS inserted
)
SELECT * FROM inserted
UNION ALL
SELECT *, false AS inserted
FROM notifications
WHERE user_id = $1
AND idempotency_key = $8
AND $8 IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM inserted)
LIMIT 1
`,
[
input.userId,
input.eventName,
input.title,
input.body,
input.targetUrl ?? null,
severity,
JSON.stringify(input.data ?? {}),
idempotencyKey,
input.batchKey ?? null,
],
);
const notification = result.rows[0];
if (!notification) {
throw new Error("Notification insert failed");
}
if (notification.inserted) {
for (const channel of ["email", "push"] as const) {
const allowedByPreference = shouldQueueExternal({
channel,
severity,
preferences,
});
if (!allowedByPreference) continue;
const allowedByRateLimit = await consumeRateLimit(
client,
input.userId,
channel,
channel === "email" ? 5 : 20,
);
if (!allowedByRateLimit) continue;
const delayMs = input.batchKey ? preferences.digest_minutes * 60_000 : 0;
await queueDelivery(
client,
notification.id,
channel,
new Date(Date.now() + delayMs),
);
}
}
await client.query("COMMIT");
return notification;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listNotifications(userId: string, unreadOnly = false, limit = 30) {
const result = await pool.query<NotificationRow>(
`
SELECT *
FROM notifications
WHERE user_id = $1
AND ($2::boolean = false OR read_at IS NULL)
ORDER BY created_at DESC
LIMIT $3
`,
[userId, unreadOnly, Math.min(limit, 100)],
);
return result.rows;
}
export async function markNotificationRead(userId: string, notificationId: string) {
const result = await pool.query<NotificationRow>(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()), updated_at = now()
WHERE user_id = $1 AND id = $2
RETURNING *
`,
[userId, notificationId],
);
return result.rows[0] ?? null;
}
export async function markAllNotificationsRead(userId: string) {
const result = await pool.query(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()), updated_at = now()
WHERE user_id = $1 AND read_at IS NULL
`,
[userId],
);
return result.rowCount ?? 0;
}
export type DeliveryJob = {
id: string;
notification_id: string;
channel: ExternalChannel;
attempts: number;
max_attempts: number;
};
export async function claimNextDeliveryJob() {
const result = await pool.query<DeliveryJob>(
`
UPDATE notification_delivery_queue
SET status = 'sending', attempts = attempts + 1, locked_at = now(), updated_at = now()
WHERE id = (
SELECT id
FROM notification_delivery_queue
WHERE status = 'pending' AND available_at <= now()
ORDER BY available_at ASC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, notification_id, channel, attempts, max_attempts
`,
);
return result.rows[0] ?? null;
}
export async function finishDeliveryJob(job: DeliveryJob, error?: unknown) {
if (!error) {
await pool.query(
"UPDATE notification_delivery_queue SET status = 'sent', updated_at = now() WHERE id = $1",
[job.id],
);
return;
}
const retryDelayMs = Math.min(30 * 60_000, 60_000 * 2 ** job.attempts);
const failedPermanently = job.attempts >= job.max_attempts;
await pool.query(
`
UPDATE notification_delivery_queue
SET status = $2,
available_at = $3,
last_error = $4,
updated_at = now()
WHERE id = $1
`,
[
job.id,
failedPermanently ? "failed" : "pending",
new Date(Date.now() + retryDelayMs),
error instanceof Error ? error.message : String(error),
],
);
}
The important behavior is that duplicate events return the existing notification and do not create new delivery jobs. That is the difference between a demo and a production system.
API Route
Create app/api/notifications/route.ts. The demo reads x-demo-user-id to stay copy-pasteable. Replace it with real session auth in production.
import { NextRequest, NextResponse } from "next/server";
import {
createNotification,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "@/lib/notification-service";
export const runtime = "nodejs";
function requireDemoUserId(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) {
throw new Error("Missing x-demo-user-id header. Replace this with real auth.");
}
return userId;
}
export async function GET(request: NextRequest) {
try {
const userId = requireDemoUserId(request);
const unreadOnly = request.nextUrl.searchParams.get("unread") === "1";
const notifications = await listNotifications(userId, unreadOnly);
return NextResponse.json({ notifications });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 401 },
);
}
}
export async function POST(request: NextRequest) {
try {
const userId = requireDemoUserId(request);
const body = await request.json();
const notification = await createNotification({
userId,
eventName: String(body.eventName),
title: String(body.title),
body: String(body.body),
targetUrl: body.targetUrl ? String(body.targetUrl) : undefined,
severity: body.severity,
resourceId: body.resourceId ? String(body.resourceId) : undefined,
batchKey: body.batchKey ? String(body.batchKey) : undefined,
data: typeof body.data === "object" && body.data ? body.data : {},
});
return NextResponse.json({ notification }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 400 },
);
}
}
export async function PATCH(request: NextRequest) {
const userId = requireDemoUserId(request);
const body = await request.json().catch(() => ({}));
if (body.notificationId) {
const notification = await markNotificationRead(userId, String(body.notificationId));
return NextResponse.json({ notification });
}
const updated = await markAllNotificationsRead(userId);
return NextResponse.json({ updated });
}
Never trust userId from the request body. A notification endpoint contains private unread state, so ownership must come from auth and every update must include a user guard.
React Notification Center
The UI keeps in-app notifications primary. Desktop browser alerts are optional and requested only from a user click.
"use client";
import { useEffect, useMemo, useState } from "react";
type AppNotification = {
id: string;
title: string;
body: string;
target_url: string | null;
severity: "info" | "success" | "warning" | "critical";
read_at: string | null;
created_at: string;
};
const demoHeaders = {
"x-demo-user-id": "demo-user-1",
};
export function NotificationCenter() {
const [items, setItems] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const [desktopEnabled, setDesktopEnabled] = useState(false);
const unreadCount = useMemo(
() => items.filter((item) => item.read_at === null).length,
[items],
);
async function loadNotifications() {
const response = await fetch("/api/notifications", {
headers: demoHeaders,
cache: "no-store",
});
if (!response.ok) return;
const data = (await response.json()) as { notifications: AppNotification[] };
setItems(data.notifications);
}
async function markRead(notificationId: string) {
await fetch("/api/notifications", {
method: "PATCH",
headers: {
...demoHeaders,
"content-type": "application/json",
},
body: JSON.stringify({ notificationId }),
});
setItems((current) =>
current.map((item) =>
item.id === notificationId
? { ...item, read_at: item.read_at ?? new Date().toISOString() }
: item,
),
);
}
async function markAllRead() {
await fetch("/api/notifications", {
method: "PATCH",
headers: {
...demoHeaders,
"content-type": "application/json",
},
body: JSON.stringify({ all: true }),
});
const now = new Date().toISOString();
setItems((current) => current.map((item) => ({ ...item, read_at: item.read_at ?? now })));
}
async function enableDesktopNotifications() {
if (!("Notification" in window)) return;
const permission = await Notification.requestPermission();
setDesktopEnabled(permission === "granted");
}
useEffect(() => {
loadNotifications();
const timer = window.setInterval(loadNotifications, 30_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!desktopEnabled || document.visibilityState === "visible") return;
const newest = items.find((item) => item.read_at === null);
if (!newest) return;
new Notification(newest.title, {
body: newest.body,
tag: newest.id,
});
}, [desktopEnabled, items]);
return (
<div className="notification-center">
<button type="button" onClick={() => setOpen((value) => !value)}>
Notifications
{unreadCount > 0 ? <span aria-label={`${unreadCount} unread`}>{unreadCount}</span> : null}
</button>
{open ? (
<section aria-label="Notification center">
<div>
<button type="button" onClick={markAllRead} disabled={unreadCount === 0}>
Mark all read
</button>
<button type="button" onClick={enableDesktopNotifications}>
Enable desktop alerts
</button>
</div>
{items.length === 0 ? (
<p>No notifications.</p>
) : (
<ul>
{items.map((item) => (
<li key={item.id} data-unread={item.read_at === null}>
<strong>{item.title}</strong>
<p>{item.body}</p>
<small>{new Date(item.created_at).toLocaleString()}</small>
<div>
{item.target_url ? <a href={item.target_url}>Open</a> : null}
{item.read_at === null ? (
<button type="button" onClick={() => markRead(item.id)}>
Mark read
</button>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
) : null}
</div>
);
}
Polling every 30 seconds is fine for the first version. WebSockets or Server-Sent Events can come later. Stable read state matters more than instant animation.
Test the Policy
Create notification-service.test.ts. These tests lock down the behavior that prevents expensive incidents.
import { describe, expect, it } from "vitest";
import { makeIdempotencyKey, shouldQueueExternal } from "./notification-service";
describe("notification policy", () => {
it("builds a stable idempotency key from user, event, and resource", () => {
expect(
makeIdempotencyKey({
userId: "user_1",
eventName: "invoice.payment_failed",
resourceId: "invoice_9",
}),
).toBe("user_1:invoice.payment_failed:invoice_9");
});
it("does not build an idempotency key without a resource id", () => {
expect(
makeIdempotencyKey({
userId: "user_1",
eventName: "system.notice",
}),
).toBeNull();
});
it("queues email only when preferences and severity allow it", () => {
expect(
shouldQueueExternal({
channel: "email",
severity: "warning",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(true);
expect(
shouldQueueExternal({
channel: "email",
severity: "info",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(false);
expect(
shouldQueueExternal({
channel: "push",
severity: "critical",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(false);
});
});
Ask Claude Code to add failing tests for duplicate webhooks, disabled preferences, rate-limit overflow, queue retry failure, and attempts to mark another user’s notification as read.
Pitfalls
The first pitfall is putting too much private data into the notification body. Email and push can appear on lock screens, in forwarded mail, and in provider logs. Store a short summary and link to an authenticated page.
The second pitfall is calculating unread count only in the browser. Multiple tabs and devices will drift. Use read_at IS NULL as the server truth, then let the UI optimistically update and refresh.
The third pitfall is rate-limiting the creation of the in-app record. That can hide audit events. Limit external delivery first, and keep the database record unless the event is invalid.
The fourth pitfall is leaving sending jobs stuck forever. Add a worker monitor that returns old locked jobs to pending, and expose permanently failed jobs in an admin view.
The fifth pitfall is giving Claude Code a vague prompt. “Build notifications” usually produces toasts and maybe a WebSocket. Include data model, idempotency, preferences, rate limiting, privacy, retry queue, and tests in the first instruction.
Monetization Path
Notifications can support revenue without becoming spam. Use them for free PDF delivery, product onboarding, broken CTA checks, and consultation reminders. The message should help the user take the next action, not interrupt every small event. For deeper implementation templates, review the ClaudeCodeLab products and the practical guides on productivity and form validation.
Hands-On Result
I pasted this schema and service into a local Next.js test project, posted the same invoice.payment_failed event twice with the same resourceId, and confirmed that notifications stayed at one row and the delivery queue only received the first job. With email disabled, the in-app record remained and no email job was queued. The most important decision was defining idempotency_key and read_at before building the UI.
Summary
Production notifications are a data consistency problem before they are a UI problem. Keep the in-app notification as the source of truth, send email and push through a queue, and require unread state, idempotency, batching, preferences, rate limiting, privacy, retries, and tests from the beginning. Claude Code can build the system quickly, but only if those boundaries are explicit.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Receipt Pattern: Record Scope, Proof, and Rollback
A permission receipt pattern for Claude Code: allowed actions, approval boundaries, proof commands, rollback, and revenue CTA checks.
Safe Agent Harness Design for Claude Code and Codex: Permissions, Checks, and Rollback
Build a practical agent harness for Claude Code and Codex with policy, planning, verification, and recovery layers.
Claude Code Subagents: A Practical Guide to Safe Agent Delegation
Claude Code subagent guide for safe parallel article and code work: delegation rules, prompts, pitfalls, and checks.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.