DEV Community

Cover image for Build a Real-Time Chat App with Next.js
Shpëtim Islami ⚡
Shpëtim Islami ⚡

Posted on

Build a Real-Time Chat App with Next.js

If you've ever used Websockets to add real-time features to a web app, you know how powerful WebSockets can be — and how quickly costs can spiral. Apinator is a drop-in alternative: a hosted real-time messaging platform with a Pusher-compatible SDK, private/presence channel support, and usage-based pricing that doesn't punish you for growing.

In this tutorial we'll build a fully working real-time chat room from scratch using Next.js 14 (App Router) and Apinator. By the end you'll have:

  • A live chat UI where messages appear instantly for all connected users
  • Private channel authentication (no anonymous broadcasting)
  • Presence indicators showing who's currently online
  • Clean, production-ready code you can extend for notifications, live cursors, or collaborative editing

What is Apinator?

Apinator provides hosted WebSocket infrastructure so you don't have to manage your own socket servers. You publish events from your backend; your frontend clients receive them in milliseconds across any number of connected users.

Key concepts:

Concept Description
App An isolated namespace with its own key/secret pair
Channel A named pub/sub topic clients subscribe to
Public channel Anyone can subscribe (e.g. chat-room)
Private channel Requires server-side auth (prefix: private-)
Presence channel Auth + online member list (prefix: presence-)
Event A named message published to a channel

Prerequisites

  • Node.js 18+
  • A free apinator.io account
  • Basic familiarity with Next.js App Router

Step 1 — Create an Apinator App

  1. Sign in to the Apinator console
  2. Click New App, give it a name (e.g. nextjs-chat), choose a cluster (eu or us)
  3. From the app's Keys page, copy:
    • App ID
    • Key (public)
    • Secret (keep this server-side only)

Step 2 — Bootstrap the Next.js Project

npx create-next-app@latest realtime-chat --typescript --tailwind --app
cd realtime-chat
Enter fullscreen mode Exit fullscreen mode

Install the Apinator SDKs:

npm install @apinator/client @apinator/server
Enter fullscreen mode Exit fullscreen mode
  • @apinator/client — browser client (WebSocket, channel subscriptions)
  • @apinator/server — server-side client (trigger events, sign channel auth)

Add your credentials to .env.local:

# Public — safe to expose to the browser
NEXT_PUBLIC_APINATOR_KEY=your_key
NEXT_PUBLIC_APINATOR_CLUSTER=eu

# Private — server only
APINATOR_APP_ID=your_app_id
APINATOR_SECRET=your_secret
Enter fullscreen mode Exit fullscreen mode

Step 3 — Server-Side: Channel Auth Endpoint

Private channels require your server to sign the subscription. Create the auth route:

// app/api/auth/channel/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Apinator } from "@apinator/server";

const apinator = new Apinator({
  appId: process.env.APINATOR_APP_ID!,
  key: process.env.NEXT_PUBLIC_APINATOR_KEY!,
  secret: process.env.APINATOR_SECRET!,
  cluster: process.env.NEXT_PUBLIC_APINATOR_CLUSTER! as "eu" | "us",
});

export async function POST(req: NextRequest) {
  const { socket_id, channel_name, username } = await req.json();

  if (!socket_id || !channel_name) {
    return NextResponse.json({ error: "Missing params" }, { status: 400 });
  }

  // In a real app, verify the user's session here before signing
  const channelData =
    channel_name.startsWith("presence-")
      ? JSON.stringify({
          user_id: socket_id, // use a real user ID from your auth system
          user_info: { name: username ?? "Anonymous" },
        })
      : undefined;

  const auth = apinator.authenticateChannel(socket_id, channel_name, channelData);
  return NextResponse.json(auth);
}
Enter fullscreen mode Exit fullscreen mode

Step 4 — Server-Side: Send Messages

Add an API route to receive new messages and broadcast them via Apinator:

// app/api/messages/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Apinator } from "@apinator/server";

