SocialPro is our social network, chat, AI, ads and creator-monetisation platform. It is a Next.js 15 App Router application on MongoDB, and its messaging needs what every chat product needs: live messages, typing indicators, read receipts, presence, and signalling for voice and video calls. On serverless hosts, no process lives long enough to hold a WebSocket. This article explains how we added realtime without letting it leak into the rest of the app: a small Socket.io sidecar, two-minute socket tokens, room guards, and a fallback that keeps the app usable when there is no sidecar at all.
A sidecar that never writes business data
The realtime server is a separate process, realtime/server.ts, that runs next to Next.js (npm run dev:realtime, port 3061 locally). Browsers connect to it directly. Next.js pushes events to it over POST /internal/emit, authenticated by a shared secret, and its header comment sets the boundary: "The sidecar never writes business data itself." With REDIS_URL set, the Socket.io Redis adapter lets several instances run behind a load balancer.
Sending a message is still a server action in Next.js: it validates, stores the message and updates counters, then asks the sidecar to tell the right rooms. The sidecar handles only ephemeral things itself (typing, presence, SDP and ICE relay), because those have no durable state.
The contract between browser, sidecar and app (event names and the user:, conv: and call: room builders) lives in one client-safe file, types/realtime.ts.
Two-minute socket tokens
The sidecar has no access to the NextAuth session cookie, and we didn't want it to parse one. Instead, a signed-in browser asks Next.js for a short-lived token:
/** Mints a 2-minute socket token for the realtime sidecar (the browser re-fetches it on every reconnect). */
export const GET = userRoute(
null,
async (_req, viewer) => {
const secret = getConfig('REALTIME_SECRET');
const url = getConfig('REALTIME_PUBLIC_URL') || getConfig('REALTIME_URL');
if (!secret || !url) return NextResponse.json({ error: 'Realtime is not configured' }, { status: 503 });
const token = signSocketToken({ uid: viewer.id, sid: viewer.sid, name: viewer.name, handle: viewer.handle }, secret);
return NextResponse.json({ token, url }, { headers: { 'Cache-Control': 'no-store' } });
},
The token is a base64url JSON body plus an HMAC-SHA256 signature. The signature covers a rt: prefix, so a token can't be confused with anything else signed with the same secret, and verification uses timingSafeEqual:
export function signSocketToken(claims: Omit<SocketClaims, 'exp'>, secret: string, ttlSec = 120): string {
const body = Buffer.from(JSON.stringify({ ...claims, exp: Math.floor(Date.now() / 1000) + ttlSec } satisfies SocketClaims)).toString('base64url');
const sig = createHmac('sha256', secret).update(`rt:${body}`).digest('base64url');
return `${body}.${sig}`;
}
Two minutes is enough because the token is only checked at the handshake. On the client, Socket.io's auth option takes a callback, so every reconnect fetches a fresh token instead of replaying the first one:
const socket = io(first.url, {
transports: ['websocket', 'polling'],
reconnectionDelayMax: 10_000,
auth: (cb) => {
void fetchToken().then((t) => cb({ token: t?.token ?? '' }));
},
});
The sidecar checks the signature and then one more thing. Each token carries the device session id (sid), and the handshake middleware confirms that session is still live:
io.use(async (socket, next) => {
const token = typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : '';
const claims = verifySocketToken(token, SECRET);
if (!claims) return next(new Error('unauthorized'));
// A revoked device session can't open new sockets.
const live = await AuthSession.exists({ sid: claims.sid, revokedAt: null, expiresAt: { $gt: new Date() } }).catch(() => null);
if (!live) return next(new Error('unauthorized'));
socket.data.claims = claims;
next();
});
A device that was signed out can't open a new socket, even with a token minted a minute earlier.
Room guards: who may join conv:<id>
Every socket joins its own user:<id> room on connect. Conversation and call rooms are joined on request, and the sidecar asks one function whether that is allowed:
export async function canJoinRoom(userId: string, room: string): Promise<boolean> {
const [prefix, id] = room.split(':');
if (!prefix || !id || !/^[a-f0-9]{24}$/i.test(id)) return false;
const guard = ROOM_GUARDS[prefix];
return guard ? guard(userId, id).catch(() => false) : false;
}
It fails closed in three ways. A malformed room name is refused, an unknown prefix is refused, and a guard that throws counts as "no". Guards are registered by prefix from a side-effect module the sidecar imports, so messaging owns its own rules:
registerRoomGuard('conv', conversationRoomGuard);
registerRoomGuard('call', callRoomGuard);
The conversation guard is one indexed query, an active ConversationMember row (leftAt: null). The call guard only admits the caller or callee of a call that is still ringing or connected, so a room closes as soon as the call ends. The guards are kept free of the service graph, so the sidecar loads a couple of models rather than the whole application. The integration suite checks them against a real database:
expect(await canJoinRoom(b.id, `conv:${conversationId}`)).toBe(true);
expect(await canJoinRoom(stranger.id, `conv:${conversationId}`)).toBe(false);
expect(await canJoinRoom(a.id, 'conv:not-an-id')).toBe(false);
WebRTC signalling reuses the same check: before relaying an offer, answer or ICE candidate, the sidecar confirms that both sender and recipient pass the call guard. Typing events are only rebroadcast from sockets already in the conversation room.
Publishing is fire-and-forget
Next.js talks to the sidecar through one function with a two-second timeout:
/**
* Emits an event to socket rooms through the realtime sidecar. Fire-and-forget: a sidecar that is down
* never fails the request (clients fall back to polling). Returns whether the sidecar accepted it.
*/
export async function publish(rooms: string | string[], event: RealtimeEvent, data: unknown): Promise<boolean> {
const ep = endpoint();
const list = (Array.isArray(rooms) ? rooms : [rooms]).filter(Boolean);
if (!ep || !list.length) return false;
try {
const res = await fetch(`${ep.url}/internal/emit`, { /* x-realtime-secret header, JSON body */ signal: AbortSignal.timeout(TIMEOUT_MS) });
return res.ok;
} catch {
return false;
}
}
If REALTIME_URL or REALTIME_SECRET isn't set, publish returns false without making a request. The message is already stored by then, so a missing or crashed sidecar costs liveness, never data.
Two details in deliverMessage matter. First, the message:new payload is built for a neutral viewer (toMessageDTOs(null, [msg])), because the same payload goes to every member. For a pay-per-view message, the locked content is withheld from everyone in that payload, and each client fetches its own copy as that viewer. In the sender's tab, the client's merge() keeps the unlocked copy it already has when the neutral version comes in. Second, push and email notifications go only to members without a live socket:
const online = await onlineStatus(others);
const offline = others.filter((id) => !online[id]);
onlineStatus returns an empty map when the sidecar can't be reached, so everyone counts as offline and gets notified. When realtime is down, we fall back to sending more notifications, not fewer.
The fallback: slow polls behind live events
A serverless host such as Vercel has nowhere to run the sidecar, so the README states the rule: "Without the sidecar the app still works and falls back to polling." In the client, that means React Query plus useRealtimeEvent, as in the unread-messages badge:
const q = useQuery({
queryKey: UNREAD_MESSAGES_KEY,
enabled,
refetchInterval: 120_000,
queryFn: async () => {
const res = await fetch('/api/messages/unread');
return res.ok ? ((await res.json()) as { count: number }).count : 0;
},
});
const bump = () => void qc.invalidateQueries({ queryKey: UNREAD_MESSAGES_KEY });
useRealtimeEvent(REALTIME_EVENTS.messageNew, bump);
useRealtimeEvent(REALTIME_EVENTS.readReceipt, bump);
With a socket, the event invalidates the query at once; without one, the two-minute poll catches up. The notification bell works the same way, and the feed's "new posts" pill polls every 60 seconds and refetches early on feed:new. Components never branch on whether realtime is up. They subscribe, and if nothing arrives, the poll still runs.
Reconnects are the last piece. A dropped socket loses its rooms, so useJoinConversations re-emits the idempotent conv:join whenever connected flips back to true.
Trade-offs and what we're cautious about
- An open thread has no poll of its own. Badges and the feed catch up without a sidecar, but a conversation you are looking at only gets other people's messages live through the socket. Your own sends appear immediately as optimistic rows; incoming ones appear when you navigate or reload. For production chat, run the sidecar.
- Revocation takes effect on reconnect. The session check runs at the handshake, so a revoked device keeps any socket it already has until that socket drops.
- Presence is per instance. The
onlinemap lives in the sidecar's memory. The Redis adapter shares room broadcasts across instances, but not that map, so presence and the "is this member online" check are only exact on a single instance. - Fire-and-forget means no delivery guarantee. A
publishthat times out isn't retried. Polls and refetches are the safety net, which is acceptable for a badge or a typing dot. Durable state never depends on it.
Wrapping up
The design keeps realtime as an optional layer. The database stays the source of truth, Next.js decides who hears what, the sidecar relays, short tokens and room guards keep it honest, and polls keep the app usable when it isn't there.
SocialPro is available on our marketplace, and you can try the live demo. If you're building a product that needs realtime on top of Next.js, this is the kind of work our SaaS engineering team does.



