Pusher Channels is a hosted pub/sub WebSocket service — server-side: new PusherServer({ appId, key, secret, cluster }) creates the publisher. pusher.trigger(channel, event, data) publishes an event. pusher.triggerBatch([{ channel, name, data }]) publishes up to 10 events at once. Client-side: new PusherClient(key, { cluster }) connects. pusher.subscribe(channelName) returns a channel. channel.bind("new-message", handler) registers a listener. Private channels prefix with private- and require server-side auth. Presence channels prefix with presence- and track connected members with members.each(member => ...). Auth endpoint: pusher.authorizeChannel(socketId, channelName, { user_id, user_info }) returns the auth signature. channel.trigger("client-typing", {}) sends client events (P2P, no server). Claude Code generates Pusher realtime chat, live cursors, typing indicators, presence member lists, and private channel auth.
CLAUDE.md for Pusher
## Pusher Stack
- Version: pusher >= 5.2 (server), pusher-js >= 8.4 (client)
- Server init: const pusher = new PusherServer({ appId: PUSHER_APP_ID, key: PUSHER_KEY, secret: PUSHER_SECRET, cluster: PUSHER_CLUSTER, useTLS: true })
- Trigger: await pusher.trigger("channel-name", "event-name", { data })
- Auth endpoint: POST /api/pusher/auth — pusher.authorizeChannel(socket_id, channel_name, { user_id, user_info })
- Client: const pusherClient = new PusherClient(NEXT_PUBLIC_PUSHER_KEY, { cluster, channelAuthorization: { endpoint: "/api/pusher/auth", transport: "ajax" } })
- Subscribe: const channel = pusherClient.subscribe("private-room-${roomId}")
- Bind: channel.bind("new-message", (data) => setMessages(prev => [...prev, data]))
Pusher Server Client
// lib/realtime/pusher.ts — server-side Pusher utilities
import PusherServer from "pusher"
import PusherClient from "pusher-js"
export const pusherServer = new PusherServer({
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,
})
// Channel name helpers — consistent naming
export const channels = {
room: (roomId: string) => `private-room-${roomId}`,
presence: (roomId: string) => `presence-room-${roomId}`,
user: (userId: string) => `private-user-${userId}`,
global: "public-global",
}
// Event name constants
export const events = {
// Chat
newMessage: "new-message",
messageDeleted: "message-deleted",
messageEdited: "message-edited",
// Presence
typing: "client-typing",
stoppedTyping: "client-stopped-typing",
cursorMove: "client-cursor",
// Notifications
notification: "notification",
// Room
userJoined: "user-joined",
userLeft: "user-left",
} as const
// ── Publisher helpers ──────────────────────────────────────────────────────
export type ChatMessage = {
id: string
content: string
authorId: string
authorName: string
authorAvatar: string | null
createdAt: string
}
export async function publishChatMessage(roomId: string, message: ChatMessage) {
await pusherServer.trigger(channels.room(roomId), events.newMessage, message)
}
export async function publishNotification(userId: string, notification: {
id: string
type: string
title: string
body: string
url?: string
}) {
await pusherServer.trigger(channels.user(userId), events.notification, notification)
}
// Batch publish to multiple channels (up to 10)
export async function publishToMultipleRooms(
roomIds: string[],
event: string,
data: unknown,
) {
const chunks = []
for (let i = 0; i < roomIds.length; i += 10) {
chunks.push(roomIds.slice(i, i + 10))
}
for (const chunk of chunks) {
await pusherServer.triggerBatch(
chunk.map(roomId => ({
channel: channels.room(roomId),
name: event,
data: JSON.stringify(data),
})),
)
}
}
Auth Endpoint
// app/api/pusher/auth/route.ts — private/presence channel auth
import { NextRequest, NextResponse } from "next/server"
import { auth, currentUser } from "@clerk/nextjs/server"
import { pusherServer } from "@/lib/realtime/pusher"
import { db } from "@/lib/db"
import { roomMembers } from "@/lib/db/schema"
import { and, eq } from "drizzle-orm"
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) return new Response("Unauthorized", { status: 401 })
const body = await request.text()
const params = new URLSearchParams(body)
const socketId = params.get("socket_id")!
const channelName = params.get("channel_name")!
// Presence channel — include member info
if (channelName.startsWith("presence-")) {
const user = await currentUser()
const presenceData = {
user_id: userId,
user_info: {
name: user?.fullName ?? user?.username ?? "Anonymous",
avatar: user?.imageUrl ?? null,
},
}
const authResponse = pusherServer.authorizeChannel(socketId, channelName, presenceData)
return NextResponse.json(authResponse)
}
// Private room channel — verify membership
if (channelName.startsWith("private-room-")) {
const roomId = channelName.replace("private-room-", "")
const membership = await db.query.roomMembers.findFirst({
where: and(eq(roomMembers.roomId, roomId), eq(roomMembers.userId, userId)),
})
if (!membership) {
return new Response("Forbidden", { status: 403 })
}
}
// Private user channel — only allow own channel
if (channelName.startsWith("private-user-")) {
const channelUserId = channelName.replace("private-user-", "")
if (channelUserId !== userId) {
return new Response("Forbidden", { status: 403 })
}
}
const authResponse = pusherServer.authorizeChannel(socketId, channelName)
return NextResponse.json(authResponse)
}
Chat Message API
// app/api/rooms/[roomId]/messages/route.ts — send + publish message
import { NextRequest, NextResponse } from "next/server"
import { auth, currentUser } from "@clerk/nextjs/server"
import { pusherServer, channels, events } from "@/lib/realtime/pusher"
import { db } from "@/lib/db"
import { messages } from "@/lib/db/schema"
import { z } from "zod"
const SendMessageSchema = z.object({
content: z.string().min(1).max(4000),
})
export async function POST(
request: NextRequest,
{ params }: { params: { roomId: string } },
) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const { content } = SendMessageSchema.parse(body)
const user = await currentUser()
// Persist to DB
const [message] = await db.insert(messages).values({
roomId: params.roomId,
authorId: userId,
content,
}).returning()
// Publish via Pusher
await pusherServer.trigger(channels.room(params.roomId), events.newMessage, {
id: message.id,
content: message.content,
authorId: userId,
authorName: user?.fullName ?? user?.username ?? "Anonymous",
authorAvatar: user?.imageUrl ?? null,
createdAt: message.createdAt.toISOString(),
})
return NextResponse.json({ message })
}
React Pusher Hooks
// hooks/usePusher.ts — typed Pusher client hooks
"use client"
import PusherClient from "pusher-js"
import { useEffect, useRef, useCallback } from "react"
import type { Channel } from "pusher-js"
// Singleton Pusher client
let pusherInstance: PusherClient | null = null
function getPusher(): PusherClient {
if (!pusherInstance) {
pusherInstance = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
channelAuthorization: {
endpoint: "/api/pusher/auth",
transport: "ajax",
},
})
}
return pusherInstance
}
export function usePusherChannel(channelName: string | null) {
const channelRef = useRef<Channel | null>(null)
useEffect(() => {
if (!channelName) return
const pusher = getPusher()
const channel = pusher.subscribe(channelName)
channelRef.current = channel
return () => {
pusher.unsubscribe(channelName)
channelRef.current = null
}
}, [channelName])
const bind = useCallback(<T>(event: string, handler: (data: T) => void) => {
channelRef.current?.bind(event, handler)
return () => channelRef.current?.unbind(event, handler)
}, [])
const trigger = useCallback((event: string, data: unknown) => {
channelRef.current?.trigger(event, data)
}, [])
return { bind, trigger, channel: channelRef.current }
}
// Convenience hook for chat rooms
export function useChatRoom(roomId: string | null, onMessage: (msg: any) => void) {
const channelName = roomId ? `private-room-${roomId}` : null
const { bind, trigger } = usePusherChannel(channelName)
useEffect(() => {
if (!roomId) return
return bind("new-message", onMessage)
}, [roomId, bind, onMessage])
const sendTyping = useCallback(() => {
trigger("client-typing", {})
}, [trigger])
const stopTyping = useCallback(() => {
trigger("client-stopped-typing", {})
}, [trigger])
return { sendTyping, stopTyping }
}
// components/chat/ChatRoom.tsx — real-time chat UI
"use client"
import { useChatRoom } from "@/hooks/usePusher"
import { useState, useEffect, useRef, useCallback } from "react"
import type { ChatMessage } from "@/lib/realtime/pusher"
export function ChatRoom({ roomId, userId }: { roomId: string; userId: string }) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [typingUsers, setTypingUsers] = useState<string[]>([])
const bottomRef = useRef<HTMLDivElement>(null)
const handleNewMessage = useCallback((msg: ChatMessage) => {
setMessages(prev => [...prev, msg])
}, [])
const { sendTyping, stopTyping } = useChatRoom(roomId, handleNewMessage)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const sendMessage = async () => {
if (!input.trim()) return
const content = input
setInput("")
await fetch(`/api/rooms/${roomId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
})
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<div key={msg.id} className={`flex gap-2 ${msg.authorId === userId ? "flex-row-reverse" : ""}`}>
{msg.authorAvatar && (
<img src={msg.authorAvatar} alt="" className="size-8 rounded-full flex-shrink-0" />
)}
<div className={`max-w-xs px-3 py-2 rounded-2xl text-sm ${
msg.authorId === userId
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}>
{msg.content}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
<div className="p-3 border-t flex gap-2">
<input
value={input}
onChange={(e) => { setInput(e.target.value); sendTyping() }}
onBlur={stopTyping}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
placeholder="Type a message..."
className="flex-1 rounded-full px-4 py-2 text-sm border bg-background"
/>
<button
onClick={sendMessage}
disabled={!input.trim()}
className="px-4 py-2 rounded-full bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Send
</button>
</div>
</div>
)
}
For the Ably alternative when a more feature-rich pub/sub platform with message history replay on reconnect, occupancy metrics, reactor rules for serverless triggers, and MQTT/AMQP protocol support is needed — Ably has more protocol flexibility while Pusher has a simpler developer experience and broader community, see the Ably guide. For the Socket.IO alternative when a self-hosted WebSocket server with full control over the infrastructure, rooms, namespaces, and Redis pub/sub adapter for horizontal scaling is preferred — Socket.IO runs in your own Node.js process while Pusher is a fully managed service, see the Socket.IO guide. The Claude Skills 360 bundle includes Pusher skill sets covering channels, presence, and realtime chat. Start with the free tier to try realtime WebSocket generation.