const apinator = new Apinator({
  appId: process.env.APINATOR_APP_ID!,
  key: process.env.NEXT_PUBLIC_APINATOR_KEY!,
  secret: process.env.APINATOR_SECRET!,
  cluster: process.env.NEXT_PUBLIC_APINATOR_CLUSTER! as "eu" | "us",
});

export async function POST(req: NextRequest) {
  const { text, username, socketId } = await req.json();

  if (!text?.trim()) {
    return NextResponse.json({ error: "Empty message" }, { status: 400 });
  }

  await apinator.trigger({
    name: "new-message",
    channel: "presence-chat-room",
    data: JSON.stringify({
      text: text.trim(),
      username: username ?? "Anonymous",
      timestamp: Date.now(),
    }),
    // Exclude the sender's own socket so they don't receive an echo
    socketId,
  });

  return NextResponse.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

The socketId exclusion is optional but gives a snappier UX — the sender sees their message immediately from local state while everyone else receives it via WebSocket.


Step 5 — Client-Side: The Chat Hook

Create a custom hook that manages the Apinator connection and channel subscription:

// hooks/useChat.ts
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import { Apinator, type PresenceChannel } from "@apinator/client";

export type ChatMessage = {
  id: string;
  text: string;
  username: string;
  timestamp: number;
  self: boolean;
};

export type OnlineMember = {
  user_id: string;
  user_info: { name: string };
};

export function useChat(username: string) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [members, setMembers] = useState<OnlineMember[]>([]);
  const [connected, setConnected] = useState(false);
  const clientRef = useRef<Apinator | null>(null);
  const socketIdRef = useRef<string | null>(null);

  useEffect(() => {
    const client = new Apinator({
      key: process.env.NEXT_PUBLIC_APINATOR_KEY!,
      cluster: process.env.NEXT_PUBLIC_APINATOR_CLUSTER! as "eu" | "us",
      authEndpoint: "/api/auth/channel",
      // Pass the username so the auth endpoint can include it in channel_data
      authHeaders: { "x-username": username },
    });

    client.connect();
    clientRef.current = client;

    client.bind("state_change", ({ current }: { current: string }) => {
      setConnected(current === "connected");
    });

    // Subscribe to a presence channel so we get the online member list
    const channel = client.subscribe("presence-chat-room") as PresenceChannel;

    channel.bind("realtime:subscription_succeeded", () => {
      const memberList = channel.getMembers() as OnlineMember[];
      setMembers(memberList);
    });

    channel.bind("realtime:member_added", (member: OnlineMember) => {
      setMembers((prev) => [...prev, member]);
    });

    channel.bind("realtime:member_removed", ({ user_id }: { user_id: string }) => {
      setMembers((prev) => prev.filter((m) => m.user_id !== user_id));
    });

    channel.bind("new-message", (data: Omit<ChatMessage, "id" | "self">) => {
      setMessages((prev) => [
        ...prev,
        {
          ...data,
          id: `${data.timestamp}-${Math.random()}`,
          self: false,
        },
      ]);
    });

    return () => {
      client.unsubscribe("presence-chat-room");
      client.disconnect();
    };
  }, [username]);

  const sendMessage = useCallback(
    async (text: string) => {
      if (!text.trim()) return;

      const socketId = clientRef.current?.socketId ?? undefined;

      // Optimistically add the message locally
      setMessages((prev) => [
        ...prev,
        {
          id: `${Date.now()}-self`,
          text,
          username,
          timestamp: Date.now(),
          self: true,
        },
      ]);

      await fetch("/api/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text, username, socketId }),
      });
    },
    [username]
  );

  return { messages, members, connected, sendMessage };
}
Enter fullscreen mode Exit fullscreen mode

Step 6 — Client-Side: Chat UI

// app/chat/page.tsx
"use client";

import { useState, useRef, useEffect, FormEvent } from "react";
import { useChat } from "@/hooks/useChat";

