Skip to main content
Back to Blog
Tutorials
3 min read
November 9, 2024

How to Build a Real-Time Chat Feature with Next.js

Build a real-time chat feature in Next.js using Pusher or Ably. Typing indicators, message history, and user presence.

Ryel Banfield

Founder & Lead Developer

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.

real-timechatWebSocketNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles