Claude Code for Pusher: Realtime WebSocket Channels — Claude Skills 360 Blog
Blog / Backend / Claude Code for Pusher: Realtime WebSocket Channels
Backend

Claude Code for Pusher: Realtime WebSocket Channels

Published: May 29, 2027
Read time: 6 min read
By: Claude Skills 360

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.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free