export default function ChatPage() {
  const [username] = useState(
    () => `User${Math.floor(Math.random() * 1000)}`
  );
  const { messages, members, connected, sendMessage } = useChat(username);
  const [input, setInput] = useState("");
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!input.trim()) return;
    await sendMessage(input);
    setInput("");
  }

  return (
    <div className="flex h-screen bg-gray-50">
      {/* Sidebar — online members */}
      <aside className="w-56 bg-white border-r p-4 flex flex-col gap-2">
        <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">
          Online — {members.length}
        </h2>
        {members.map((m) => (
          <div key={m.user_id} className="flex items-center gap-2">
            <span className="w-2 h-2 rounded-full bg-green-400" />
            <span className="text-sm text-gray-700">{m.user_info.name}</span>
          </div>
        ))}
      </aside>

      {/* Chat area */}
      <div className="flex-1 flex flex-col">
        {/* Header */}
        <header className="border-b px-6 py-3 bg-white flex items-center gap-3">
          <span className="font-semibold text-gray-800">#general</span>
          <span
            className={`text-xs px-2 py-0.5 rounded-full ${
              connected
                ? "bg-green-100 text-green-700"
                : "bg-yellow-100 text-yellow-700"
            }`}
          >
            {connected ? "Connected" : "Connecting…"}
          </span>
        </header>

        {/* Messages */}
        <div className="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-3">
          {messages.map((msg) => (
            <div
              key={msg.id}
              className={`flex flex-col ${msg.self ? "items-end" : "items-start"}`}
            >
              <span className="text-xs text-gray-400 mb-1">{msg.username}</span>
              <div
                className={`px-4 py-2 rounded-2xl text-sm max-w-xs ${
                  msg.self
                    ? "bg-blue-500 text-white rounded-tr-sm"
                    : "bg-white text-gray-800 shadow-sm rounded-tl-sm"
                }`}
              >
                {msg.text}
              </div>
            </div>
          ))}
          <div ref={bottomRef} />
        </div>

        {/* Input */}
        <form
          onSubmit={handleSubmit}
          className="border-t px-6 py-4 bg-white flex gap-3"
        >
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Type a message…"
            className="flex-1 rounded-full border px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-300"
          />
          <button
            type="submit"
            disabled={!input.trim() || !connected}
            className="bg-blue-500 text-white rounded-full px-5 py-2 text-sm font-medium disabled:opacity-40"
          >
            Send
          </button>
        </form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 7 — Fix the Auth Endpoint to Use Headers

Update the auth route to read the username from the request header:

// app/api/auth/channel/route.ts  (updated section)
const username = req.headers.get("x-username") ?? "Anonymous";
Enter fullscreen mode Exit fullscreen mode

Run It

npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000/chat in two browser tabs. Type a message in one — it appears instantly in the other.


How It Works (Architecture Overview)

Browser A                  Next.js Server            Apinator
   |                            |                        |
   |-- POST /api/messages ------>|                        |
   |                            |-- trigger(event) ------>|
   |                            |                        |
   |<-- WebSocket push ------------------------------------|
Browser B                                               |
   |<-- WebSocket push ------------------------------------|
Enter fullscreen mode Exit fullscreen mode
  1. User submits a message → hits your Next.js API route
  2. API route calls apinator.trigger() to publish the event
  3. Apinator fans out the event to all connected WebSocket clients subscribed to that channel
  4. The sender's own socket is excluded (via socketId) to avoid double-rendering

What to Build Next

Private DMs — Create a channel per user pair: private-dm-{userId1}-{userId2}. The auth endpoint checks that the requesting user is one of the participants.

Typing indicators — Use Apinator's client events (client- prefix). These travel peer-to-peer through the server without hitting your API:

channel.trigger("client-typing", { username });
channel.bind("client-typing", ({ username }) => showTyping(username));
Enter fullscreen mode Exit fullscreen mode

Notifications — Subscribe users to a private private-user-{id} channel on login. Trigger events from any backend service to push notifications in real time.

Webhook processing — Use client.verifyWebhook(headers, body) to securely receive delivery confirmations and channel lifecycle events from Apinator.


Closing Thoughts

Apinator gets you from zero to real-time in under an hour with a clean, well-typed SDK that feels right at home in a TypeScript/Next.js project. The same mental model (apps → channels → events) scales from a weekend project to production traffic without re-architecting.

Top comments (0)