Realtime

intermediate*

Real-time pub/sub updates using Pusher. Includes React hooks for subscribing to channels and events.

realtimepusherwebsocketpubsubchannels
Tested on201619TS5.9
$ bunx sinew add infrastructure/realtime
Interactive demo coming soon

1The Problem

Building real-time features requires:

  • WebSocket connection management
  • Channel authorization
  • Presence tracking
  • Server-side event publishing

2The Solution

Use Pusher for hosted real-time messaging with React hooks for subscribing to channels and events.

3Files

lib/realtime/pusher-server.ts

lib/realtime/pusher-server.tsTypeScript
import Pusher from "pusher";

export const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  useTLS: true,
});

export const channels = {
  public: (name: string) => `public-${name}`,
  private: (name: string) => `private-${name}`,
  presence: (name: string) => `presence-${name}`,
  user: (userId: string) => `private-user-${userId}`,
};

export async function publish<T>(channel: string, event: string, data: T) {
  await pusher.trigger(channel, event, data);
}

export async function notifyUser<T>(userId: string, event: string, data: T) {
  await publish(channels.user(userId), event, data);
}

lib/realtime/pusher-client.ts

lib/realtime/pusher-client.tsTypeScript
import PusherClient from "pusher-js";

let pusherClient: PusherClient | null = null;

export function getPusherClient(): PusherClient {
  if (!pusherClient) {
    pusherClient = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
      authEndpoint: "/api/realtime/auth",
    });
  }
  return pusherClient;
}

export function subscribe(channelName: string) {
  return getPusherClient().subscribe(channelName);
}

export function unsubscribe(channelName: string) {
  getPusherClient().unsubscribe(channelName);
}

hooks/use-channel.ts

hooks/use-channel.tsTypeScript
"use client";

import { useEffect, useRef, useCallback } from "react";
import { Channel } from "pusher-js";
import { subscribe, unsubscribe } from "@/lib/realtime/pusher-client";

export function useChannel(channelName: string) {
  const channelRef = useRef<Channel | null>(null);

  useEffect(() => {
    const channel = subscribe(channelName);
    channelRef.current = channel;

    return () => {
      unsubscribe(channelName);
      channelRef.current = null;
    };
  }, [channelName]);

  const bind = useCallback(<T>(event: string, callback: (data: T) => void) => {
    channelRef.current?.bind(event, callback);
    return () => channelRef.current?.unbind(event, callback);
  }, []);

  return { bind, channel: channelRef.current };
}

hooks/use-event.ts

hooks/use-event.tsTypeScript
"use client";

import { useEffect, useRef } from "react";
import { useChannel } from "./use-channel";

export function useEvent<T>(channelName: string, eventName: string, callback: (data: T) => void) {
  const { bind } = useChannel(channelName);

  // Keep the latest callback in a ref so an inline function doesn't re-bind every render.
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  useEffect(() => {
    return bind<T>(eventName, (data) => callbackRef.current(data));
  }, [bind, eventName]);
}

app/api/realtime/auth/route.ts

app/api/realtime/auth/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import { pusher } from "@/lib/realtime/pusher-server";

export async function POST(req: NextRequest) {
  const formData = await req.formData();
  const socketId = formData.get("socket_id") as string;
  const channelName = formData.get("channel_name") as string;

  // TODO: load the authenticated user from your auth system.
  const user: { id: string; name: string } | null = null;

  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // TODO: authorize this user for the requested channel. Default-deny so a
  // missing check can't authorize every socket in production.
  const hasAccess = false; // await checkChannelAccess(user.id, channelName);
  if (!hasAccess) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  // For presence channels, include user data
  if (channelName.startsWith("presence-")) {
    const auth = pusher.authorizeChannel(socketId, channelName, {
      user_id: user.id,
      user_info: { name: user.name },
    });
    return NextResponse.json(auth);
  }

  // For private channels
  const auth = pusher.authorizeChannel(socketId, channelName);
  return NextResponse.json(auth);
}

4Dependencies

$ bun add pusher pusher-js

5Configuration

Environment Variables

| Variable | Description | Required | | ---------------------------- | ------------------- | -------- | | PUSHER_APP_ID | Pusher app ID | Yes | | PUSHER_SECRET | Pusher secret | Yes | | NEXT_PUBLIC_PUSHER_KEY | Pusher key (public) | Yes | | NEXT_PUBLIC_PUSHER_CLUSTER | Pusher cluster | Yes |

6Usage

Subscribe to Events

"use client";

import { useEvent } from "@/hooks/use-event";

export function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  useEvent<Message>(`presence-room-${roomId}`, "message", (message) => {
    setMessages((prev) => [...prev, message]);
  });

  return (
    <div>
      {messages.map((m) => (
        <p key={m.id}>{m.text}</p>
      ))}
    </div>
  );
}
TSX

Publish from Server

import { publish, channels } from "@/lib/realtime/pusher-server";

export async function sendMessage(roomId: string, message: Message) {
  await db.message.create({ data: message });
  await publish(channels.presence(roomId), "message", message);
}
TypeScript

7Alternatives

  • Pusher - Mature with presence channels
  • Ably - Higher free tier limits
  • Supabase Realtime - Database change streams
  • PartyKit - Edge-native, great for collaboration

Related patterns