Build a Real-Time Notification System with Next.js, Express.js, and Socket.IO
Next.js (TypeScript) frontend, Express (TypeScript) backend আর Socket.IO v4 দিয়ে user-specific real-time notification system — bell, unread count, mark-as-read ও reconnection সহ সম্পূর্ণ Bangla project গাইড।
এখন পর্যন্ত শেখা ধারণাগুলো — rooms, namespaces, authentication middleware, reconnection আর typed event contract — একসাথে কাজে লাগিয়ে একটি real-time notification system বানাই। Frontend হবে Next.js (TypeScript), backend Express (TypeScript), আর real-time delivery-এর জন্য Socket.IO v4।
লক্ষ্য শুধু একটা bell icon নয় — বরং সঠিক architecture: প্রতিটি notification শুধু তার নিজের user-এর কাছে যাবে, identity server-এ যাচাই হবে, আর client-এর পাঠানো user id কখনো বিশ্বাস করা হবে না।
শেখার লক্ষ্য
- Next.js client-কে Express + Socket.IO server-এর সাথে যুক্ত করা
- user-specific room দিয়ে private notification সঠিক user-এর কাছে পাঠানো
- handshake auth middleware দিয়ে identity যাচাই করা
- notification bell, unread badge আর panel বানানো
- mark-as-read / mark-all-read implement করা
- reconnection-এ সংরক্ষিত notification আনা ও state sync রাখা
- client-এর ডেটা validate করা এবং সব ডেটা নিরাপদে render করা
এটি শুধু একটি শেখার example — Tech Tutor ওয়েবসাইটে app হিসেবে যোগ করা হয়নি। এখানে code পড়ে নিজের project-এ ব্যবহার করবেন (তুলনায় দেখুন Real-Time Chat Application ও Next.js + Express Chat App)।
Project Overview
আমাদের notification system-এর বারোটি target:
| # | Feature |
|---|---|
| 1 | Next.js client → Express Socket.IO server-এ connect |
| 2 | Connection status display |
| 3 | Authenticated identity (verified user) |
| 4 | Real-time notification পাওয়া |
| 5 | Title, body, timestamp ও read state display |
| 6 | Online/unread badge ও live count |
| 7 | Notification-এ mark-as-read |
| 8 | সব notification mark-all-read |
| 9 | Simulated order status update → notification |
| 10 | Reconnection ও missed notification retrieval |
| 11 | Validation, authorization ও safe rendering |
| 12 | Responsive, accessible panel UI |
Architecture
দুইটি আলাদা process চলে — Next.js (port 3000) আর Express + Socket.IO (port 4000)। Browser একটি token নিয়ে Socket.IO server-এ connect করে; server token verify করে userId বের করে এবং একটি user-specific room-এ socket-কে যোগ করে। এরপর যেকোনো notification শুধু সেই room-এ emit হয় — কখনো সব client-এ নয়।
Browser (Next.js UI)
useNotifications ──io(auth.token)──► Express + Socket.IO (port 4000)
▲ │
│ io.use(verifyToken) → user:<id> room
│ │
│ notification:new ◄────────────────┤ emitNotification(userId, n)
│ │
│ notification:fetch (ack: list) ───►│ in-memory store (Map)
│ │
Order service ──POST /api/orders/:id/status──► createNotification() → emitEvent flow:
- Client handshake-এ
auth.tokenপাঠায় → serververifyTokenদিয়ে userId নেয় ওuser:<id>room-এ join করায় - কোনো order status বদলালে server
createNotification()করেnotification:newকেবল সেই user-এর room-এ পাঠায় - Client
notification:mark-readপাঠায় → server ownership যাচাই করেnotification:reademit করে - Client
notification:fetchপাঠায় → server-এর সংরক্ষিত list ack করে (reconnect-এ missed পাওয়ার জন্য) - Disconnect হলে কিছুই broadcast হয় না — queue/DB না থাকলে delivery হারাতে পারে
Prerequisites
- Node.js 20+ ও npm
- TypeScript-এর মৌলিক ধারণা (Socket.IO with TypeScript)
- React hooks (
useState,useEffect,useRef,useCallback) সম্পর্কে ধারণা - Rooms, authentication ও middleware সম্পর্কে ধারণা (Rooms, Authentication, Middleware)
Folder Structure
আগের project-এর মতোই একটি ছোট monorepo — root-এ npm workspaces, তিনটি package: shared, server, client। shared শুধু TypeScript type ধরে, যাতে server ও client একই event contract ব্যবহার করে।
socketio-notify-app/
├── package.json # workspaces: shared, server, client
├── shared/
│ ├── package.json
│ └── src/events.ts # shared event + data types
├── server/
│ ├── .env
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── config.ts
│ ├── auth.ts # token verify (teaching-only)
│ ├── store.ts # in-memory notification store
│ ├── socket.ts # Socket.IO + auth + rooms
│ └── index.ts # Express routes + http server
└── client/ # create-next-app দিয়ে তৈরি
├── .env.local
├── app/
│ └── page.tsx
├── components/
│ ├── notification-bell.tsx
│ └── order-simulator.tsx
└── hooks/
└── use-notifications.tsStep 1 — Project Init
Root-এ workspace বানান, তারপর তিনটি package install করুন।
mkdir socketio-notify-app
cd socketio-notify-app
# shared workspace
mkdir shared/src
# server workspace
mkdir server/src
# Next.js client
npx create-next-app@latest client --ts --tailwind --eslint --app --no-src-dir --import-alias "@/*" --use-npm
# root-এ সব install (workspaces)
npm install{
"name": "socketio-notify-app",
"private": true,
"workspaces": ["shared", "server", "client"]
}তারপর server ও client-এর dependency যোগ করুন। @notify/shared type-only, তাই এতে runtime dependency লাগে না।
# Express + Socket.IO server
npm install -w server express socket.io cors dotenv
npm install -w server -D typescript tsx @types/express @types/cors @types/node
# Next.js-এ Socket.IO client
npm install -w client socket.io-client{
"name": "@notify/shared",
"version": "1.0.0",
"private": true,
"types": "./src/events.ts",
"main": "./src/events.ts"
}Step 2 — Shared Event Contract
shared/src/events.ts
সবচেয়ে গুরুত্বপূর্ণ ভিত্তি হলো event ও type-এর single source of truth। server ও client একই events.ts import करায় event নাম বা payload কখনো mismatch হবে না।
// shared/src/events.ts
export interface AppNotification {
id: string;
userId: string;
title: string;
body: string;
read: boolean;
createdAt: number;
}
export interface AckResult {
ok: boolean;
error?: string;
}
/** server থেকে client-এ যাওয়া event */
export interface ServerToClientEvents {
"notification:new": (notification: AppNotification) => void;
"notification:read": (id: string) => void;
"notification:all-read": () => void;
}
/** client থেকে server-এ যাওয়া event */
export interface ClientToServerEvents {
"notification:fetch": (
since: number,
ack: (list: AppNotification[]) => void,
) => void;
"notification:mark-read": (
id: string,
ack: (result: AckResult) => void,
) => void;
"notification:mark-all-read": (ack: (result: AckResult) => void) => void;
}
/** প্রতিটি socket-এ রাখা server-side verified data */
export interface SocketData {
userId?: string;
username?: string;
}
export type InterServerEvents = Record<string, never>;server/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}server/package.json
{
"name": "server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
}
}Step 3 — Express Backend
Express app আর node:http server তৈরি করি। Socket.IO ঠিক এই একই HTTP server-এর উপরে বসবে, তাই একই port-এ REST + realtime দুটোই চলবে (Socket.IO with Express.js)।
server/.env
# server/.env
PORT=4000
CLIENT_ORIGIN=http://localhost:3000server/src/config.ts
// server/src/config.ts
import "dotenv/config";
export const PORT = Number(process.env.PORT ?? 4000);
export const CLIENT_ORIGIN =
process.env.CLIENT_ORIGIN ?? "http://localhost:3000";Step 4 — Auth Middleware (identity যাচাই)
এই project-এর মূল নিরাপত্তা এখানেই। client কখনো userId পাঠাবে না — server handshake-এ token verify করে নিজে userId বের করবে। নিচের verifier শুধু শেখানোর জন্য (কোনো আসল secret নেই); production-এ JWT/session verify ব্যবহার করবেন (Authentication)।
server/src/auth.ts
// server/src/auth.ts
export interface AuthUser {
id: string;
username: string;
}
// ⚠️ teaching-only: token → user map (কোনো আসল secret নেই)।
// production-এ jsonwebtoken/session (httpOnly cookie) দিয়ে verify করুন।
const SESSIONS = new Map<string, AuthUser>([
["token-demo-1", { id: "user-1", username: "Ayesha" }],
["token-demo-2", { id: "user-2", username: "Rahim" }],
]);
export function verifyToken(token: string): AuthUser | null {
if (!token) return null;
return SESSIONS.get(token) ?? null;
}client থেকে পাঠানো কোনো userId বা room নাম কখনো সরাসরি ব্যবহার করবেন না। সবসময় verified token/session থেকে server-এ identity নিন — নাহলে একজন user অন্যের notification পড়তে পারবে।
Step 5 — In-Memory Notification Store
প্রথমে একটি সরল store — Map<userId, AppNotification[]>। এখানে read state-ও থাকে, তাই mark-read server-এ যাচাই করা যায়।
server/src/store.ts
// server/src/store.ts
import { randomUUID } from "node:crypto";
import type { AppNotification } from "@notify/shared";
// ⚠️ in-memory only: server restart/crash-এ সব notification হারিয়ে যায়।
// production-এ এগুলো database-এ রাখতে হবে (নিচে দেখুন)।
const byUser = new Map<string, AppNotification[]>();
export function listForUser(userId: string): AppNotification[] {
return byUser.get(userId) ?? [];
}
export function createNotification(input: {
userId: string;
title: string;
body: string;
}): AppNotification {
const notification: AppNotification = {
id: crypto.randomUUID(),
userId: input.userId,
title: String(input.title).trim().slice(0, 120),
body: String(input.body).trim().slice(0, 500),
read: false,
createdAt: Date.now(),
};
const list = byUser.get(notification.userId) ?? [];
list.unshift(notification);
if (list.length > 100) list.length = 100; // memory সীমিত রাখুন
byUser.set(notification.userId, list);
return notification;
}
/** read flag আপডেট করে; সফল হলে true ফেরায়। */
export function markRead(userId: string, id: string): boolean {
const found = (byUser.get(userId) ?? []).find((n) => n.id === id);
if (!found) return false;
found.read = true;
return true;
}
export function markAllRead(userId: string): void {
for (const n of byUser.get(userId) ?? []) n.read = true;
}Step 5.1 — CORS (explicit origin)
Socket.IO v3 থেকে CORS ডিফল্টভাবে বন্ধ। client আর server আলাদা origin-এ (:3000 বনাম :4000) চলায় নির্দিষ্ট origin দিতে হবে — "*" নয়। বিস্তারিত CORS Configuration-এ।
Step 6 — Socket.IO Server, Auth ও User-Specific Rooms
এখন মূল server logic। io.use() middleware-এ token verify হয়; সফল হলে socket-কে শুধু তার নিজের user:<id> room-এ join করানো হয়। room নাম কখনো client থেকে নেওয়া হয় না — verified userId থেকে server নিজেই বানায়।
server/src/socket.ts
// server/src/socket.ts
import type http from "node:http";
import { Server } from "socket.io";
import type {
AppNotification,
ClientToServerEvents,
InterServerEvents,
ServerToClientEvents,
SocketData,
} from "@notify/shared";
import { CLIENT_ORIGIN } from "./config";
import { verifyToken } from "./auth";
import { listForUser, markAllRead, markRead } from "./store";
type NotifyServer = Server<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>;
// শুধু server-এর বানানো room নাম — client এটা দিতে পারে না
const userRoom = (userId: string) => `user:${userId}`;
let ioRef: NotifyServer | null = null;
export function createSocketServer(httpServer: http.Server): NotifyServer {
const io: NotifyServer = new Server(httpServer, {
cors: { origin: CLIENT_ORIGIN, methods: ["GET", "POST"] },
});
ioRef = io;
// handshake auth — identity server-এ verify হয়, client-এর দাবি নয়
io.use((socket, next) => {
const token = socket.handshake.auth?.token;
const user = verifyToken(String(token ?? ""));
if (!user) return next(new Error("unauthorized"));
socket.data.userId = user.id;
socket.data.username = user.username;
next();
});
io.on("connection", (socket) => {
const userId = socket.data.userId;
if (!userId) return; // auth middleware ছাড়া এখানে আসা উচিত নয়
// verified identity থেকে room-এ join
socket.join(userRoom(userId));
// connect/reconnect-এ সংরক্ষিত notification আনুন
socket.on("notification:fetch", (since, ack) => {
const list = listForUser(userId).filter((n) => n.createdAt > since);
ack(list);
});
socket.on("notification:mark-read", (id, ack) => {
const clean = String(id ?? "").slice(0, 64);
// ownership সর্বদা server-এর verified userId দিয়ে যাচাই
const ok = markRead(userId, clean);
if (ok) io.to(userRoom(userId)).emit("notification:read", clean);
ack(ok ? { ok: true } : { ok: false, error: "not found" });
});
socket.on("notification:mark-all-read", (ack) => {
markAllRead(userId);
io.to(userRoom(userId)).emit("notification:all-read");
ack({ ok: true });
});
});
return io;
}
/** নির্দিষ্ট user-এর room-এ notification emit — কখনো io.emit (broadcast) নয়। */
export function emitNotification(
userId: string,
notification: AppNotification,
): void {
ioRef?.to(userRoom(userId)).emit("notification:new", notification);
}Line-by-line:
io.use(...)— handshake-এ একবার identity যাচাই; ব্যর্থ হলেnext(new Error(...)), connection-ই হবে না।socket.join(userRoom(userId))— room নাম server তৈরি করে verified userId থেকে; client এর উপর কোনো নিয়ন্ত্রণ নেই।io.to(userRoom(userId)).emit(...)— private delivery;io.emit(...)করলে সবার কাছে চলে যেত।markRead(userId, id)— read operation-এ userId দিয়ে authorization বাধ্যতামূলক।
Step 7 — Express Routes ও Simulated Order Update
Express-এ health route, একটি authorization-protected notification list, আর একটি সিমুলেটেড order status route — কোনোটিতেই client-এর পাঠানো userId বিশ্বাস করা হয় না; Bearer token থেকেই user বের করা হয়।
server/src/index.ts
// server/src/index.ts
import http from "node:http";
import express from "express";
import cors from "cors";
import { CLIENT_ORIGIN, PORT } from "./config";
import { verifyToken } from "./auth";
import { createNotification, listForUser } from "./store";
import { createSocketServer, emitNotification } from "./socket";
const app = express();
app.use(cors({ origin: CLIENT_ORIGIN }));
app.use(express.json());
// Bearer token → verified user. client-এর পাঠানো userId কখনো বিশ্বাস করবেন না।
function readUser(req: express.Request) {
const header = req.header("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
return verifyToken(token);
}
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
// authorization: শুধু নিজের notification ফেরত দেয়
app.get("/api/notifications", (req, res) => {
const user = readUser(req);
if (!user) return res.status(401).json({ error: "unauthorized" });
res.json(listForUser(user.id));
});
// ⚠️ সিমুলেটেড order update — বাইরের payment/order API লাগে না।
app.post("/api/orders/:orderId/status", (req, res) => {
const user = readUser(req);
if (!user) return res.status(401).json({ error: "unauthorized" });
const status = String(req.body?.status ?? "").trim();
const allowed = ["confirmed", "shipped", "delivered"];
if (!allowed.includes(status)) {
return res.status(400).json({ error: "invalid status" });
}
const orderId = String(req.params.orderId).slice(0, 40);
const notification = createNotification({
userId: user.id,
title: "Order status আপডেট",
body: `আপনার order ${orderId} এখন ${status}।`,
});
// প্রাপক কখনো request body থেকে নয় — verified user থেকেই
emitNotification(user.id, notification);
res.json({ ok: true, notification });
});
// Express app-কে একটি http.Server-এ মুড়ে দিন — Socket.IO এখানেই বসবে
const httpServer = http.createServer(app);
createSocketServer(httpServer);
httpServer.listen(PORT, () => {
console.log(`Server চলছে: http://localhost:${PORT}`);
});Socket.IO যোগ করার পর app.listen(PORT) ব্যবহার করবেন না — httpServer.listen(PORT) করুন। নাহলে Socket.IO কখনো bind হবে না (Express Integration)।
Step 8 — Next.js Client Setup
Socket.IO URL ও demo token environment variable থেকে নেওয়া হবে — কোনো production URL hardcode নয়।
client/.env.local
# client/.env.local
NEXT_PUBLIC_SOCKET_URL=http://localhost:4000
NEXT_PUBLIC_DEMO_TOKEN=token-demo-1এখানে token-demo-1 একটি বানানো (non-secret) token। প্রকৃত authentication token NEXT_PUBLIC_* variable-এ রাখবেন না — সেটি browser bundle-এ চলে যায়। production-এ প্রশিক্ষণযোগ্য session/token httpOnly cookie বা server-side route থেকে নিন।
Step 9 — useNotifications Hook (state ও lifecycle)
এই hook-ই socket তৈরি, listener যোগ, notification state, unread count ও cleanup করে। socket কেবল useEffect-এর ভিতরে তৈরি হয় — render-এর সময় নয়।
client/hooks/use-notifications.ts
// client/hooks/use-notifications.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { io, type Socket } from "socket.io-client";
import type {
AppNotification,
ClientToServerEvents,
ServerToClientEvents,
} from "@notify/shared";
type NotifySocket = Socket<ServerToClientEvents, ClientToServerEvents>;
type Status = "connecting" | "connected" | "disconnected";
const SOCKET_URL =
process.env.NEXT_PUBLIC_SOCKET_URL ?? "http://localhost:4000";
export function useNotifications(token: string) {
const socketRef = useRef<NotifySocket | null>(null);
const [status, setStatus] = useState<Status>("connecting");
const [items, setItems] = useState<AppNotification[]>([]);
useEffect(() => {
if (!token) return;
// socket কেবল effect-এ — render-এ নয় (duplicate connection এড়াতে)
const socket: NotifySocket = io(SOCKET_URL, { auth: { token } });
socketRef.current = socket;
// id দিয়ে merge — duplicate আসলেও একবারই থাকবে
const merge = (list: AppNotification[]) => {
setItems((prev) => {
const map = new Map(list.map((n) => [n.id, n]));
for (const n of prev) if (!map.has(n.id)) map.set(n.id, n);
return [...map.values()].sort((a, b) => b.createdAt - a.createdAt);
});
};
const onConnect = () => {
setStatus("connected");
// connect বা reconnect — সংরক্ষিত notification নিয়ে আসুন
socket.emit("notification:fetch", 0, merge);
};
const onDisconnect = () => setStatus("disconnected");
const onError = () => setStatus("disconnected");
const onNew = (n: AppNotification) => merge([n]);
const onRead = (id: string) =>
setItems((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
);
const onAllRead = () =>
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
socket.on("connect", onConnect);
socket.on("disconnect", onDisconnect);
socket.on("connect_error", onError);
socket.on("notification:new", onNew);
socket.on("notification:read", onRead);
socket.on("notification:all-read", onAllRead);
// cleanup — listener সরান ও connection বন্ধ করুন (StrictMode/routing)
return () => {
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
socket.off("connect_error", onError);
socket.off("notification:new", onNew);
socket.off("notification:read", onRead);
socket.off("notification:all-read", onAllRead);
socket.disconnect();
};
}, [token]);
const unread = items.filter((n) => !n.read).length;
const markRead = useCallback((id: string) => {
socketRef.current?.emit("notification:mark-read", id, () => {});
}, []);
const markAllRead = useCallback(() => {
socketRef.current?.emit("notification:mark-all-read", () => {});
}, []);
return { status, items, unread, markRead, markAllRead };
}Step 10 — Notification Bell, Unread Badge ও Panel
Bell-এ unread badge দেখানো হয়; panel-এ title, body, timestamp ও read state। সব text {n.title} আকারে render হয় — React নিজেই escape করে, তাই কখনো dangerouslySetInnerHTML ব্যবহার করবেন না। Escape চাপলে ও বাইরে click করলে panel বন্ধ হয় (accessible)।
client/components/notification-bell.tsx
// client/components/notification-bell.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useNotifications } from "@/hooks/use-notifications";
export function NotificationBell({ token }: { token: string }) {
const { status, items, unread, markRead, markAllRead } =
useNotifications(token);
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
// Escape চাপলে বন্ধ — cleanup সহ
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<div className="relative">
<button
type="button"
aria-label={"Notifications"}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((v) => !v)}
className="relative rounded-full border p-2"
>
<span aria-hidden>bell</span>
{unread > 0 ? (
<span
aria-label={`${unread} unread`}
className="absolute -right-1 -top-1 rounded-full bg-red-600 px-1.5 text-xs text-white"
>
{unread > 99 ? "99+" : unread}
</span>
) : null}
</button>
{open ? (
<div
ref={panelRef}
role="dialog"
aria-label="Notifications"
className="absolute right-0 mt-2 w-80 max-w-[90vw] rounded-xl border bg-white p-0 shadow-lg"
>
<header className="flex items-center justify-between border-b p-3">
<span className="font-medium">Notifications</span>
<button
type="button"
onClick={markAllRead}
disabled={items.every((n) => n.read)}
className="text-sm text-blue-600 disabled:opacity-40"
>
Mark all read
</button>
</header>
<ul className="max-h-96 overflow-y-auto">
{items.length === 0 ? (
<li className="p-4 text-sm text-gray-500">
কোনো notification নেই
</li>
) : (
items.map((n) => (
<li
key={n.id}
className={n.read ? "opacity-60" : "bg-blue-50"}
>
<button
type="button"
onClick={() => markRead(n.id)}
disabled={n.read}
className="block w-full p-3 text-left"
>
<span className="block font-medium">{n.title}</span>
<span className="block text-sm text-gray-600">
{n.body}
</span>
<time className="block text-xs text-gray-400">
{new Date(n.createdAt).toLocaleString()}
</time>
</button>
</li>
))
)}
</ul>
<footer className="border-t p-2 text-xs text-gray-500">
{status === "connected" ? "Live" : "Offline"}
</footer>
</div>
) : null}
</div>
);
}Step 10.1 — Order Simulator (client trigger)
শুধু demo-র convenience: একটা button চাপলে server-এর simulated order route-এ request যায়, server notification তৈরি করে user room-এ emit করে, আর bell badge সাথে সাথে বাড়ে।
client/components/order-simulator.tsx
// client/components/order-simulator.tsx
"use client";
import { useState } from "react";
const API_URL = process.env.NEXT_PUBLIC_SOCKET_URL ?? "http://localhost:4000";
export function OrderSimulator({ token }: { token: string }) {
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function simulate(status: string) {
setBusy(true);
setMessage(null);
try {
const res = await fetch(`${API_URL}/api/orders/demo-order/status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status }),
});
setMessage(res.ok ? "Order update পাঠানো হয়েছে" : "অনুরোধ ব্যর্থ");
} catch {
setMessage("Server-এ পৌঁছানো যায়নি");
} finally {
setBusy(false);
}
}
return (
<div className="mt-6 flex items-center gap-3">
<button
type="button"
onClick={() => simulate("shipped")}
disabled={busy}
className="rounded-lg bg-blue-600 px-3 py-2 text-sm text-white disabled:opacity-50"
>
Simulate order shipped
</button>
{message ? <span className="text-sm">{message}</span> : null}
</div>
);
}Step 11 — Page
client/app/page.tsx
// client/app/page.tsx
import { NotificationBell } from "@/components/notification-bell";
import { OrderSimulator } from "@/components/order-simulator";
const DEMO_TOKEN = process.env.NEXT_PUBLIC_DEMO_TOKEN ?? "token-demo-1";
export default function Home() {
return (
<main className="min-h-screen bg-slate-950 p-8 text-slate-100">
<div className="mx-auto flex max-w-2xl items-center justify-between">
<h1 className="text-xl font-semibold">Notification Demo</h1>
<NotificationBell token={DEMO_TOKEN} />
</div>
<div className="mx-auto max-w-2xl">
<OrderSimulator token={DEMO_TOKEN} />
</div>
</main>
);
}Step 12 — Reconnection ও Missed Notifications
React-এ socket নিয়ে দুটি বড় ভুল এড়াতে হবে:
- render-এ socket তৈরি — component body-তে
io()কল করলে প্রতিটি render-এ নতুন connection খোলে। সমাধান:useEffect(token deps)। - cleanup না করা — route বদলালে listener ও connection জমে leak হয়।
useEffect-এর return-এsocket.off(...)ওsocket.disconnect()দিন।
Socket.IO by default reconnect করে, আর প্রতিবার connect event আবার চলে — তাই onConnect-এর notification:fetch আবার চলে এবং missed notification ফিরে আসে।
client/hooks/use-notifications.ts (reconnect অংশ)
// connect বা reconnect দুইবারই চলে
const onConnect = () => {
setStatus("connected");
socket.emit("notification:fetch", 0, merge); // missed সহ আনুন
};// server — connection state recovery (optional)
const io = new Server(httpServer, {
cors: { origin: CLIENT_ORIGIN },
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
},
});Socket.IO শুধু live delivery নিশ্চিত করে — message broker নয়। server restart হলে in-memory list হারায়, তাই reconnect-এ ফেরানো সম্ভব হবে না। স্থায়ী history দরকার হলে notification অবশ্যই database-এ লিখুন (নিচে দেখুন)। বিস্তারিত Connection State Recovery ও Reconnection & Error Handling-এ।
Step 13 — Run and Test
server চালু করুন
# terminal 1 — backend (port 4000)
npm run dev -w server
# output: Server চলছে: http://localhost:4000client চালু করুন
# terminal 2 — frontend (port 3000)
npm run dev -w clientতারপর browser-এ http://localhost:3000 খুলুন।
Testing the Complete Application
- Connect — page খুললে footer-এ
Liveদেখুন; bell-এ badge0 - Trigger —
Simulate order shippedচাপুন; badge1হবে, panel-এ নতুন notification আসবে - Read — notification-এ ক্লিক করুন; read হয়ে badge কমবে (একাধিক tab থাকলে সবগুলোতেই sync হবে)
- Mark all —
Mark all readচাপুন; সব read হবে আর badge0 - Isolation — দ্বিতীয় tab-এ
NEXT_PUBLIC_DEMO_TOKEN=token-demo-2ব্যবহার করুন; একজনের notification অন্যজন দেখবে না - Reconnection — server বন্ধ করলে footer
Offlineহবে; আবার চালু করলে reconnect হয়ে list ফিরে আসবে (in-memory সীমায়) - Auth — ভুল token দিলে connect ব্যর্থ হবে (
connect_error) - XSS — server body-তে
<b>x</b>পাঠালে এটি text হিসেবেই দেখাবে
Next.js dev mode-এ React StrictMode effect দুইবার চালায় — তাই cleanup ঠিকভাবে লেখা জরুরি, নাহলে duplicate listener হবে। client component-এ অবশ্যই "use client" দিন ও socket কেবল effect-এ তৈরি করুন।
Troubleshooting
| সমস্যা | কারণ ও সমাধান |
|---|---|
connect_error / unauthorized | auth.token পাঠানো হচ্ছে কি? token verifyToken-এ আছে কি? |
connect_error / CORS | server-এর cors.origin-এ client origin (http://localhost:3000) দিন; \"*\" নয় |
| Connection refused | server চালু আছে কি? http://localhost:4000/health খুলে দেখুন |
| Notification এসে দুবার দেখাচ্ছে | duplicate listener বা merge ছাড়া append — merge id দিয়ে dedupe করে |
| অন্য user-এর notification দেখা যাচ্ছে | কখনো io.emit ব্যবহার করা হয়েছে? সব emit io.to(userRoom).emit হতে হবে |
NEXT_PUBLIC_SOCKET_URL কাজ করছে না | .env.local বানানোর পর dev server restart করুন |
| Badge কমছে না | mark-read event সঠিক id পাঠাচ্ছে কি ও server emit করছে কি দেখুন |
| Type mismatch | server ও client একই @notify/shared type import করছে কি না দেখুন |
| Server restart-এ সব গায়েব | in-memory store; নিচের production অংশ দেখুন |
Production Considerations (Data Persistence)
এই teaching example আর production-এর মধ্যে বড় পার্থক্য:
- In-memory notification server restart/crash-এ মুছে যায় — বর্তমান example-এ কোনো database নেই, তাই notification স্থায়ীভাবে সংরক্ষিত নয়।
- Socket.IO delivery একা durable notification history দেয় না — offline থাকাকালীন notification হারাতে পারে।
- production-এ notification ও তার read status database-এ রাখুন, এবং connect/reconnect-এ সেই table থেকে আনুন।
Database schema (উদাহরণ — PostgreSQL)
-- notifications টেবিল: notification ও read status দুটোই
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_notifications_user_created
ON notifications (user_id, created_at DESC);server/src/store.ts (database-backed version, conceptual)
// production-এ in-memory Map বাদ দিয়ে এই ধরনের query ব্যবহার করুন
export async function listForUser(userId: string) {
const rows = await db.query(
"SELECT * FROM notifications WHERE user_id = $1 " +
"ORDER BY created_at DESC LIMIT 100",
[userId],
);
return rows;
}
// reconnect-এর পর missed notification আনতে
// WHERE user_id = $1 AND created_at > $2
// authorization: প্রতিটি query-তে দর্শকের userId server-এর verified identity থেকেএকাধিক server instance চললে প্রতিটির room আলাদা — তাই io.to(user:<id>) সব node-এ পৌঁছাবে না। এর জন্য shared adapter (যেমন @socket.io/redis-adapter) লাগবে, আর notification store-ও common DB/Redis-এ রাখতে হবে (Best Practices)।
server/src/socket.ts (Redis adapter — production)
// একাধিক instance-এ room/emit ভাগ করতে shared adapter
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
io.adapter(createAdapter(pub, sub));Security
- কখনো broadcast নয় — private notification সবসময়
io.to(userRoom(userId)).emit(...); কখনোio.emit(...)নয়। - room নাম client থেকে নয় — join শুধু server-side, verified userId থেকে তৈরি room-এ।
- read operation authorize করুন —
markRead/markAllReadসর্বদা verifieduserId-এর মধ্যে সীমাবদ্ধ; অন্য user-এর id দিলে "not found"। - client-এর পাঠানো userId বিশ্বাস করবেন না — handshake token/server session থেকেই identity নিন (Authentication)।
- token log করবেন না —
console.log(token)বা error-এ token print করবেন না। - Input validation — title/body length-limit ও trim করুন (এই store তাই করে)।
- Rate limiting — spam notification ঠেকাতে throttle (Middleware)।
- HTTPS/WSS — production-এ ট্রাফিক encrypt করুন (CORS Configuration)।
- Safe rendering —
{n.title}/{n.body}হিসেবে দেখান;dangerouslySetInnerHTMLএড়িয়ে চলুন।
Limitations
in-memory state:
- notification Map, read flag server process-এর memory-তে
- server restart / crash → সব হারায় (durable নয়)
- একাধিক node → room ও state প্রতিটিতে আলাদা
- Socket.IO delivery live-ই নিশ্চিত করে, history নয়
- production-এ DB + Redis adapter + auth verify লাগবেএই example-এ কোনো database নেই — server notification সংরক্ষণ করে না। তাই প্রথম connect-এ আগের notification মিলতে পারে (একই process চললে), কিন্তু server restart বা page reload-এ স্থায়ীভাবে হারাবে।
Practice Task
- project-টি উপরের সব file দিয়ে সম্পূর্ণভাবে locally চালান
- notification-এ
type(order, mention, system) যোগ করে icon/রং আলাদা করুন mark-unreadimplement করুন — read flag আবার false করার authorization সহ- SQLite/Postgres দিয়ে notification persist করুন; reconnect-এ DB থেকে আনুন
- handshake
auth-এ আসল JWT verify দিয়েverifyTokenপ্রতিস্থাপন করুন - user প্রতি unread count একটি আলাদা
notification:countevent দিয়ে sync করুন
সমাপ্তি
এই project-এ আপনি একটি সম্পূর্ণ full-stack real-time notification system বানিয়েছেন — handshake auth, user-specific rooms, live delivery, unread count, mark-as-read, reconnection-এ missed retrieval, validation ও safe rendering সহ। এখন এই ভিত্তি দিয়ে activity feed, alerts বা live dashboard বানাতে পারেন।
সম্পর্কিত অধ্যায়: Real-Time Chat Application · Next.js + Express Chat App · Rooms · Authentication · Broadcasting · Best Practices