| name | realtime-kit |
| description | Unified real-time infrastructure — WebSocket lifecycle management and scaling, Socket.io (namespaces, rooms, middleware, Redis adapter), Liveblocks collaboration (presence, shared CRDT storage, comments), push notifications (Web Push API, FCM, APNs, in-app). Single entry point for presence + channels + delivery. |
| layer | domain |
| category | realtime |
| triggers | ["@liveblocks/","LiveList","LiveObject","RoomProvider","fcm","io()","io.of","live updates","liveblocks","notification","push notification","real-time","realtime","server-sent events","service worker notification","socket.emit","socket.io","socket.join","socket.on","socket.to","sse","useMyPresence","useOthers","web push","websocket","websockets"] |
realtime-kit
Unified real-time infrastructure — WebSocket lifecycle management and scaling, Socket.io (namespaces, rooms, middleware, Redis adapter), Liveblocks collaboration (presence, shared CRDT storage, comments), push notifications (Web Push API, FCM, APNs, in-app). Single entry point for presence + channels + delivery.
Absorbs
websockets
socketio
liveblocks
notifications
From websockets
Real-time communication patterns, WebSocket lifecycle management, scaling strategies, and protocol design
WebSockets Domain Skill
Purpose
Provide expert-level guidance on real-time communication patterns including WebSocket lifecycle management, reconnection strategies, horizontal scaling with pub/sub, protocol design, and choosing between WebSockets, SSE, and long polling.
When to Use What
| Technology | Use Case | Direction | Overhead |
|---|
| WebSocket | Chat, gaming, collaboration | Bidirectional | Low after handshake |
| SSE (Server-Sent Events) | Notifications, feeds, dashboards | Server-to-client | Very low |
| Long Polling | Fallback, low-frequency updates | Client-initiated | Medium |
| WebTransport | Ultra-low latency, UDP semantics | Bidirectional | Lowest |
Default to SSE unless you need client-to-server messaging. WebSockets add complexity.
Key Patterns
1. WebSocket Server (Node.js)
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';
import { randomUUID } from 'crypto';
interface Client {
id: string;
ws: WebSocket;
userId: string;
rooms: Set<string>;
isAlive: boolean;
metadata: Record<string, unknown>;
}
class RealtimeServer {
private wss: WebSocketServer;
private clients = new Map<string, Client>();
private rooms = new Map<string, Set<string>>();
private heartbeatInterval: NodeJS.Timeout;
constructor(server: http.Server) {
. = ({ server, : });
..(, ..());
. = ( .(), );
}
() {
token = (req.!, )..();
user = .(token);
(!user) {
ws.(, );
;
}
: = {
: (),
ws,
: user.,
: (),
: ,
: {},
};
..(client., client);
.(client, { : , : client. });
ws.(, .(client, data));
ws.(, { client. = ; });
ws.(, .(client));
ws.(, {
.(, err);
ws.();
});
}
() {
{
message = .(raw.());
(message.) {
:
.(client, message.);
;
:
.(client, message.);
;
:
.(message., {
: ,
: client.,
: message.,
}, client.);
;
:
.(client, { : , : .() });
;
:
.(client, { : , : });
}
} {
.(client, { : , : });
}
}
() {
client..(room);
(!..(room)) ..(room, ());
..(room)!.(client.);
.(client, { : , room });
}
() {
client..(room);
..(room)?.(client.);
(..(room)?. === ) ..(room);
}
() {
clientIds = ..(room);
(!clientIds) ;
payload = .(message);
( clientId clientIds) {
(clientId === excludeClientId) ;
client = ..(clientId);
(client?.. === .) {
client..(payload);
}
}
}
() {
(client.. === .) {
client..(.(message));
}
}
() {
( room client.) {
.(client, room);
}
..(client.);
}
() {
( [id, client] .) {
(!client.) {
client..();
.(client);
;
}
client. = ;
client..();
}
}
() {
(.);
( client ..()) {
client..(, );
}
}
}
2. Client-Side Reconnection
class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private baseDelay = 1000;
private maxDelay = 30000;
private messageQueue: string[] = [];
private listeners = new Map<string, Set<Function>>();
constructor(private url: string, private protocols?: string[]) {
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url, this.protocols);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.();
.();
};
.. = {
{
data = .(event.);
.(, data);
.(data., data);
} {
.(, event.);
}
};
.. = {
.(, event);
(event. !== && event. !== ) {
.();
}
};
.. = {
.(, error);
};
}
() {
(. >= .) {
.();
;
}
delay = .(
. * .(, .) + .() * ,
.
);
.++;
.(, { : ., delay });
( .(), delay);
}
() {
payload = .(data);
(.?. === .) {
..(payload);
} {
..(payload);
}
}
() {
(.. > && .?. === .) {
..(..()!);
}
}
() {
(!..(event)) ..(event, ());
..(event)!.(callback);
..(event)?.(callback);
}
() {
..(event)?.( (...args));
}
() {
. = ;
.?.();
}
}
3. Scaling with Redis Pub/Sub
import Redis from 'ioredis';
class ScalableRealtimeServer extends RealtimeServer {
private redisPub: Redis;
private redisSub: Redis;
private serverId = randomUUID();
constructor(server: http.Server, redisUrl: string) {
super(server);
this.redisPub = new Redis(redisUrl);
this.redisSub = new Redis(redisUrl);
this.redisSub.on('message', (channel, message) => {
const { serverId, room, data } = JSON.parse(message);
if (serverId === this.serverId) return;
super.broadcastToRoom(room, data);
});
}
() {
.(room, message, excludeClientId);
..(, .({
: .,
room,
: message,
}));
}
() {
.(client, room);
..();
}
}
4. Server-Sent Events (SSE)
import { Router } from 'express';
const router = Router();
router.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
const userId = req.user.id;
const send = (event: string, data: unknown) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n`);
res.write(`id: ${Date.now()}\n\n`);
};
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30_000);
unsubscribe = eventBus.(userId, send);
req.(, {
(heartbeat);
();
});
});
events = (, { : });
events.(, {
data = .(e.);
(data);
});
events.(, {
.();
});
Best Practices
- Default to SSE for server-to-client push -- simpler, auto-reconnects, works through proxies
- Authenticate on connection, not per message
- Implement heartbeat/ping-pong to detect dead connections (30s interval)
- Use exponential backoff with jitter for client reconnection
- Queue messages during reconnection for delivery after reconnect
- Use Redis Pub/Sub for horizontal scaling across multiple server instances
- Set
X-Accel-Buffering: no for SSE behind Nginx
- Send message IDs for SSE so clients can resume from last received event
- Limit connections per user to prevent resource exhaustion
- Use binary protocols (MessagePack, Protobuf) for high-throughput scenarios
Common Pitfalls
| Pitfall | Impact | Fix |
|---|
| No heartbeat mechanism | Zombie connections accumulate | Ping/pong every 30 seconds, terminate dead clients |
| No reconnection logic on client | Permanent disconnection on network blip | Implement exponential backoff reconnection |
| Authentication only at connect time | Stale sessions remain connected | Periodic token refresh or disconnect on auth change |
| Buffering by reverse proxy | SSE events delayed or batched | X-Accel-Buffering: no for Nginx, chunked encoding |
| Broadcasting to all in a loop | O(n) for every message | Use rooms/channels to scope broadcasts |
| No message ordering guarantee | Out-of-order events | Include sequence numbers, reorder on client |
| Unbounded connection count | Server resource exhaustion | Rate limit connections per IP/user, use connection pools |
From socketio
Real-time bidirectional event-based communication with Socket.IO
Socket.IO
Real-time bidirectional event-based communication with rooms, namespaces, auto-reconnection, fallback transports, acknowledgements, and TypeScript type safety.
When to Use
Socket.IO when you need rooms, namespaces, auto-reconnect, fallback transports, or acks. Use raw WebSockets for minimal overhead or SSE for server-to-client only.
Key Patterns
Type-Safe Server — Namespaces, Middleware, Rooms, Acks
import { Server } from "socket.io";
interface SrvEv { message: (d: { room: string; body: string }, ack: (ok: boolean) => void) => void; joinRoom: (room: string) => void; }
interface CliEv { message: (d: { from: string; body: string }) => void; userJoined: (id: string) => void; }
const io = new Server<SrvEv, CliEv>(httpServer, { cors: { origin: process.env.CLIENT_URL } });
const chat = io.of("/chat");
chat.use((socket, next) => {
try { socket.. = (socket...); (); } { ( ()); }
});
chat.(, {
socket.(, { socket.(room); socket.(room).(, socket...); });
socket.(, { chat.(d.).(, { : socket..., : d. }); (); });
socket.(, { });
});
Client — Reconnection, Error Handling, Acks
import { io } from "socket.io-client";
const socket = io("/chat", { auth: { token }, reconnectionDelay: 1000, reconnectionDelayMax: 30000 });
socket.on("connect_error", (err) => { if (err.message === "Unauthorized") refreshToken(); });
socket.emit("message", { room: "general", body: "hello" }, (ok) => console.log("ack:", ok));
socket.emit("file", { name: "doc.pdf", data: fileBuffer });
Redis Adapter — Horizontal Scaling
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pub = createClient({ url: process.env.REDIS_URL }), sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
io.adapter(createAdapter(pub, sub));
Anti-Patterns
| Anti-Pattern | Fix |
|---|
| State in socket objects | Use rooms or external store (Redis) |
| No namespace separation | Isolate concerns: /chat, /notifications |
Missing connect_error handler | Handle auth failures, trigger token refresh |
| No adapter in multi-server deploy | Use @socket.io/redis-adapter |
| No ack for critical emits | Use callback pattern for delivery confirmation |
Only catching error event | Also handle connect_error, disconnect, middleware errors |
Related Skills: websockets | nodejs | redis | nextjs
From liveblocks
Real-time collaboration infrastructure — presence, shared storage, comments, notifications, and Yjs integration
Liveblocks
Real-time collaboration infrastructure for web apps. Handles presence, shared storage (CRDTs), comments, notifications, and text editor bindings out of the box.
When to Use
- Live cursors, selections, or avatars showing who is online
- Shared state that syncs across clients without custom WebSocket code
- Collaborative text editing (via Yjs / Tiptap integration)
- Threaded comments or in-app notifications anchored to content
Key Patterns
Room Setup
Wrap collaborative sections in <RoomProvider> with initialPresence and initialStorage (using LiveList, LiveObject, LiveMap). Use <ClientSideSuspense> for loading states.
Presence (cursors, selections, awareness)
useMyPresence() — read/update local user presence (cursor position, selection, etc.)
useOthers() — observe all other connected users' presence in real time
Storage (CRDT-based shared state)
useStorage((root) => root.items) — subscribe to shared data reactively
useMutation(({ storage }, val) => { storage.get("list").push(val) }, []) — write mutations
- Types:
LiveObject (.set/.get), LiveList (.push/.delete/.move), LiveMap (.set/.delete)
- All writes are conflict-free (CRDTs) — no manual conflict resolution needed
Broadcasting Custom Events
useBroadcastEvent() — fire ephemeral events (reactions, pings) to all room users
useEventListener(({ event }) => {}) — listen for broadcast events
Comments and Notifications
<Thread> and <Composer> from @liveblocks/react-ui for threaded comments
useInboxNotifications() for notification feeds, useMarkAllInboxNotificationsAsRead()
Yjs Integration (text editing)
LiveblocksYjsProvider bridges Liveblocks rooms to Yjs documents
- Works with Tiptap, ProseMirror, Monaco, CodeMirror — pass the
Y.Doc to the editor
Authentication and Permissions
- Server-side:
liveblocks.prepareSession(userId, { userInfo }) then session.allow(roomPattern, accessLevel)
- Endpoint at
/api/liveblocks-auth returning the authorized session token
- Scoping:
session.allow("org:*:*", session.FULL_ACCESS) for org-level rooms
Anti-Patterns
| Anti-Pattern | Instead |
|---|
| Storing large blobs in LiveObject | Use external storage, store references only |
Skipping initialPresence / initialStorage | Always define defaults in RoomProvider |
| Polling for presence data | Use useOthers / useMyPresence hooks |
| One global room for everything | Scope rooms per document / context |
| Raw WebSockets alongside Liveblocks | Use useBroadcastEvent for custom events |
Related Skills
react | websockets | tiptap | nextjs
From notifications
Notification systems — Web Push API, service workers, FCM, in-app notification centers, delivery pipelines, and user preference management
Notifications Specialist
Purpose
Notifications connect users to timely, relevant information across channels — push, in-app, email, and SMS. A well-designed notification system respects user preferences, delivers reliably, and avoids the spam trap that causes users to disable notifications entirely. This skill covers Web Push API, Firebase Cloud Messaging (FCM), in-app notification centers, database design, and delivery orchestration.
Key Concepts
Notification Channels
| Channel | Latency | Reach | Best For |
|---|
| Web Push | ~1-5s | Browser open/closed | Time-sensitive actions, re-engagement |
| Mobile Push (FCM/APNs) | ~1-3s | App installed | Real-time alerts, messages |
| In-App | Instant | App open | Feature updates, activity feed |
| Email | Minutes | Universal | Digests, receipts, important updates |
| SMS | Seconds | Universal | 2FA, critical alerts |
Architecture Overview
Event Source -> Notification Service -> Channel Router -> Delivery Adapters
| |
v v
Preferences DB +--------------+
Notification DB | Web Push |
| FCM / APNs |
| In-App (WS) |
| Email (SES) |
| SMS (Twilio) |
+--------------+
Workflow
Step 1: Database Schema
CREATE TABLE notification_types (
id TEXT PRIMARY KEY,
title_template TEXT NOT NULL,
body_template TEXT NOT NULL,
default_channels TEXT[] NOT NULL,
category TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'normal'
);
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type_id TEXT NOT NULL REFERENCES notification_types(id),
title TEXT NOT NULL,
body TEXT NOT NULL,
data JSONB DEFAULT '{}',
image_url TEXT,
read_at TIMESTAMPTZ,
seen_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
idempotency_key TEXT UNIQUE
);
CREATE INDEX idx_notifications_user_unread
ON notifications (user_id, created_at DESC)
WHERE read_at archived_at ;
INDEX idx_notifications_user_feed
notifications (user_id, created_at )
archived_at ;
notification_deliveries (
id UUID gen_random_uuid(),
notification_id UUID notifications(id) CASCADE,
channel TEXT ,
status TEXT ,
provider_id TEXT,
error_message TEXT,
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ now()
);
notification_preferences (
user_id UUID users(id) CASCADE,
category TEXT ,
channel TEXT ,
enabled ,
(user_id, category, channel)
);
push_subscriptions (
id UUID gen_random_uuid(),
user_id UUID users(id) CASCADE,
endpoint TEXT ,
p256dh TEXT ,
auth TEXT ,
user_agent TEXT,
created_at TIMESTAMPTZ now(),
last_used_at TIMESTAMPTZ
);
INDEX idx_push_subs_user push_subscriptions (user_id);
Step 2: Web Push API Implementation
Generate VAPID Keys
npx web-push generate-vapid-keys
Client-Side: Register Service Worker and Subscribe
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;
export async function subscribeToPush(): Promise<PushSubscription | null> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Push notifications not supported');
return null;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Push notification permission denied');
return null;
}
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.({
: ,
: (),
});
(, {
: ,
: { : },
: .(subscription.()),
});
subscription;
}
(): <> {
registration = navigator..;
subscription = registration..();
(subscription) {
(, {
: ,
: { : },
: .({ : subscription. }),
});
subscription.();
}
}
(): {
padding = .(( - (base64String. % )) % );
base64 = (base64String + padding).(, ).(, );
rawData = .(base64);
.([...rawData].( char.()));
}
Service Worker: Handle Push Events
self.addEventListener('push', (event) => {
if (!event.data) return;
const payload = event.data.json();
const options = {
body: payload.body,
icon: payload.icon ?? '/icons/notification-192.png',
badge: payload.badge ?? '/icons/badge-72.png',
image: payload.image,
tag: payload.tag,
renotify: payload.renotify ?? false,
requireInteraction: payload.requireInteraction ?? false,
data: payload.data ?? {},
actions: payload.actions ?? [],
timestamp: payload.timestamp ?? Date.now(),
};
event.waitUntil(
self.registration.showNotification(payload.title, options)
);
});
self.addEventListener('notificationclick', () => {
event..();
url = event..?. ?? ;
action = event.;
event.(
clients.({ : , : }).( {
( client clientList) {
(client..(url) && client) {
client.();
}
}
clients.(url);
})
);
});
Server-Side: Send Push Notifications
import webpush from 'web-push';
import { db } from '@/db';
webpush.setVapidDetails(
'mailto:notifications@example.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
interface PushPayload {
title: string;
body: string;
icon?: string;
image?: string;
tag?: string;
data?: Record<string, unknown>;
actions?: Array<{ action: string; title: string; icon?: string }>;
}
export async function sendPushToUser(userId: string, payload: PushPayload): Promise<void> {
const subscriptions = await db.query(
'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1',
[userId]
);
results = .(
subscriptions..( (sub) => {
{
webpush.(
{
: sub.,
: { : sub., : sub. },
},
.(payload),
{ : * }
);
} (: ) {
(error. === || error. === ) {
db.(
,
[sub.]
);
}
error;
}
})
);
failures = results.( r. === );
(failures. > ) {
.();
}
}
Step 3: In-App Notification Center
API Endpoints
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/db';
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const cursor = request.nextUrl.searchParams.get('cursor');
const limit = Math.min(parseInt(request.nextUrl.searchParams.get('limit') ?? '20'), 50);
const notifications = await db.query(`
SELECT id, type_id, title, body, data, image_url, read_at, seen_at, created_at
FROM notifications
WHERE user_id = $1 AND archived_at IS NULL
${cursor ? 'AND created_at < $3' : }
ORDER BY created_at DESC
LIMIT $2
`, cursor
? [session.., limit + , cursor]
: [session.., limit + ]
);
hasMore = notifications.. > limit;
items = notifications..(, limit);
nextCursor = hasMore ? items[items. - ]. : ;
.({
: items,
nextCursor,
hasMore,
});
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { ids } = await request.json();
if (ids === 'all') {
await db.query(`
UPDATE notifications SET read_at = now()
WHERE user_id = $1 AND read_at IS NULL
`, [session.user.id]);
} else if (Array.isArray(ids) && ids.length > 0) {
await db.query(`
UPDATE notifications SET read_at = now()
WHERE user_id = $1 AND id = ANY($2) AND read_at IS NULL
`, [session.user.id, ids]);
}
return NextResponse.json({ success: true });
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const result = await db.query(`
SELECT count(*) AS count
FROM notifications
WHERE user_id = $1 AND read_at IS NULL AND archived_at IS NULL
`, [session.user.id]);
return NextResponse.json({
count: Math.min(parseInt(result.rows[0].count), 99),
});
}
Step 4: Notification Dispatch Service
import { db } from '@/db';
import { sendPushToUser } from './push-sender';
import { sendEmail } from './email-sender';
import Mustache from 'mustache';
interface NotifyOptions {
userId: string;
typeId: string;
variables: Record<string, string>;
data?: Record<string, unknown>;
imageUrl?: string;
idempotencyKey?: string;
}
export async function notify(options: NotifyOptions): Promise<string> {
const { userId, typeId, variables, data, imageUrl, idempotencyKey } = options;
const type = await db.query(
'SELECT * FROM notification_types WHERE id = $1',
[typeId]
);
if (type.rows. === ) ();
template = .[];
title = .(template., variables);
body = .(template., variables);
notification = db.(, [userId, typeId, title, body, data ?? {}, imageUrl, idempotencyKey]);
(notification.. === ) {
;
}
notificationId = notification.[].;
preferences = db.(, [userId, template.]);
prefMap = (preferences..( [p., p.]));
channels = template..(
prefMap.(ch) !==
);
( channel channels) {
{
(channel) {
:
(userId, {
title,
body,
: typeId,
: { : data?., notificationId },
});
;
:
({
userId,
: title,
body,
: ,
});
;
:
;
}
db.(, [notificationId, channel]);
} (error) {
db.(, [notificationId, channel, (error ).]);
}
}
notificationId;
}
({
: order.,
: ,
: {
: order.,
: order.,
},
: { : },
: ,
});
Step 5: User Preference Management
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const preferences = await db.query(`
SELECT np.category, np.channel, np.enabled
FROM notification_preferences np
WHERE np.user_id = $1
ORDER BY np.category, np.channel
`, [session.user.id]);
return NextResponse.json({ preferences: preferences.rows });
}
export async function PUT(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { category, channel, enabled } = await request.();
(category === && channel === && !enabled) {
.(
{ : },
{ : }
);
}
db.(, [session.., category, channel, enabled]);
.({ : });
}
Best Practices
- Always use idempotency keys to prevent duplicate notifications from retries
- Respect user preferences — check before every send, not just at subscription time
- Clean up expired push subscriptions (410/404 from push service) immediately
- Use notification tags to group and replace related notifications (e.g., "3 new messages" replaces individual ones)
- Batch digest notifications for non-urgent categories (hourly/daily email digests)
- Store all notifications server-side — do not rely solely on push delivery
- Add a "seen" state separate from "read" (seen = appeared in feed, read = clicked/opened)
- Rate-limit notifications per user per channel to prevent spam (e.g., max 5 push/hour)
- Use
requireInteraction: false for informational pushes, true only for action-required alerts
- Put the VAPID private key in environment variables, never in client code
Common Pitfalls
| Pitfall | Fix |
|---|
| Push subscription lost after browser update | Re-subscribe on every page load if subscription is null; store server-side |
| Service worker not updating | Use skipWaiting() + clients.claim() or version the SW file |
| Notifications sent to users who opted out | Always check notification_preferences before dispatch |
| Duplicate notifications on retry | Use idempotency keys on the notifications table |
| Push payload too large (>4KB) | Send minimal payload via push; fetch full content from API on click |
| No fallback when push fails | Use multi-channel delivery — if push fails, fall back to in-app or email |
| Notification permission prompt on page load | Never prompt immediately — show a custom UI first explaining value, then call Notification.requestPermission() |
Not handling notificationclick | Users tap notification and nothing happens — always implement the click handler in the service worker |
Examples
Firebase Cloud Messaging (FCM) via Admin SDK
import admin from 'firebase-admin';
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
}),
});
}
export async function sendFCM(
tokens: string[],
notification: { title: string; body: string; imageUrl?: string },
data?: Record<string, string>
) {
const message: admin.messaging.MulticastMessage = {
tokens,
notification: {
title: notification.title,
body: notification.body,
imageUrl: notification.imageUrl,
},
data,
android: {
priority: ,
: {
: ,
: ,
},
},
: {
: {
: {
: { : notification., : notification. },
: ,
: ,
},
},
},
: {
: { : },
: {
: ,
: ,
},
},
};
response = admin.().(message);
response..( {
(resp.?. === ) {
}
});
{
: response.,
: response.,
};
}
Notification Center React Component Pattern
'use client';
import { useState, useEffect, useCallback } from 'react';
interface Notification {
id: string;
title: string;
body: string;
readAt: string | null;
createdAt: string;
data: Record<string, unknown>;
}
export function NotificationBell() {
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const fetchCount = async () => {
const res = await fetch('/api/notifications/unread-count');
const data = await res.json();
setUnreadCount(data.count);
};
fetchCount();
interval = (fetchCount, );
(interval);
}, []);
openPanel = ( () => {
();
res = ();
data = res.();
(data.);
}, []);
markAllRead = ( () => {
(, {
: ,
: { : },
: .({ : }),
});
();
(
prev.( ({ ...n, : ().() }))
);
}, []);
(
);
}