Real-time chat requires WebSocket connections. Pusher makes this easy without managing your own WebSocket server.
Step 1: Install Dependencies
pnpm add pusher pusher-js
Step 2: Configure Pusher
// lib/pusher-server.ts
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,
});
// lib/pusher-client.ts
import PusherClient from "pusher-js";
export const pusherClient = new PusherClient(
process.env.NEXT_PUBLIC_PUSHER_KEY!,
{
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
}
);
Step 3: API Route for Sending Messages
// app/api/messages/route.ts
import { NextRequest, NextResponse } from "next/server";
import { pusher } from "@/lib/pusher-server";
export async function POST(req: NextRequest) {
const { message, channelId, userId, username } = await req.json();
if (!message?.trim()) {
return NextResponse.json({ error: "Message required" }, { status: 400 });
}
const messageData = {
id: crypto.randomUUID(),
text: message.trim(),
userId,
username,
timestamp: new Date().toISOString(),
};
// Trigger the event on Pusher
await pusher.trigger(`chat-${channelId}`, "new-message", messageData);
// Optionally save to database
// await db.insert(messages).values(messageData);
return NextResponse.json({ success: true, message: messageData });
}
Step 4: Chat Component
"use client";
import { useState, useEffect, useRef } from "react";
import { pusherClient } from "@/lib/pusher-client";
interface Message {
id: string;
text: string;
userId: string;
username: string;
timestamp: string;
}
export function Chat({
channelId,
currentUser,
}: {
channelId: string;
currentUser: { id: string; name: string };
}) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// Subscribe to channel
useEffect(() => {
const channel = pusherClient.subscribe(`chat-${channelId}`);
channel.bind("new-message", (message: Message) => {
setMessages((prev) => [...prev, message]);
});
return () => {
channel.unbind_all();
pusherClient.unsubscribe(`chat-${channelId}`);
};
}, [channelId]);
// Auto-scroll to bottom
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function sendMessage(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || sending) return;
setSending(true);
try {
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: input,
channelId,
userId: currentUser.id,
username: currentUser.name,
}),
});
setInput("");
} finally {
setSending(false);
}
}
return (
<div className="flex h-[500px] flex-col rounded-xl border dark:border-gray-700">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${
msg.userId === currentUser.id ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
msg.userId === currentUser.id
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white"
}`}
>
{msg.userId !== currentUser.id && (
<p className="mb-0.5 text-xs font-medium opacity-70">
{msg.username}
</p>
)}
<p className="text-sm">{msg.text}</p>
<p className="mt-1 text-[10px] opacity-50">
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<form
onSubmit={sendMessage}
className="flex gap-2 border-t p-4 dark:border-gray-700"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 rounded-lg border bg-transparent px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600"
/>
<button
type="submit"
disabled={!input.trim() || sending}
className="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</form>
</div>
);
}
Step 5: Typing Indicators
// API route for typing events
export async function POST(req: NextRequest) {
const { channelId, userId, username } = await req.json();
await pusher.trigger(`chat-${channelId}`, "typing", { userId, username });
return NextResponse.json({ success: true });
}
// In Chat component
const [typingUsers, setTypingUsers] = useState<string[]>([]);
useEffect(() => {
const channel = pusherClient.subscribe(`chat-${channelId}`);
channel.bind("typing", ({ username }: { username: string }) => {
setTypingUsers((prev) =>
prev.includes(username) ? prev : [...prev, username]
);
// Remove after 2 seconds
setTimeout(() => {
setTypingUsers((prev) => prev.filter((u) => u !== username));
}, 2000);
});
return () => channel.unbind("typing");
}, [channelId]);
// Trigger typing on input change
function handleInputChange(value: string) {
setInput(value);
fetch("/api/typing", {
method: "POST",
body: JSON.stringify({
channelId,
userId: currentUser.id,
username: currentUser.name,
}),
});
}
// Display typing indicator
{typingUsers.length > 0 && (
<p className="px-4 text-xs text-gray-500 italic">
{typingUsers.join(", ")} {typingUsers.length === 1 ? "is" : "are"} typing...
</p>
)}
Step 6: Load Message History
// app/api/messages/[channelId]/route.ts
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ channelId: string }> }
) {
const { channelId } = await params;
const messages = await db
.select()
.from(messagesTable)
.where(eq(messagesTable.channelId, channelId))
.orderBy(asc(messagesTable.timestamp))
.limit(50);
return NextResponse.json(messages);
}
// Load history on mount
useEffect(() => {
fetch(`/api/messages/${channelId}`)
.then((res) => res.json())
.then(setMessages);
}, [channelId]);
Step 7: Online Presence
// Use Pusher presence channels
const channel = pusherClient.subscribe(`presence-chat-${channelId}`);
channel.bind("pusher:subscription_succeeded", (members: any) => {
setOnlineUsers(Object.keys(members.members));
});
channel.bind("pusher:member_added", (member: any) => {
setOnlineUsers((prev) => [...prev, member.id]);
});
channel.bind("pusher:member_removed", (member: any) => {
setOnlineUsers((prev) => prev.filter((id) => id !== member.id));
});
Need Real-Time Features?
We build web applications with real-time messaging, notifications, and collaboration features. Contact us to discuss your project.